Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .tasks/phase-2-content-routes/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,3 @@

- StyleX 0.19 `unstable_createThemeNested`에 비어 있지 않은 오버라이드를 넣으면 babel-plugin이 크래시한다 (`undefined.slice`). colorway 시각 차등은 `ColorwayShell`/`ArticleLayout` variant + 팔레트 토큰으로 낸다. `themes.ts`의 createTheme 껍데기는 `{}`로 둔다.
- `llm-wiki-template` 이식은 6편 게이트와 분리. wiki 21개 확인했으나 공개안전 큐레이션은 후속.

43 changes: 43 additions & 0 deletions .tasks/phase-3-wiki-quiz/LOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Phase 3 LOG

- Harness: cursor
- Started: 2026-09-10
- Status: PR-READY #60

## Notes

- 스택: `feat/phase-2-content` ← `feat/phase-3-quiz`. 머지는 사람. #56 → #58 → #59 먼저.
- Quiz island는 Home three와 별개. `client:visible` Preact. JS 꺼져도 noscript 목록.

## 2026-09-10 implement

- 스키마 `src/content/quiz.ts` + 덱 4×5=20 (`ts-basics`, `tdd`, `refactoring`, `design-system`). til 3편 `quizRefs`.
- 엔진 `shuffle` / `grade`(NFKC+trim+lower+공백·마침표 제거) / `session` / `progress` — vitest 21 passed.
- UI `QuizDeck` Preact + `/wiki/quiz` (덱 picker, noscript 정적 목록, 키보드 1–4·Enter·←→, aria-live, reduced-motion 페이드).
- **Quiz island gzip** (`dist/_astro/` quiz 페이지 로드 청크 합): **13,608 bytes** (QuizDeck 3,702 + Preact shared). 목표 30KB 이내.
- PR: #60 draft. 게이트: check·oxlint·vitest·lint:stylex·build·build-storybook green. oxfmt는 base부터 `.tasks/**/PLAN.md` 2건 drift(phase-3 src 무관).

## 2026-09-10 review fix @ `61bfae5`

- **Island always ship:** `quiz.astro`가 SSG `searchParams`로 island 분기하던 문제 수정 → `<QuizDeck client:visible />` 항상 + 전 덱 props. 덱 선택은 island 내부(`history.replaceState`).
- **dist 증명:** `rg dist/wiki/quiz/index.html` → `astro-island` + `component-url="/_astro/QuizDeck.*.js"`.
- **Scoring:** `snapshots[idx]` per card — 재방문 시 picked/revealed freeze, `recordDeckAnswer`/`score` 중복 없음.
- **oxfmt:** `bunx oxfmt .` — PLAN 2건 포함 green.
- **번들 회귀:** 전 덱 props 인라인으로 QuizDeck chunk gzip **31,247 bytes** (목표 30KB 초과 → lazy deck import 후속).

## 2026-09-10 lazy deck @ `8b194ac`

- `loadDeckItems()` — `import()` per deck module; island props = `deckIds` + `deckLabels` + `deckCounts` only.
- **QuizDeck entry gzip:** **4,511 bytes** (`dist/_astro/QuizDeck.*.js`). 덱 청크는 별도 lazy (예: ts-basics ~1,167 gzip).
- vitest `loadDeckItems` 2건 추가 (총 23 passed). dist HTML: `astro-island` + `component-url` 유지.

## 2026-09-10 finished view @ `e20f48a`

- `quizView()` — `finished` → `loading` → `empty` → `card` 순서. `idx === total`일 때 완료 UI·다시 풀기 표시.
- vitest `quizView` 5건 (총 28 passed). QuizDeck entry gzip **4,610 bytes**. dist: `astro-island` 유지.

## 2026-09-10 review

- Sol `gpt-5.6-sol-medium`: island 누락 → scoring freeze → finished 화면 순서. 최종 `REVIEW APPROVE phase-3 60`. usage: not visible.
- Leader 재실행 HEAD `ea043cf`: check 127/0, oxfmt, oxlint, vitest 28, lint:stylex, build 34 pages, quiz HTML `astro-island`+QuizDeck chunk.
- `gh pr ready 60`. 머지는 사람. 스택: #56 → #58 → #59 → #60.
4 changes: 2 additions & 2 deletions .tasks/phase-3-wiki-quiz/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@

