diff --git a/src/app.js b/src/app.js index 8282141..1d9afaf 100644 --- a/src/app.js +++ b/src/app.js @@ -947,13 +947,30 @@ export class BamLearningApp { this.renderLesson(); const updatedButton = document.querySelector("[data-toggle-complete]"); updatedButton?.focus(); - this.announce(wasCompleted ? "학습 완료 표시를 해제했습니다." : "학습을 완료로 표시했습니다."); + this.announce( + wasCompleted ? "학습 완료 표시를 해제했습니다." : "학습을 완료로 표시했습니다.", + ); } catch { this.announce("진도를 저장하지 못했습니다. 브라우저 저장 공간 설정을 확인해 주세요."); } return; } + const modelAnswerButton = event.target.closest("[data-toggle-model-answer]"); + if (modelAnswerButton) { + const modelAnswer = document.querySelector("#lesson-model-answer"); + if (!modelAnswer) return; + + const isExpanded = modelAnswerButton.getAttribute("aria-expanded") === "true"; + modelAnswer.hidden = isExpanded; + modelAnswerButton.setAttribute("aria-expanded", String(!isExpanded)); + modelAnswerButton.textContent = isExpanded ? "면접 답변 보기" : "면접 답변 숨기기"; + + if (isExpanded) modelAnswerButton.focus(); + else modelAnswer.focus(); + return; + } + const copyButton = event.target.closest("[data-copy-code]"); if (copyButton) { const codeElement = copyButton.closest(".code-card")?.querySelector("code"); @@ -2616,6 +2633,21 @@ export class BamLearningApp { languageCodingTestCollection, ); const supportsLanguageFeatures = course.categoryId === "language"; + const normalizedMarkdown = String(this.currentMarkdown).replaceAll("\r\n", "\n"); + const objectivesMatch = normalizedMarkdown.match( + /(?:^|\n)(## 학습 목표\n[\s\S]*?)(?=\n##\s|$)/, + ); + const objectivesMarkdown = objectivesMatch?.[1] ?? ""; + let lessonBodyMarkdown = objectivesMatch + ? normalizedMarkdown.replace(objectivesMatch[0], "\n") + : normalizedMarkdown; + const modelAnswerMatch = lessonBodyMarkdown.match( + /(?:^|\n)(## 면접 답변 예시\n[\s\S]*?)(?=\n##\s|$)/, + ); + const modelAnswerMarkdown = modelAnswerMatch?.[1] ?? ""; + if (modelAnswerMatch) { + lessonBodyMarkdown = lessonBodyMarkdown.replace(modelAnswerMatch[0], "\n"); + } const mainContent = `
@@ -2630,6 +2662,7 @@ export class BamLearningApp {

${escapeHtml(lesson.title)}

${escapeHtml(lesson.summary)}

+ ${objectivesMarkdown ? `
${renderMarkdown(objectivesMarkdown)}
` : ""}
오늘의 핵심 질문${escapeHtml(lesson.essentialQuestion)}
@@ -2637,7 +2670,8 @@ export class BamLearningApp {
- ${renderMarkdown(this.currentMarkdown, { skipFirstHeading: true })} + ${renderMarkdown(lessonBodyMarkdown, { skipFirstHeading: true })} + ${modelAnswerMarkdown ? `

답을 보기 전에 먼저 확인 문제를 자신의 말로 설명해 보세요.

` : ""}
diff --git a/styles/app.css b/styles/app.css index dfd9592..9551927 100644 --- a/styles/app.css +++ b/styles/app.css @@ -557,6 +557,35 @@ a.language-nav-link:hover { line-height: 1.75; } +.lesson-objectives { + margin-top: 26px; +} + +.lesson-objectives h2 { + margin: 0 0 12px; + color: var(--color-text); + font-size: 18px; + line-height: 1.35; +} + +.lesson-objectives ul { + display: grid; + gap: 8px; + padding-left: 1.35rem; + margin: 0; + color: var(--color-text-soft); +} + +.lesson-objectives li { + padding-left: 4px; + overflow-wrap: anywhere; +} + +.lesson-objectives li::marker { + color: var(--color-primary-strong); + font-weight: 800; +} + .essential-question { display: flex; gap: 13px; @@ -609,6 +638,34 @@ a.language-nav-link:hover { color: var(--color-text-soft); } +.lesson-model-answer { + scroll-margin-top: 80px; +} + +.lesson-model-answer-disclosure { + display: flex; + gap: 18px; + align-items: center; + justify-content: space-between; + padding-top: 24px; + margin-top: 26px; + border-top: 1px solid var(--color-border); +} + +.lesson-model-answer-disclosure p { + margin: 0; +} + +.lesson-model-answer-toggle { + min-height: 44px; + flex: 0 0 auto; +} + +.lesson-model-answer:focus { + outline: 2px solid var(--color-primary-strong); + outline-offset: 8px; +} + .lesson-body h2, .lesson-body h3, .lesson-body h4 { @@ -664,11 +721,12 @@ a.language-nav-link:hover { color: var(--color-text); } -.lesson-body :not(pre) > code { +.lesson-body :not(pre) > code, +.lesson-objectives :not(pre) > code { padding: 0.13em 0.38em; border: 1px solid transparent; border-radius: 5px; - background: #292929; + background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 0.88em; overflow-wrap: anywhere; @@ -1387,7 +1445,7 @@ a.language-nav-link:hover { padding: 0.12em 0.34em; border: 1px solid transparent; border-radius: 0.35rem; - background: #292929; + background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 0.9em; overflow-wrap: anywhere; @@ -1911,7 +1969,7 @@ a.language-nav-link:hover { padding: 0.08em 0.32em; border: 1px solid transparent; border-radius: 5px; - background: #292929; + background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 0.9em; overflow-wrap: anywhere; @@ -4601,6 +4659,15 @@ a.language-nav-link:hover { font-size: 21px; } + .lesson-model-answer-disclosure { + align-items: stretch; + flex-direction: column; + } + + .lesson-model-answer-toggle { + width: 100%; + } + .code-card { margin-right: -8px; margin-left: -8px; diff --git a/tests/accessibility.test.js b/tests/accessibility.test.js index 2e1f95a..f73456f 100644 --- a/tests/accessibility.test.js +++ b/tests/accessibility.test.js @@ -35,31 +35,36 @@ test("주요 버튼의 기본·hover 색 대비가 일반 텍스트 AA 기준을 test("긴 교안 제목과 인라인 코드가 320px 문서 폭을 늘리지 않도록 줄바꿈한다", async () => { const css = await readFile(new URL("../styles/app.css", import.meta.url), "utf8"); const lessonTitleRule = css.match(/\.lesson-hero h1\s*\{([^}]*)\}/); - const inlineCodeRule = css.match(/\.lesson-body :not\(pre\) > code\s*\{([^}]*)\}/); + const inlineCodeRule = css.match( + /\.lesson-body :not\(pre\) > code,\s*\.lesson-objectives :not\(pre\) > code\s*\{([^}]*)\}/, + ); assert.ok(lessonTitleRule); assert.ok(inlineCodeRule); assert.match(lessonTitleRule[1], /overflow-wrap:\s*anywhere/); assert.match(inlineCodeRule[1], /overflow-wrap:\s*anywhere/); assert.match(inlineCodeRule[1], /border:\s*1px solid transparent/); - assert.match(inlineCodeRule[1], /background:\s*#292929/); + assert.match(inlineCodeRule[1], /background:\s*var\(--color-surface-raised\)/); }); test("인라인 코드는 참고 UI의 byte 톤을 쓰고 오류 제목은 물결 밑줄로 구분한다", async () => { const css = await readFile(new URL("../styles/app.css", import.meta.url), "utf8"); + const tokens = await readFile(new URL("../styles/tokens.css", import.meta.url), "utf8"); const sharedInlineRule = css.match(/#lesson-content :not\(pre\) > code\s*\{([^}]*)\}/)?.[1] ?? ""; const quizInlineRule = css.match(/\.quiz-card :not\(pre\) > code\s*\{([^}]*)\}/)?.[1] ?? ""; const questInlineRule = css.match(/\.quest-prose code\s*\{([^}]*)\}/)?.[1] ?? ""; + const raisedSurface = tokens.match(/--color-surface-raised:\s*(#[\da-f]{6})\s*;/i)?.[1]; const diagnosticRule = css.match( /\.quiz-answer-summary\.is-incorrect h3,[\s\S]*?\.web-project-result-item\.is-engine_error strong\s*\{([^}]*)\}/, )?.[1] ?? ""; for (const rule of [quizInlineRule, questInlineRule]) { assert.match(rule, /border:\s*1px solid transparent/); - assert.match(rule, /background:\s*#292929/); + assert.match(rule, /background:\s*var\(--color-surface-raised\)/); } assert.match(sharedInlineRule, /color:\s*#e26b60/); - assert.ok(contrastRatio("#e26b60", "#292929") >= 4.5); + assert.ok(raisedSurface); + assert.ok(contrastRatio("#e26b60", raisedSurface) >= 4.5); assert.match(diagnosticRule, /text-decoration-color:\s*var\(--color-danger\)/); assert.match(diagnosticRule, /text-decoration-line:\s*underline/); assert.match(diagnosticRule, /text-decoration-style:\s*wavy/); diff --git a/tests/course-separation.test.js b/tests/course-separation.test.js index e228497..9e676e3 100644 --- a/tests/course-separation.test.js +++ b/tests/course-separation.test.js @@ -6,6 +6,7 @@ import { getLessonsForCourse, getLessonsForLanguage, } from "../src/core/content.js"; +import { renderMarkdown } from "../src/ui/markdown.js"; const curriculum = JSON.parse( await readFile(new URL("../content/curriculum.json", import.meta.url), "utf8"), @@ -43,6 +44,217 @@ test("JavaScript 언어 과정 7개와 알고리즘 12개를 실행 언어 합 assert.equal(getLessonsForLanguage(curriculum, "javascript").length, 19); }); +test("모든 교안은 원문 학습 목표를 hero에 한 번만 렌더링하고 나머지 섹션을 보존한다", async () => { + const app = createShellHarness(); + const renderedById = new Map(); + + for (const lesson of curriculum.lessons) { + const markdown = await readFile(new URL(`../${lesson.contentFile}`, import.meta.url), "utf8"); + const objectivesMarkdown = markdown.match( + /(?:^|\n)(## 학습 목표\n[\s\S]*?)(?=\n##\s|$)/, + )?.[1]; + assert.ok(objectivesMarkdown, `${lesson.id}: 원문 학습 목표가 필요합니다.`); + + app.currentLesson = lesson; + app.currentMarkdown = markdown; + app.renderLesson(); + + const html = app.root.innerHTML; + const hero = html.match(/
([\s\S]*?)<\/header>/)?.[1] ?? ""; + const body = html.match(/
\s*([\s\S]*?)\s*<\/article>/)?.[1] ?? ""; + const objectives = hero.match( + /
([\s\S]*?)<\/section>/, + )?.[1] ?? ""; + + assert.equal( + (html.match(/

학습 목표<\/h2>/g) ?? []).length, + 1, + lesson.id, + ); + assert.equal(objectives, renderMarkdown(objectivesMarkdown), lesson.id); + assert.ok(hero.indexOf("lesson-summary") < hero.indexOf("lesson-objectives"), lesson.id); + assert.ok( + hero.indexOf("lesson-objectives") < hero.indexOf("essential-question"), + lesson.id, + ); + assert.doesNotMatch(body, /

/, lesson.id); + + if (markdown.includes("## 면접 답변 예시")) { + assert.match( + body, + /
]*role="region"[^>]*tabindex="-1"[^>]*aria-labelledby="면접-답변-예시"[^>]* hidden>/, + lesson.id, + ); + assert.match(body, /

면접 답변 예시<\/h2>/, lesson.id); + assert.match( + body, + /data-toggle-model-answer[^>]*aria-controls="lesson-model-answer"[^>]*aria-describedby="lesson-model-answer-help"[^>]*aria-expanded="false"[^>]*>면접 답변 보기<\/button>/, + lesson.id, + ); + const completionButton = html.match(/]*data-toggle-complete[^>]*>/)?.[0] ?? ""; + assert.doesNotMatch(completionButton, /aria-(?:controls|expanded)=/, lesson.id); + } else { + assert.doesNotMatch( + body, + /data-toggle-model-answer|lesson-model-answer-help|id="lesson-model-answer"/, + lesson.id, + ); + } + + renderedById.set(lesson.id, { body, hero, html }); + } + + const javascriptLesson = renderedById.get("js-01-runtime"); + assert.match(javascriptLesson.hero, /defer<\/code>/); + assert.match(javascriptLesson.body.trim(), /^

JavaScript란\?<\/h2>/); + + const cssLesson = renderedById.get("css-01-cascade-box-model"); + assert.match(cssLesson.body.trim(), /^

학습 시간<\/h2>/); + + const algorithmLesson = renderedById.get( + getLessonsForCourse(curriculum, "algorithm")[0].id, + ); + assert.match(algorithmLesson.body, /

확인 문제<\/h2>/); + assert.match(algorithmLesson.body, /

면접 답변 예시<\/h2>/); + assert.match(algorithmLesson.html, /id="completion-title"/); +}); + +test("면접 답변 전용 버튼은 일시적으로 공개·숨기고 재렌더에서 닫힌다", async (t) => { + const lesson = getLessonsForCourse(curriculum, "algorithm")[0]; + const markdown = await readFile(new URL(`../${lesson.contentFile}`, import.meta.url), "utf8"); + const questionCount = + markdown.match(/\n## 확인 문제\n([\s\S]*?)(?=\n##\s|$)/)?.[1].match(/^\d+\./gm) + ?.length ?? 0; + const answerCount = + markdown.match(/\n## 면접 답변 예시\n([\s\S]*?)$/)?.[1].match(/^### 답변 \d+/gm) + ?.length ?? 0; + const progress = { completedLessonIds: [] }; + let answerFocusCount = 0; + let disclosureFocusCount = 0; + let completionFocusCount = 0; + let completionSaveCount = 0; + const messages = []; + const answerElement = { + hidden: true, + focus() { + answerFocusCount += 1; + }, + }; + const attributes = new Map([["aria-expanded", "false"]]); + const disclosureButton = { + textContent: "면접 답변 보기", + getAttribute(name) { + return attributes.get(name) ?? null; + }, + setAttribute(name, value) { + attributes.set(name, String(value)); + }, + focus() { + disclosureFocusCount += 1; + }, + }; + const app = createShellHarness(); + Object.assign(app, { + currentLesson: lesson, + currentMarkdown: markdown, + handleWebProjectClick: () => false, + handleCodingTestClick: () => false, + handleCodeQuestClick: () => false, + handleQuizClick: () => false, + progressRepository: { + getProgress: () => progress, + setLessonCompleted(lessonId, completed) { + assert.equal(lessonId, lesson.id); + completionSaveCount += 1; + progress.completedLessonIds = completed ? [lessonId] : []; + }, + }, + announce(message) { + messages.push(message); + }, + }); + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + querySelector(selector) { + if (selector === "#lesson-model-answer") return answerElement; + if (selector === "[data-toggle-complete]") { + return { focus: () => { completionFocusCount += 1; } }; + } + return null; + }, + }, + }); + t.after(() => { + if (previousDocument) Object.defineProperty(globalThis, "document", previousDocument); + else delete globalThis.document; + }); + const disclosureEvent = { + target: { + closest(selector) { + return selector === "[data-toggle-model-answer]" ? disclosureButton : null; + }, + }, + }; + const completionEvent = { + target: { + closest(selector) { + return selector === "[data-toggle-complete]" ? {} : null; + }, + }, + }; + + app.renderLesson(); + assert.match(app.root.innerHTML, /id="lesson-model-answer"[^>]* hidden>/); + assert.match( + app.root.innerHTML, + /data-toggle-model-answer[^>]*aria-expanded="false"[^>]*>면접 답변 보기<\/button>/, + ); + assert.match(app.root.innerHTML, /답을 보기 전에 먼저 확인 문제를 자신의 말로 설명해 보세요\./); + + app.handleClick(disclosureEvent); + assert.equal(answerElement.hidden, false); + assert.equal(attributes.get("aria-expanded"), "true"); + assert.equal(disclosureButton.textContent, "면접 답변 숨기기"); + assert.equal(answerFocusCount, 1); + assert.equal(disclosureFocusCount, 0); + assert.equal(completionSaveCount, 0); + assert.equal(questionCount, answerCount); + assert.equal( + (app.root.innerHTML.match(/

답변 \d+<\/h3>/g) ?? []).length, + answerCount, + ); + + app.handleClick(disclosureEvent); + assert.equal(answerElement.hidden, true); + assert.equal(attributes.get("aria-expanded"), "false"); + assert.equal(disclosureButton.textContent, "면접 답변 보기"); + assert.equal(disclosureFocusCount, 1); + assert.equal(completionSaveCount, 0); + assert.deepEqual(messages, []); + + progress.completedLessonIds = [lesson.id]; + app.renderLesson(); + assert.match(app.root.innerHTML, /id="lesson-model-answer"[^>]* hidden>/); + assert.match( + app.root.innerHTML, + /data-toggle-model-answer[^>]*aria-expanded="false"[^>]*>면접 답변 보기<\/button>/, + ); + const completedButton = app.root.innerHTML.match( + /]*data-toggle-complete[^>]*aria-pressed="true"[^>]*>/, + )?.[0] ?? ""; + assert.ok(completedButton); + assert.doesNotMatch(completedButton, /aria-(?:controls|expanded)=/); + + app.handleClick(completionEvent); + assert.deepEqual(progress.completedLessonIds, []); + assert.equal(completionSaveCount, 1); + assert.equal(completionFocusCount, 1); + assert.deepEqual(messages, ["학습 완료 표시를 해제했습니다."]); + assert.match(app.root.innerHTML, /id="lesson-model-answer"[^>]* hidden>/); +}); + test("알고리즘 교안 셸은 12개 목차만 표시하고 미제공 평가 기능은 숨긴다", () => { const app = createShellHarness(); const lesson = getLessonsForCourse(curriculum, "algorithm")[0];