From 01d102f5efca4aa64964e795d7ac54fbd6e2547e Mon Sep 17 00:00:00 2001 From: user Date: Mon, 27 Apr 2026 11:33:20 +0900 Subject: [PATCH] style: improve frontend UX/UI - visual states, loading feedback, typography, and accessibility --- internal/webapp/static/app.js | 96 ++++++++++++--- internal/webapp/static/index.html | 51 +++++--- internal/webapp/static/styles.css | 190 +++++++++++++++++++++++++++--- 3 files changed, 288 insertions(+), 49 deletions(-) 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 - + -

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 - +
- Ingest material to inspect ontology candidates. + Ingest material to inspect ontology candidates.
- +
- Generate a prompt to inspect model key, review state, and evidence. + Generate a prompt to inspect model key, review state, and evidence.
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; }