## 승인 요청

- [ ] island = Preact 동의? (React 유지 희망 시 코멘트)
- [ ] 주관식 정규화 수준 동의?
- [x] island = Preact 동의? — 2026-09-10 스택 진행. Phase 1.5에서 `@astrojs/preact` 이미 설치.
- [x] 주관식 정규화 수준 동의? — PLAN 기본안 (NFKC + trim + lower + 공백/마침표 제거).
33 changes: 33 additions & 0 deletions src/components/quiz/QuizDeck.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { deckLabels, getDeckItemCounts, quizDeckIds } from "../../lib/quiz/decks.ts";

import { QuizDeck } from "./QuizDeck.tsx";

const deckCounts = getDeckItemCounts();

export default {
title: "quiz/QuizDeck",
component: QuizDeck,
parameters: {
docs: {
description: {
component:
"Wiki flash card island. 덱 데이터는 loadDeckItems로 동적 import. 키보드 1–4·Enter·화살표, aria-live.",
},
},
},
};

export const DeckPicker = {
render: () => <QuizDeck deckIds={quizDeckIds} deckLabels={deckLabels} deckCounts={deckCounts} />,
};

export const ShortSession = {
render: () => (
<QuizDeck
deckIds={quizDeckIds}
deckLabels={deckLabels}
deckCounts={deckCounts}
sessionLimit={3}
/>
),
};
250 changes: 250 additions & 0 deletions src/components/quiz/QuizDeck.stylex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import * as stylex from "@stylexjs/stylex";

import { tokens } from "../../styles/tokens.stylex.ts";

