style: improve frontend UX/UI - visual states, loading feedback, typography, and accessibility

This commit is contained in:
user
2026-04-27 11:33:20 +09:00
parent c54da12a4c
commit 01d102f5ef
3 changed files with 288 additions and 49 deletions

View File

@@ -27,10 +27,51 @@ const els = {
title: document.querySelector("#session-title"), 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) => { els.sessionForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
clearError(); clearError();
setStatus("Creating diagnostic session..."); setStatus("Creating diagnostic session...", true);
setButtonLoading(event.submitter || document.querySelector("#start-button"), "Starting...");
const payload = { const payload = {
user_id: value("#user-id"), user_id: value("#user-id"),
@@ -54,6 +95,8 @@ els.sessionForm.addEventListener("submit", async (event) => {
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
setStatus("Ready"); setStatus("Ready");
} finally {
clearButtonLoading(document.querySelector("#start-button"));
} }
}); });
@@ -67,8 +110,8 @@ els.answerForm.addEventListener("submit", async (event) => {
clearError(); clearError();
if (!state.session || !state.selectedQuestion) return; if (!state.session || !state.selectedQuestion) return;
setStatus("Submitting answer..."); setStatus("Submitting answer...", true);
els.answerButton.disabled = true; setButtonLoading(event.submitter || els.answerButton, "Grading...");
try { try {
const answer = await request(`/api/v1/diagnostic-sessions/${state.session.id}/answers`, { 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); showError(error.message);
setStatus("Session ready"); setStatus("Session ready");
} finally { } finally {
clearButtonLoading(els.answerButton);
els.answerButton.disabled = !state.selectedQuestion; els.answerButton.disabled = !state.selectedQuestion;
} }
}); });
@@ -93,7 +137,8 @@ els.answerForm.addEventListener("submit", async (event) => {
els.materialForm.addEventListener("submit", async (event) => { els.materialForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
clearError(); clearError();
setStatus("Ingesting material..."); setStatus("Ingesting material...", true);
setButtonLoading(event.submitter || document.querySelector("#material-button"), "Ingesting...");
try { try {
const result = await request("/api/v1/materials", { const result = await request("/api/v1/materials", {
@@ -110,13 +155,16 @@ els.materialForm.addEventListener("submit", async (event) => {
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
setStatus("Content workspace ready"); setStatus("Content workspace ready");
} finally {
clearButtonLoading(document.querySelector("#material-button"));
} }
}); });
els.assetForm.addEventListener("submit", async (event) => { els.assetForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
clearError(); clearError();
setStatus("Generating prompt candidate..."); setStatus("Generating prompt candidate...", true);
setButtonLoading(event.submitter || els.assetButton, "Generating...");
try { try {
const prompt = await request("/api/v1/teaching-assets/prompts", { const prompt = await request("/api/v1/teaching-assets/prompts", {
@@ -132,12 +180,14 @@ els.assetForm.addEventListener("submit", async (event) => {
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
setStatus("Content workspace ready"); setStatus("Content workspace ready");
} finally {
clearButtonLoading(els.assetButton);
} }
}); });
function renderSession() { function renderSession() {
if (!state.session) return; 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.className = "question-list";
els.questions.innerHTML = ""; els.questions.innerHTML = "";
@@ -146,7 +196,7 @@ function renderSession() {
button.type = "button"; button.type = "button";
button.className = "question-button"; button.className = "question-button";
button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id)); button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id));
button.innerHTML = `<span class="question-id">${question.id}</span>${escapeHTML(question.prompt)}`; button.innerHTML = `<span class="question-id">${escapeHTML(question.id)}</span>${escapeHTML(question.prompt)}`;
button.addEventListener("click", () => { button.addEventListener("click", () => {
state.selectedQuestion = question; state.selectedQuestion = question;
els.answerText.value = ""; els.answerText.value = "";
@@ -161,7 +211,7 @@ function renderSession() {
async function refreshProgress() { async function refreshProgress() {
if (!state.session) return; if (!state.session) return;
setStatus("Refreshing learning progress..."); setStatus("Refreshing learning progress...", true);
try { try {
const userID = encodeURIComponent(state.session.user_id); const userID = encodeURIComponent(state.session.user_id);
@@ -183,7 +233,7 @@ function renderProgress() {
els.refreshProgress.disabled = !state.session; els.refreshProgress.disabled = !state.session;
if (!state.progress) { if (!state.progress) {
els.progress.className = "feedback empty-state"; els.progress.className = "feedback empty-state";
els.progress.textContent = "Answer once to update learner memory and readiness."; els.progress.innerHTML = `<span class="empty-hint">Answer once to update learner memory and readiness.</span>`;
return; return;
} }
@@ -197,11 +247,14 @@ function renderProgress() {
</section> </section>
<section> <section>
<h2>Concept memory</h2> <h2>Concept memory</h2>
<div>${mastery.map((item) => `<span class="concept-pill">${escapeHTML(item.concept.label)} - ${escapeHTML(item.state)}</span>`).join("")}</div> <div>${mastery.map((item) => {
const cls = readinessClassMap[item.state] || "pill-neutral";
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)}${escapeHTML(item.state)}</span>`;
}).join("")}</div>
</section> </section>
<section> <section>
<h2>Next challenge</h2> <h2>Next challenge</h2>
<p class="status-line">${escapeHTML(challenge.concept.label)} - ${escapeHTML(challenge.ladder_level)}</p> <p class="status-line">${escapeHTML(challenge.concept.label)} ${escapeHTML(challenge.ladder_level)}</p>
<p>${escapeHTML(challenge.question)}</p> <p>${escapeHTML(challenge.question)}</p>
</section> </section>
`; `;
@@ -210,7 +263,7 @@ function renderProgress() {
function renderOntology() { function renderOntology() {
if (!state.ontology) { if (!state.ontology) {
els.ontology.className = "ontology-view empty-state"; els.ontology.className = "ontology-view empty-state";
els.ontology.textContent = "Ingest material to inspect ontology candidates."; els.ontology.innerHTML = `<span class="empty-hint">Ingest material to inspect ontology candidates.</span>`;
return; return;
} }
@@ -224,7 +277,10 @@ function renderOntology() {
</div> </div>
<section> <section>
<h2>Candidate concepts</h2> <h2>Candidate concepts</h2>
<div>${concepts.map((item) => `<span class="concept-pill">${escapeHTML(item.concept.label)} - ${escapeHTML(item.review_state)}</span>`).join("") || "No candidates yet."}</div> <div>${concepts.map((item) => {
const cls = reviewClassMap[item.review_state] || "pill-neutral";
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)}${escapeHTML(item.review_state)}</span>`;
}).join("") || "No candidates yet."}</div>
</section> </section>
`; `;
@@ -238,7 +294,7 @@ function renderOntology() {
function renderAssetPrompt() { function renderAssetPrompt() {
if (!state.assetPrompt) { if (!state.assetPrompt) {
els.assetOutput.className = "ontology-view empty-state"; els.assetOutput.className = "ontology-view empty-state";
els.assetOutput.textContent = "Generate a prompt to inspect model key, review state, and evidence."; els.assetOutput.innerHTML = `<span class="empty-hint">Generate a prompt to inspect model key, review state, and evidence.</span>`;
return; return;
} }
@@ -258,15 +314,16 @@ function renderAssetPrompt() {
function renderFeedback() { function renderFeedback() {
if (!state.lastAnswer) { if (!state.lastAnswer) {
els.feedback.className = "feedback empty-state"; els.feedback.className = "feedback empty-state";
els.feedback.textContent = "Submit an answer to see grade, evidence, and follow-up."; els.feedback.innerHTML = `<span class="empty-hint">Submit an answer to see grade, evidence, and follow-up.</span>`;
return; return;
} }
const grade = state.lastAnswer.grade; const grade = state.lastAnswer.grade;
const gradeClass = gradeClassMap[grade.overall] || "";
els.feedback.className = "feedback"; els.feedback.className = "feedback";
els.feedback.innerHTML = ` els.feedback.innerHTML = `
<div> <div>
<div class="grade">${escapeHTML(grade.overall)}</div> <div class="grade ${gradeClass}">${escapeHTML(grade.overall)}</div>
<p class="status-line">${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}</p> <p class="status-line">${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}</p>
</div> </div>
${scoreRows(grade.scores)} ${scoreRows(grade.scores)}
@@ -318,8 +375,11 @@ function value(selector) {
return document.querySelector(selector).value.trim(); return document.querySelector(selector).value.trim();
} }
function setStatus(message) { function setStatus(message, busy = false) {
els.status.textContent = message; 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) { function showError(message) {

View File

@@ -30,26 +30,37 @@
Timeline Timeline
<input id="timeline" name="interview_timeline" value="30 days" /> <input id="timeline" name="interview_timeline" value="30 days" />
</label> </label>
<button id="start-button" type="submit">Start diagnostic</button> <button id="start-button" type="submit">
<span class="btn-text">Start diagnostic</span>
<span class="btn-spinner" aria-hidden="true"></span>
</button>
</form> </form>
<p id="status-line" class="status-line" role="status">Ready</p> <p id="status-line" class="status-line" role="status">
<span class="status-icon" aria-hidden="true"></span>
<span class="status-text">Ready</span>
</p>
<p id="error-line" class="error-line" role="alert"></p> <p id="error-line" class="error-line" role="alert"></p>
</aside> </aside>
<section class="practice-pane" aria-label="Diagnostic practice"> <section class="practice-pane" aria-label="Diagnostic practice">
<div class="section-heading"> <div class="section-heading">
<p class="eyebrow">Diagnostic</p> <div>
<h2 id="session-title">No active session</h2> <p class="eyebrow">Diagnostic</p>
<h2 id="session-title">No active session</h2>
</div>
</div> </div>
<div id="questions" class="question-list empty-state"> <div id="questions" class="question-list empty-state">
Start a diagnostic session to load interview questions. <span class="empty-hint">Start a diagnostic session to load interview questions.</span>
</div> </div>
<form id="answer-form" class="answer-form"> <form id="answer-form" class="answer-form">
<label for="answer-text">Answer</label> <label for="answer-text">Answer</label>
<textarea id="answer-text" rows="7" placeholder="Select a question, then answer with concrete production reasoning."></textarea> <textarea id="answer-text" rows="7" placeholder="Select a question, then answer with concrete production reasoning."></textarea>
<button id="answer-button" type="submit" disabled>Submit answer</button> <button id="answer-button" type="submit" disabled>
<span class="btn-text">Submit answer</span>
<span class="btn-spinner" aria-hidden="true"></span>
</button>
</form> </form>
<section class="content-workspace" aria-label="Material and asset workspace"> <section class="content-workspace" aria-label="Material and asset workspace">
@@ -73,17 +84,22 @@
Source material Source material
<textarea id="material-body" rows="5">Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs and database indexes support query plans.</textarea> <textarea id="material-body" rows="5">Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs and database indexes support query plans.</textarea>
</label> </label>
<button id="material-button" type="submit">Ingest material</button> <button id="material-button" type="submit">
<span class="btn-text">Ingest material</span>
<span class="btn-spinner" aria-hidden="true"></span>
</button>
</form> </form>
<div id="ontology" class="ontology-view empty-state"> <div id="ontology" class="ontology-view empty-state">
Ingest material to inspect ontology candidates. <span class="empty-hint">Ingest material to inspect ontology candidates.</span>
</div> </div>
<form id="asset-form" class="asset-form"> <form id="asset-form" class="asset-form">
<label> <label>
Concept Concept
<select id="asset-concept" disabled></select> <select id="asset-concept" disabled>
<option value="">Select a concept</option>
</select>
</label> </label>
<label> <label>
Asset type Asset type
@@ -94,22 +110,27 @@
<option value="interview_card">Interview card</option> <option value="interview_card">Interview card</option>
</select> </select>
</label> </label>
<button id="asset-button" type="submit" disabled>Generate prompt</button> <button id="asset-button" type="submit" disabled>
<span class="btn-text">Generate prompt</span>
<span class="btn-spinner" aria-hidden="true"></span>
</button>
</form> </form>
<div id="asset-output" class="ontology-view empty-state"> <div id="asset-output" class="ontology-view empty-state">
Generate a prompt to inspect model key, review state, and evidence. <span class="empty-hint">Generate a prompt to inspect model key, review state, and evidence.</span>
</div> </div>
</section> </section>
</section> </section>
<aside class="feedback-pane" aria-label="Feedback"> <aside class="feedback-pane" aria-label="Feedback">
<div class="section-heading"> <div class="section-heading">
<p class="eyebrow">Feedback</p> <div>
<h2>Rubric result</h2> <p class="eyebrow">Feedback</p>
<h2>Rubric result</h2>
</div>
</div> </div>
<div id="feedback" class="feedback empty-state"> <div id="feedback" class="feedback empty-state">
Submit an answer to see grade, evidence, and follow-up. <span class="empty-hint">Submit an answer to see grade, evidence, and follow-up.</span>
</div> </div>
<div class="section-heading progress-heading"> <div class="section-heading progress-heading">
<div> <div>
@@ -119,7 +140,7 @@
<button id="refresh-progress" class="small-button" type="button" disabled>Refresh</button> <button id="refresh-progress" class="small-button" type="button" disabled>Refresh</button>
</div> </div>
<div id="progress" class="feedback empty-state"> <div id="progress" class="feedback empty-state">
Answer once to update learner memory and readiness. <span class="empty-hint">Answer once to update learner memory and readiness.</span>
</div> </div>
</aside> </aside>
</main> </main>

View File

@@ -9,6 +9,16 @@
--accent: #19764b; --accent: #19764b;
--accent-dark: #105c39; --accent-dark: #105c39;
--danger: #a93a2f; --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); color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0; letter-spacing: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
button, button,
@@ -57,7 +69,12 @@ select {
color: var(--accent); color: var(--accent);
font-size: 12px; font-size: 12px;
font-weight: 750; font-weight: 750;
text-transform: uppercase; letter-spacing: 0.06em;
}
html[lang="ko"] .eyebrow {
text-transform: none;
letter-spacing: 0.02em;
} }
h1, h1,
@@ -67,8 +84,8 @@ h2 {
} }
h1 { h1 {
max-width: 9ch; font-size: clamp(36px, 5vw, 64px);
font-size: clamp(42px, 6vw, 74px); line-height: 1.05;
} }
h2 { h2 {
@@ -118,6 +135,14 @@ select {
color: var(--text); color: var(--text);
padding: 12px; padding: 12px;
outline: none; 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 { textarea {
@@ -141,29 +166,86 @@ button {
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
font-weight: 750; font-weight: 750;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
} }
button:hover:not(:disabled) { button:hover:not(:disabled) {
background: var(--accent-dark); background: var(--accent-dark);
} }
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled { button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.48; opacity: 0.48;
} }
.status-line, .btn-spinner {
.error-line { display: none;
min-height: 20px; width: 16px;
margin: 18px 0 0; height: 16px;
font-size: 13px; 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 { .status-line {
display: flex;
align-items: center;
gap: 8px;
min-height: 20px;
margin: 18px 0 0;
font-size: 13px;
color: var(--muted); 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 { .error-line {
min-height: 20px;
margin: 10px 0 0;
font-size: 13px;
color: var(--danger); color: var(--danger);
} }
@@ -181,15 +263,23 @@ button:disabled {
.question-button { .question-button {
border: 1px solid var(--line); border: 1px solid var(--line);
border-left: 3px solid transparent;
background: #fbfcfa; background: #fbfcfa;
color: var(--text); color: var(--text);
padding: 16px; padding: 16px;
min-height: 72px; min-height: 72px;
text-align: left; 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"] { .question-button[aria-pressed="true"] {
border-color: var(--accent); border-color: var(--accent);
border-left-color: var(--accent);
background: var(--surface-muted); background: var(--surface-muted);
} }
@@ -205,7 +295,21 @@ button:disabled {
border: 1px dashed var(--line); border: 1px dashed var(--line);
border-radius: 6px; border-radius: 6px;
color: var(--muted); 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 { .feedback {
@@ -232,10 +336,30 @@ button:disabled {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.metric-row:last-child {
border-bottom: 0;
}
.grade { .grade {
color: var(--accent);
font-size: 38px; font-size: 38px;
font-weight: 800; 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 { .readiness-value {
@@ -250,10 +374,41 @@ button:disabled {
margin: 4px 6px 4px 0; margin: 4px 6px 4px 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
padding: 6px 9px; padding: 6px 10px;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
font-weight: 650; 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 { .small-list {
@@ -264,10 +419,11 @@ button:disabled {
} }
.content-workspace { .content-workspace {
border-top: 1px solid var(--line); border-top: 2px solid var(--line);
display: grid; display: grid;
gap: 18px; gap: 18px;
padding-top: 24px; padding-top: 28px;
margin-top: 6px;
} }
.ontology-view { .ontology-view {
@@ -283,11 +439,11 @@ button:disabled {
.summary-chip { .summary-chip {
background: var(--surface-muted); background: var(--surface-muted);
border-radius: 6px; border-radius: 999px;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
font-weight: 750; font-weight: 750;
padding: 9px 11px; padding: 7px 12px;
} }
.prompt-text { .prompt-text {
@@ -297,6 +453,9 @@ button:disabled {
margin: 0; margin: 0;
padding: 14px; padding: 14px;
white-space: pre-wrap; white-space: pre-wrap;
font-size: 13px;
line-height: 1.5;
color: var(--text);
} }
@media (max-width: 980px) { @media (max-width: 980px) {
@@ -305,7 +464,6 @@ button:disabled {
} }
h1 { h1 {
max-width: 100%;
font-size: 42px; font-size: 42px;
} }