diff --git a/app/index.html b/app/index.html index 74799ee..1e73f35 100644 --- a/app/index.html +++ b/app/index.html @@ -2,7 +2,7 @@ - + Hankan — 한국어 읽기 diff --git a/app/src/domain/cards.ts b/app/src/domain/cards.ts index c5517d6..a14130f 100644 --- a/app/src/domain/cards.ts +++ b/app/src/domain/cards.ts @@ -9,6 +9,8 @@ import type { Db } from "../db/types.js"; import { editCard, editCardReset, editStudyLog, seedCard } from "../db/writes.js"; import { grade, markKnown, newCard, statusOf, type Card, type CardStatus, type Grade } from "@lib/srs.js"; import { GOOD } from "@lib/srs.js"; +import type { ProgressState } from "@lib/gate.js"; +import { unitIndex } from "./gate.js"; export interface CardRow extends Card { lemma_id: number; @@ -58,6 +60,36 @@ export interface DeckOptions { sentences?: boolean; /** Only this source — used by "practise these sentences". */ only?: "sentences"; + /** Narrow the words to his own study pool — see studyPool(). */ + pool?: ProgressState; +} + +/** Fewer words than this in his units, and the pool is the whole deck. */ +export const MY_UNITS_MIN = 20; + +/** + * Whether a word is his: introduced by a unit he has reached — finished, or + * at or before the one he is on — or added by him. + * + * The artifact counted only the units' words, so once there were twenty of + * them a word added from a lesson was never reviewed again. + */ +export function isMine(e: Pick, p: ProgressState): boolean { + if (e.source === "custom") return true; + if (!e.unitId) return false; + const at = unitIndex(e.unitId); + return at >= 0 && (Boolean(p.done[e.unitId]) || at <= unitIndex(p.current)); +} + +/** + * The words a review draws on: his units' words while there are at least + * MY_UNITS_MIN of them — reviewing words from six phases ahead is not + * studying — and the whole deck until then. + */ +export function studyPool(entries: DeckEntry[], p: ProgressState): DeckEntry[] { + const words = entries.filter((e) => e.source !== "sentence"); + if (words.filter((e) => isMine(e, p)).length < MY_UNITS_MIN) return entries; + return entries.filter((e) => e.source === "sentence" || isMine(e, p)); } function sourceClause(opts: DeckOptions): string { @@ -76,7 +108,7 @@ export async function deck(db: Db, opts: DeckOptions = {}): Promise ORDER BY l.headword`, ); - return rows.map((r) => { + const entries = rows.map((r): DeckEntry => { const card = toCard(r); return { lemmaId: r.lemmaId as number, @@ -90,6 +122,8 @@ export async function deck(db: Db, opts: DeckOptions = {}): Promise status: statusOf(card), }; }); + + return opts.pool && opts.only !== "sentences" ? studyPool(entries, opts.pool) : entries; } export interface Counts { diff --git a/app/src/main.tsx b/app/src/main.tsx index 3e98d2b..e893ac9 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; -import { App } from "./ui/App.js"; +// First, so every stylesheet after it can build on the tokens and reset. import "./style/tokens.css"; +import { App } from "./ui/App.js"; createRoot(document.getElementById("root")!).render(); diff --git a/app/src/style/tokens.css b/app/src/style/tokens.css index e39bbd7..9ac03cc 100644 --- a/app/src/style/tokens.css +++ b/app/src/style/tokens.css @@ -1,9 +1,13 @@ /* Hankan's design tokens. - Carried over from the artifact, whose visual language is small and worth - keeping exactly: two hand-tuned palettes, three type stacks, and one hard - rule — NOTHING IS ROUNDED and there are no icons. Korean glyphs do the - work icons would. Deviating from that is what would make it look generic. + Carried over from the artifact: two hand-tuned palettes and three type + stacks. Korean glyphs still do most of the work icons would. + + The shape language has two registers, as the artifact's has since it went + mobile-first. Reference material — panels, tables, the jamo grids — stays + square-cornered. The things a thumb works — cards, calls to action, chips, + sheets, the nav — are rounded to --radius, and the five nav destinations + carry icons: at 390px a label alone is too small a target to find. Themes: light on bare :root so it is the default; the dark palette is redefined under prefers-color-scheme, guarded so an explicit light choice @@ -65,6 +69,18 @@ sans-serif; --serif: "Gowun Batang", "Nanum Myeongjo", "Apple SD Gothic Neo", Georgia, serif; --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + /* Layout. */ + --gutter: 16px; + --radius: 12px; + --cta-h: 56px; + --row-h: 64px; + --safe-b: env(safe-area-inset-bottom, 0px); + --safe-t: env(safe-area-inset-top, 0px); + --dur-sheet: 220ms; + --ease: cubic-bezier(0.2, 0, 0, 1); + /* The word sheet's current height; the shell shrinks by it. */ + --sheet-h: 0px; } @media (prefers-color-scheme: dark) { @@ -245,15 +261,16 @@ p { } .wrap { - max-width: 1000px; + width: 100%; + max-width: 920px; margin: 0 auto; - padding: 0 20px; + padding-inline: var(--gutter); } -.stack { - display: flex; - flex-direction: column; - gap: 26px; +@media (min-width: 600px) { + .wrap { + padding-inline: 24px; + } } .eyebrow { @@ -268,10 +285,4 @@ p { body { font-size: 15px; } - .wrap { - padding: 0 13px; - } - .stack { - gap: 18px; - } } diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx index 3a2d131..83dd363 100644 --- a/app/src/ui/App.tsx +++ b/app/src/ui/App.tsx @@ -1,33 +1,18 @@ -/* The shell: header, tab bar, and one section per tab. +/* The app: the shell, the routes, and what sits above them. - Six tabs, Korean-labelled, with the English as a subtitle that drops away - on a phone. No icons anywhere — the Korean glyph is the icon. */ + Five destinations in a bottom bar on a phone, a rail on anything wider — + see shell/shell.css. The review screen is a layer over all of it. */ -import { useState } from "react"; -import { StoreProvider, useStore, type BootState } from "../state/store.js"; -import { TutorTab } from "./tutor/TutorTab.js"; -import { TodayTab } from "./tabs/TodayTab.js"; -import { VocabTab } from "./tabs/VocabTab.js"; -import { SentencesTab } from "./tabs/SentencesTab.js"; -import { GrammarTab } from "./tabs/GrammarTab.js"; -import { HangulTab } from "./tabs/HangulTab.js"; -import { ReviewOverlay } from "./review/ReviewOverlay.js"; -import { ReviewProvider, useReview } from "./review/useReview.js"; -import { currentUnit } from "../domain/progress.js"; +import { StoreProvider, type BootState } from "../state/store.js"; +import { RouterProvider } from "./shell/router.js"; +import { Nav } from "./shell/Nav.js"; +import { Routes } from "./routes.js"; +import { ReviewProvider } from "./review/useReview.js"; +import { ReviewScreen } from "./review/ReviewScreen.js"; import "../style/components.css"; +import "./shell/shell.css"; import "./app.css"; -const TABS = [ - { id: "lesson", ko: "수업", en: "Lesson" }, - { id: "today", ko: "오늘", en: "Today" }, - { id: "vocab", ko: "단어", en: "Vocabulary" }, - { id: "sent", ko: "문장", en: "Sentences" }, - { id: "grammar", ko: "문법", en: "Grammar" }, - { id: "hangul", ko: "한글", en: "Hangul" }, -] as const; - -type TabId = (typeof TABS)[number]["id"]; - function Boot({ boot }: { boot: BootState }) { return (
@@ -44,119 +29,20 @@ function Boot({ boot }: { boot: BootState }) { ); } -function Header({ tab, onTab }: { tab: TabId; onTab: (t: TabId) => void }) { - const { progress, dbInfo } = useStore(); - const unit = currentUnit(progress); - - return ( -
-
-
-
- 한칸 - Korean reading desk -
-
- - {unit.id} · {unit.ko} - - - {dbInfo.persistent ? "offline" : "session only"} - -
-
- - -
-
- ); -} - -function Shell() { - const [tab, setTab] = useState("lesson"); - const review = useReview(); - - const go = (t: TabId) => { - setTab(t); - window.scrollTo({ top: 0, behavior: "instant" }); - }; - - return ( - <> -
-
-
- - - - - - -
-
-