export const quizDeckStyles = stylex.create({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.space[6],
maxWidth: "42rem",
},
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.space[3],
},
stats: {
display: "flex",
flexWrap: "wrap",
gap: tokens.space[4],
fontSize: tokens.fontSize.sm,
color: tokens.color.inkSecondary,
fontFamily: tokens.fontFamily.sans,
},
progressTrack: {
width: "100%",
height: "6px",
borderRadius: tokens.radius.pill,
backgroundColor: tokens.color.surfaceMuted,
overflow: "hidden",
},
progressFill: {
height: "100%",
backgroundColor: tokens.color.accent,
transitionProperty: "width",
transitionDuration: tokens.duration.normal,
},
cardShell: {
perspective: "1200px",
minHeight: "18rem",
},
cardShellReduced: {
perspective: "none",
minHeight: "18rem",
},
card: {
position: "relative",
width: "100%",
minHeight: "18rem",
transformStyle: "preserve-3d",
transitionProperty: "transform",
transitionDuration: tokens.duration.slow,
transitionTimingFunction: "ease",
},
cardFlipped: {
transform: "rotateY(180deg)",
},
cardFade: {
position: "relative",
width: "100%",
minHeight: "18rem",
transitionProperty: "opacity",
transitionDuration: tokens.duration.normal,
},
cardFace: {
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
gap: tokens.space[4],
padding: tokens.space[6],
borderRadius: tokens.radius.md,
borderWidth: "1px",
borderStyle: "solid",
borderColor: tokens.color.border,
backgroundColor: tokens.color.surface,
backfaceVisibility: "hidden",
},
cardBack: {
transform: "rotateY(180deg)",
},
cardFaceStatic: {
display: "flex",
flexDirection: "column",
gap: tokens.space[4],
padding: tokens.space[6],
borderRadius: tokens.radius.md,
borderWidth: "1px",
borderStyle: "solid",
borderColor: tokens.color.border,
backgroundColor: tokens.color.surface,
},
cardFaceHidden: {
opacity: 0,
pointerEvents: "none",
position: "absolute",
inset: 0,
},
prompt: {
fontSize: tokens.fontSize.lg,
fontWeight: 600,
lineHeight: tokens.lineHeight.tight,
color: tokens.color.ink,
fontFamily: tokens.fontFamily.sans,
margin: 0,
},
choices: {
display: "flex",
flexDirection: "column",
gap: tokens.space[2],
margin: 0,
padding: 0,
listStyle: "none",
},
choiceButton: {
display: "flex",
alignItems: "flex-start",
gap: tokens.space[3],
width: "100%",
padding: tokens.space[3],
borderRadius: tokens.radius.sm,
borderWidth: "1px",
borderStyle: "solid",
borderColor: tokens.color.border,
backgroundColor: tokens.color.canvas,
color: tokens.color.ink,
fontFamily: tokens.fontFamily.sans,
fontSize: tokens.fontSize.sm,
textAlign: "left",
cursor: "pointer",
transitionProperty: "border-color, background-color",
transitionDuration: tokens.duration.fast,
},
choiceSelected: {
borderColor: tokens.color.accent,
backgroundColor: tokens.color.accentSoft,
},
choiceCorrect: {
borderColor: tokens.color.accentInk,
backgroundColor: tokens.color.accentSoft,
},
choiceWrong: {
borderColor: "#c62828",
backgroundColor: "#ffebee",
},
shortInput: {
width: "100%",
padding: tokens.space[3],
borderRadius: tokens.radius.sm,
borderWidth: "1px",
borderStyle: "solid",
borderColor: tokens.color.border,
fontFamily: tokens.fontFamily.sans,
fontSize: tokens.fontSize.md,
color: tokens.color.ink,
backgroundColor: tokens.color.canvas,
},
actions: {
display: "flex",
flexWrap: "wrap",
gap: tokens.space[3],
marginTop: "auto",
},
primaryButton: {
paddingBlock: tokens.space[2],
paddingInline: tokens.space[4],
borderRadius: tokens.radius.sm,
borderWidth: "1px",
borderStyle: "solid",
borderColor: tokens.color.accentInk,
backgroundColor: tokens.color.accentInk,
color: tokens.color.canvas,
fontFamily: tokens.fontFamily.sans,
fontSize: tokens.fontSize.sm,
fontWeight: 500,
cursor: "pointer",
},
secondaryButton: {
paddingBlock: tokens.space[2],
paddingInline: tokens.space[4],
borderRadius: tokens.radius.sm,
borderWidth: "1px",
borderStyle: "solid",
borderColor: tokens.color.border,
backgroundColor: "transparent",
color: tokens.color.ink,
fontFamily: tokens.fontFamily.sans,
fontSize: tokens.fontSize.sm,
cursor: "pointer",
},
feedback: {
fontSize: tokens.fontSize.sm,
fontFamily: tokens.fontFamily.sans,
color: tokens.color.inkSecondary,
margin: 0,
},
explanation: {
fontSize: tokens.fontSize.sm,
lineHeight: tokens.lineHeight.body,
color: tokens.color.ink,
fontFamily: tokens.fontFamily.sans,
margin: 0,
},
sourceLink: {
fontSize: tokens.fontSize.sm,
color: tokens.color.accentInk,
fontFamily: tokens.fontFamily.sans,
},
hint: {
fontSize: tokens.fontSize.sm,
color: tokens.color.inkTertiary,
fontFamily: tokens.fontFamily.mono,
},
deckNav: {
display: "flex",
flexWrap: "wrap",
gap: tokens.space[2],
margin: 0,
padding: 0,
listStyle: "none",
},
deckButton: {
paddingBlock: tokens.space[2],
paddingInline: tokens.space[3],
borderRadius: tokens.radius.sm,
borderWidth: "1px",
borderStyle: "solid",
borderColor: tokens.color.border,
backgroundColor: tokens.color.canvas,
color: tokens.color.ink,
fontFamily: tokens.fontFamily.sans,
fontSize: tokens.fontSize.sm,
cursor: "pointer",
textDecoration: "none",
},
deckButtonActive: {
borderColor: tokens.color.accentInk,
backgroundColor: tokens.color.accentSoft,
color: tokens.color.accentInk,
fontWeight: 600,
},
deckPrompt: {
fontSize: tokens.fontSize.md,
color: tokens.color.inkSecondary,
fontFamily: tokens.fontFamily.sans,
margin: 0,
},
});
Loading