diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js
index c7090bb..589e545 100644
--- a/internal/webapp/static/app.js
+++ b/internal/webapp/static/app.js
@@ -27,10 +27,51 @@ const els = {
title: document.querySelector("#session-title"),
};
+const readinessClassMap = {
+ unknown: "pill-neutral",
+ fragile: "pill-weak",
+ improving: "pill-warn",
+ interview_ready: "pill-good",
+ strong_signal: "pill-strong",
+};
+
+const reviewClassMap = {
+ candidate: "pill-neutral",
+ reviewed: "pill-good",
+};
+
+const gradeClassMap = {
+ miss: "grade-miss",
+ partial: "grade-partial",
+ solid: "grade-solid",
+ strong: "grade-strong",
+};
+
+function setButtonLoading(button, loadingText) {
+ button.disabled = true;
+ button.classList.add("is-loading");
+ const textEl = button.querySelector(".btn-text");
+ if (textEl && loadingText) {
+ textEl.dataset.originalText = textEl.textContent;
+ textEl.textContent = loadingText;
+ }
+}
+
+function clearButtonLoading(button) {
+ button.disabled = false;
+ button.classList.remove("is-loading");
+ const textEl = button.querySelector(".btn-text");
+ if (textEl && textEl.dataset.originalText) {
+ textEl.textContent = textEl.dataset.originalText;
+ delete textEl.dataset.originalText;
+ }
+}
+
els.sessionForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
- setStatus("Creating diagnostic session...");
+ setStatus("Creating diagnostic session...", true);
+ setButtonLoading(event.submitter || document.querySelector("#start-button"), "Starting...");
const payload = {
user_id: value("#user-id"),
@@ -54,6 +95,8 @@ els.sessionForm.addEventListener("submit", async (event) => {
} catch (error) {
showError(error.message);
setStatus("Ready");
+ } finally {
+ clearButtonLoading(document.querySelector("#start-button"));
}
});
@@ -67,8 +110,8 @@ els.answerForm.addEventListener("submit", async (event) => {
clearError();
if (!state.session || !state.selectedQuestion) return;
- setStatus("Submitting answer...");
- els.answerButton.disabled = true;
+ setStatus("Submitting answer...", true);
+ setButtonLoading(event.submitter || els.answerButton, "Grading...");
try {
const answer = await request(`/api/v1/diagnostic-sessions/${state.session.id}/answers`, {
@@ -86,6 +129,7 @@ els.answerForm.addEventListener("submit", async (event) => {
showError(error.message);
setStatus("Session ready");
} finally {
+ clearButtonLoading(els.answerButton);
els.answerButton.disabled = !state.selectedQuestion;
}
});
@@ -93,7 +137,8 @@ els.answerForm.addEventListener("submit", async (event) => {
els.materialForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
- setStatus("Ingesting material...");
+ setStatus("Ingesting material...", true);
+ setButtonLoading(event.submitter || document.querySelector("#material-button"), "Ingesting...");
try {
const result = await request("/api/v1/materials", {
@@ -110,13 +155,16 @@ els.materialForm.addEventListener("submit", async (event) => {
} catch (error) {
showError(error.message);
setStatus("Content workspace ready");
+ } finally {
+ clearButtonLoading(document.querySelector("#material-button"));
}
});
els.assetForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
- setStatus("Generating prompt candidate...");
+ setStatus("Generating prompt candidate...", true);
+ setButtonLoading(event.submitter || els.assetButton, "Generating...");
try {
const prompt = await request("/api/v1/teaching-assets/prompts", {
@@ -132,12 +180,14 @@ els.assetForm.addEventListener("submit", async (event) => {
} catch (error) {
showError(error.message);
setStatus("Content workspace ready");
+ } finally {
+ clearButtonLoading(els.assetButton);
}
});
function renderSession() {
if (!state.session) return;
- els.title.textContent = `${state.session.target_role} - ${state.session.questions.length} questions`;
+ els.title.textContent = `${state.session.target_role} — ${state.session.questions.length} questions`;
els.questions.className = "question-list";
els.questions.innerHTML = "";
@@ -146,7 +196,7 @@ function renderSession() {
button.type = "button";
button.className = "question-button";
button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id));
- button.innerHTML = `${question.id} ${escapeHTML(question.prompt)}`;
+ button.innerHTML = `${escapeHTML(question.id)} ${escapeHTML(question.prompt)}`;
button.addEventListener("click", () => {
state.selectedQuestion = question;
els.answerText.value = "";
@@ -161,7 +211,7 @@ function renderSession() {
async function refreshProgress() {
if (!state.session) return;
- setStatus("Refreshing learning progress...");
+ setStatus("Refreshing learning progress...", true);
try {
const userID = encodeURIComponent(state.session.user_id);
@@ -183,7 +233,7 @@ function renderProgress() {
els.refreshProgress.disabled = !state.session;
if (!state.progress) {
els.progress.className = "feedback empty-state";
- els.progress.textContent = "Answer once to update learner memory and readiness.";
+ els.progress.innerHTML = `Answer once to update learner memory and readiness. `;
return;
}
@@ -197,11 +247,14 @@ function renderProgress() {
Concept memory
- ${mastery.map((item) => `${escapeHTML(item.concept.label)} - ${escapeHTML(item.state)} `).join("")}
+ ${mastery.map((item) => {
+ const cls = readinessClassMap[item.state] || "pill-neutral";
+ return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.state)} `;
+ }).join("")}
Next challenge
- ${escapeHTML(challenge.concept.label)} - ${escapeHTML(challenge.ladder_level)}
+ ${escapeHTML(challenge.concept.label)} — ${escapeHTML(challenge.ladder_level)}
${escapeHTML(challenge.question)}
`;
@@ -210,7 +263,7 @@ function renderProgress() {
function renderOntology() {
if (!state.ontology) {
els.ontology.className = "ontology-view empty-state";
- els.ontology.textContent = "Ingest material to inspect ontology candidates.";
+ els.ontology.innerHTML = `Ingest material to inspect ontology candidates. `;
return;
}
@@ -224,7 +277,10 @@ function renderOntology() {
Candidate concepts
- ${concepts.map((item) => `${escapeHTML(item.concept.label)} - ${escapeHTML(item.review_state)} `).join("") || "No candidates yet."}
+ ${concepts.map((item) => {
+ const cls = reviewClassMap[item.review_state] || "pill-neutral";
+ return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.review_state)} `;
+ }).join("") || "No candidates yet."}
`;
@@ -238,7 +294,7 @@ function renderOntology() {
function renderAssetPrompt() {
if (!state.assetPrompt) {
els.assetOutput.className = "ontology-view empty-state";
- els.assetOutput.textContent = "Generate a prompt to inspect model key, review state, and evidence.";
+ els.assetOutput.innerHTML = `Generate a prompt to inspect model key, review state, and evidence. `;
return;
}
@@ -258,15 +314,16 @@ function renderAssetPrompt() {
function renderFeedback() {
if (!state.lastAnswer) {
els.feedback.className = "feedback empty-state";
- els.feedback.textContent = "Submit an answer to see grade, evidence, and follow-up.";
+ els.feedback.innerHTML = `Submit an answer to see grade, evidence, and follow-up. `;
return;
}
const grade = state.lastAnswer.grade;
+ const gradeClass = gradeClassMap[grade.overall] || "";
els.feedback.className = "feedback";
els.feedback.innerHTML = `
-
${escapeHTML(grade.overall)}
+
${escapeHTML(grade.overall)}
${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}
${scoreRows(grade.scores)}
@@ -318,8 +375,11 @@ function value(selector) {
return document.querySelector(selector).value.trim();
}
-function setStatus(message) {
- els.status.textContent = message;
+function setStatus(message, busy = false) {
+ const textEl = els.status.querySelector(".status-text");
+ if (textEl) textEl.textContent = message;
+ else els.status.textContent = message;
+ els.status.classList.toggle("is-busy", busy);
}
function showError(message) {
diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html
index e07d4dc..e957d4c 100644
--- a/internal/webapp/static/index.html
+++ b/internal/webapp/static/index.html
@@ -30,26 +30,37 @@
Timeline
- Start diagnostic
+
+ Start diagnostic
+
+
- Ready
+
+
+ Ready
+
-
Diagnostic
-
No active session
+
+
Diagnostic
+
No active session
+
- Start a diagnostic session to load interview questions.
+ Start a diagnostic session to load interview questions.
@@ -73,17 +84,22 @@
Source material
Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs and database indexes support query plans.
- Ingest material
+
+ Ingest material
+
+
- Ingest material to inspect ontology candidates.
+ Ingest material to inspect ontology candidates.
Concept
-
+
+ Select a concept
+
Asset type
@@ -94,22 +110,27 @@
Interview card
- Generate prompt
+
+ Generate prompt
+
+
- Generate a prompt to inspect model key, review state, and evidence.
+ Generate a prompt to inspect model key, review state, and evidence.
-
Feedback
-
Rubric result
+
+
Feedback
+
Rubric result
+
- Submit an answer to see grade, evidence, and follow-up.
+ Submit an answer to see grade, evidence, and follow-up.
@@ -119,7 +140,7 @@
Refresh
- Answer once to update learner memory and readiness.
+ Answer once to update learner memory and readiness.
diff --git a/internal/webapp/static/styles.css b/internal/webapp/static/styles.css
index 12ec64d..72d613a 100644
--- a/internal/webapp/static/styles.css
+++ b/internal/webapp/static/styles.css
@@ -9,6 +9,16 @@
--accent: #19764b;
--accent-dark: #105c39;
--danger: #a93a2f;
+ --warn: #b45f1a;
+ --warn-bg: #fff6eb;
+ --weak: #a93a2f;
+ --weak-bg: #fdf2f1;
+ --good: #1a6b8f;
+ --good-bg: #eef7fb;
+ --strong: #19764b;
+ --strong-bg: #f0faf3;
+ --neutral: #6b7570;
+ --neutral-bg: #f4f5f4;
}
* {
@@ -22,6 +32,8 @@ body {
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
}
button,
@@ -57,7 +69,12 @@ select {
color: var(--accent);
font-size: 12px;
font-weight: 750;
- text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+html[lang="ko"] .eyebrow {
+ text-transform: none;
+ letter-spacing: 0.02em;
}
h1,
@@ -67,8 +84,8 @@ h2 {
}
h1 {
- max-width: 9ch;
- font-size: clamp(42px, 6vw, 74px);
+ font-size: clamp(36px, 5vw, 64px);
+ line-height: 1.05;
}
h2 {
@@ -118,6 +135,14 @@ select {
color: var(--text);
padding: 12px;
outline: none;
+ appearance: none;
+}
+
+select {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b665f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ padding-right: 36px;
}
textarea {
@@ -141,29 +166,86 @@ button {
color: #fff;
cursor: pointer;
font-weight: 750;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ position: relative;
}
button:hover:not(:disabled) {
background: var(--accent-dark);
}
+button:active:not(:disabled) {
+ transform: translateY(1px);
+}
+
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
-.status-line,
-.error-line {
- min-height: 20px;
- margin: 18px 0 0;
- font-size: 13px;
+.btn-spinner {
+ display: none;
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.35);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+button.is-loading .btn-text {
+ opacity: 0.9;
+}
+
+button.is-loading .btn-spinner {
+ display: inline-block;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
}
.status-line {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 20px;
+ margin: 18px 0 0;
+ font-size: 13px;
color: var(--muted);
}
+.status-icon {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+}
+
+.status-line.is-busy .status-icon {
+ background: var(--warn);
+ animation: pulse 1.2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.35;
+ }
+}
+
.error-line {
+ min-height: 20px;
+ margin: 10px 0 0;
+ font-size: 13px;
color: var(--danger);
}
@@ -181,15 +263,23 @@ button:disabled {
.question-button {
border: 1px solid var(--line);
+ border-left: 3px solid transparent;
background: #fbfcfa;
color: var(--text);
padding: 16px;
min-height: 72px;
text-align: left;
+ border-radius: 6px;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.question-button:hover:not(:disabled) {
+ border-color: var(--accent);
}
.question-button[aria-pressed="true"] {
border-color: var(--accent);
+ border-left-color: var(--accent);
background: var(--surface-muted);
}
@@ -205,7 +295,21 @@ button:disabled {
border: 1px dashed var(--line);
border-radius: 6px;
color: var(--muted);
- padding: 18px;
+ padding: 22px 18px;
+ text-align: center;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.empty-hint::before {
+ content: "";
+ display: block;
+ width: 24px;
+ height: 24px;
+ margin: 0 auto 10px;
+ opacity: 0.45;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%235b665f' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
+ background-size: contain;
}
.feedback {
@@ -232,10 +336,30 @@ button:disabled {
border-bottom: 1px solid var(--line);
}
+.metric-row:last-child {
+ border-bottom: 0;
+}
+
.grade {
- color: var(--accent);
font-size: 38px;
font-weight: 800;
+ line-height: 1.1;
+}
+
+.grade-miss {
+ color: var(--weak);
+}
+
+.grade-partial {
+ color: var(--warn);
+}
+
+.grade-solid {
+ color: var(--good);
+}
+
+.grade-strong {
+ color: var(--strong);
}
.readiness-value {
@@ -250,10 +374,41 @@ button:disabled {
margin: 4px 6px 4px 0;
border: 1px solid var(--line);
border-radius: 999px;
- padding: 6px 9px;
+ padding: 6px 10px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
+ background: var(--surface);
+}
+
+.pill-neutral {
+ background: var(--neutral-bg);
+ border-color: #d5dad3;
+ color: var(--neutral);
+}
+
+.pill-weak {
+ background: var(--weak-bg);
+ border-color: #e8bdb9;
+ color: var(--weak);
+}
+
+.pill-warn {
+ background: var(--warn-bg);
+ border-color: #edd5b5;
+ color: var(--warn);
+}
+
+.pill-good {
+ background: var(--good-bg);
+ border-color: #b8d9e8;
+ color: var(--good);
+}
+
+.pill-strong {
+ background: var(--strong-bg);
+ border-color: #b8dfc6;
+ color: var(--strong);
}
.small-list {
@@ -264,10 +419,11 @@ button:disabled {
}
.content-workspace {
- border-top: 1px solid var(--line);
+ border-top: 2px solid var(--line);
display: grid;
gap: 18px;
- padding-top: 24px;
+ padding-top: 28px;
+ margin-top: 6px;
}
.ontology-view {
@@ -283,11 +439,11 @@ button:disabled {
.summary-chip {
background: var(--surface-muted);
- border-radius: 6px;
+ border-radius: 999px;
color: var(--muted);
font-size: 12px;
font-weight: 750;
- padding: 9px 11px;
+ padding: 7px 12px;
}
.prompt-text {
@@ -297,6 +453,9 @@ button:disabled {
margin: 0;
padding: 14px;
white-space: pre-wrap;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text);
}
@media (max-width: 980px) {
@@ -305,7 +464,6 @@ button:disabled {
}
h1 {
- max-width: 100%;
font-size: 42px;
}