feat(ui): 단어 and 오늘, rebuilt for a thumb

단어. The search and the filters stay put above the list; the filters are
chips with counts — 내 진도 My units (the default), All, Due, New, Learning,
In review, Secure, then every topic. Below 840px the words are a list, 40
rows at a time as it scrolls, and a row opens its actions — Know it or
Reset, Delete for his own words — instead of three buttons in 360px. From
840px it is a table. The artifact chose between the two once, at first
render, so turning a tablet left the wrong one; this follows the width.
The add form sits behind +, and searching still reaches past the deck into
the dictionary — whose words can now be added from there.

오늘. One number and one action: what a review started now would hold (the
same number as 복습's badge), how long it should take, and Start. Below it a
line — streak, answered today, words secure — and the daily goal. The road,
the study log and the deck in numbers fold away underneath. The heatmap
shows as many weeks as fit, with weekdays, months and a legend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 22:05:54 +02:00
parent ab9b75ff32
commit ede8f295a2
6 changed files with 1051 additions and 551 deletions

View File

@@ -83,10 +83,7 @@ export function Routes() {
</Route> </Route>
<Route id="words"> <Route id="words">
<RouteHead title="단어" sub="Vocabulary" /> <VocabTab />
<Scroll>
<VocabTab />
</Scroll>
</Route> </Route>
<Route id="learn"> <Route id="learn">

View File

@@ -65,7 +65,17 @@ export function RouteHead({
* The route's scroller. The page itself never scrolls. The position is * The route's scroller. The page itself never scrolls. The position is
* put back when the route is shown again: a display:none box loses it. * put back when the route is shown again: a display:none box loses it.
*/ */
export function Scroll({ children }: { children: ReactNode }) { export function Scroll({
children,
bare = false,
onNearEnd,
}: {
children: ReactNode;
/** Full-bleed content: no centred column around it. */
bare?: boolean;
/** Called when the end comes within a screen's height — for a list that loads more. */
onNearEnd?: () => void;
}) {
const active = useRouteActive(); const active = useRouteActive();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const top = useRef(0); const top = useRef(0);
@@ -79,10 +89,13 @@ export function Scroll({ children }: { children: ReactNode }) {
ref={ref} ref={ref}
className="scroll" className="scroll"
onScroll={(e) => { onScroll={(e) => {
if (active) top.current = e.currentTarget.scrollTop; if (!active) return;
const el = e.currentTarget;
top.current = el.scrollTop;
if (onNearEnd && el.scrollHeight - el.scrollTop - el.clientHeight < el.clientHeight) onNearEnd();
}} }}
> >
<div className="wrap page">{children}</div> {bare ? children : <div className="wrap page">{children}</div>}
</div> </div>
); );
} }

View File

@@ -1,66 +1,166 @@
/* 오늘 — where you are and what is due. The settings are their own page. */ /* 오늘 — the one number that drives the one action.
import { useEffect, useState } from "react"; What is due, a button to start it, and a line of where things stand.
Everything else — the road, the study log, the deck in numbers — is
folded away underneath, a tap from view. The settings are their own page. */
import { useEffect, useRef, useState } from "react";
import { useStore } from "../../state/store.js"; import { useStore } from "../../state/store.js";
import { useReview } from "../review/useReview.js"; import { useReview } from "../review/useReview.js";
import { useNavigator } from "../shell/router.js"; import { deck, streakFrom, studyLog, type DayRow, type DeckEntry } from "../../domain/cards.js";
import { counts, streakFrom, studyLog, type Counts, type DayRow } from "../../domain/cards.js"; import { curriculum, UNITS } from "../../domain/gate.js";
import { curriculum } from "../../domain/gate.js";
import { currentUnit } from "../../domain/progress.js"; import { currentUnit } from "../../domain/progress.js";
import { GRAMMAR_LEARNED, readJsonMeta } from "../../domain/notes.js";
import grammarJson from "@data/grammar.json";
import sfxJson from "@data/sfx.json";
import "./today.css"; import "./today.css";
const HEATMAP_DAYS = 7 * 22; const GRAMMAR_POINTS = (grammarJson as unknown as { points: unknown[] }).points.length;
const SOUND_WORDS = (sfxJson as unknown as { items: unknown[] }).items.length;
/** Seconds a card takes, for the estimate under the number. */
const SECONDS_PER_CARD = 7;
const MAX_WEEKS = 36;
const CELL = 18;
const GAP = 4;
/** A day number (lib/srs.js dayNumber) as the local calendar date it names. */
function dateOf(day: number): Date {
const t = new Date(day * 864e5);
return new Date(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate());
}
const short = (d: Date) => d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
const isDue = (e: DeckEntry, today: number) => e.card !== null && e.status !== "new" && e.card.due <= today;
/* ── the study log ───────────────────────────────────────────────── */
function Heatmap({ rows, today }: { rows: DayRow[]; today: number }) { function Heatmap({ rows, today }: { rows: DayRow[]; today: number }) {
const byDay = new Map(rows.map((r) => [r.day, r.reviews + r.drills])); const box = useRef<HTMLDivElement>(null);
const start = today - HEATMAP_DAYS + 1; const [weeks, setWeeks] = useState(22);
// As many weeks as fit. The fold is closed at first, so this runs again
// once it opens and has a width.
useEffect(() => {
const el = box.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const w = el.clientWidth;
if (w > 0) setWeeks(Math.max(10, Math.min(MAX_WEEKS, Math.floor((w - 36) / (CELL + GAP)))));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const byDay = new Map(rows.map((r) => [r.day, r]));
const monday = (dateOf(today).getDay() + 6) % 7;
const start = today - monday - (weeks - 1) * 7;
const level = (n: number) => (n === 0 ? 0 : n < 5 ? 1 : n < 15 ? 2 : n < 30 ? 3 : 4); const level = (n: number) => (n === 0 ? 0 : n < 5 ? 1 : n < 15 ? 2 : n < 30 ? 3 : 4);
const cells = []; const cells = [];
for (let d = start; d <= today; d++) { const months = [];
const n = byDay.get(d) ?? 0; let lastMonth = -1;
cells.push( let lastLabel = -99;
<i let total = 0;
key={d} let active = 0;
data-level={level(n)} for (let w = 0; w < weeks; w++) {
title={n ? `${n} answers` : "nothing"} const first = dateOf(start + w * 7);
aria-label={n ? `${n} answers` : "nothing"} if (first.getMonth() !== lastMonth && (w === 0 || first.getDate() <= 7) && w - lastLabel >= 3) {
/>, lastMonth = first.getMonth();
); lastLabel = w;
months.push(
<span key={w} style={{ left: w * (CELL + GAP) }}>
{first.toLocaleDateString(undefined, { month: "short" })}
</span>,
);
}
for (let d = 0; d < 7; d++) {
const day = start + w * 7 + d;
if (day > today) {
cells.push(<i key={day} data-l="0" data-future="1" />);
continue;
}
const r = byDay.get(day);
const n = (r?.reviews ?? 0) + (r?.drills ?? 0);
total += n;
if (n) active++;
cells.push(
<i
key={day}
data-l={level(n)}
data-today={day === today ? "1" : undefined}
title={
n
? `${short(dateOf(day))}${r?.reviews ?? 0} reviews, ${r?.drills ?? 0} drill answers`
: `${short(dateOf(day))} — no study`
}
/>,
);
}
} }
const total = rows.reduce((a, r) => a + r.reviews + r.drills, 0);
return ( return (
<div className="panel"> <div ref={box}>
<div className="panel-h"> <div className="hm-scroll">
<h2> </h2> <div className="hm">
<span className="note tnum">{total} answers in the last 22 weeks</span> <div className="hm-days" aria-hidden="true">
<span className="ko"></span>
<span />
<span className="ko"></span>
<span />
<span className="ko"></span>
<span />
<span className="ko"></span>
</div>
<div className="hm-grid-wrap">
<div className="hm-months">{months}</div>
<div className="hm-grid">{cells}</div>
</div>
</div>
</div> </div>
<div className="panel-b"> <div className="hm-legend">
<div className="hm">{cells}</div> <span>fewer</span>
{[0, 1, 2, 3, 4].map((l) => (
<i key={l} data-l={l} />
))}
<span>more</span>
<span className="hm-total tnum">
{total.toLocaleString()} answers over {active} day{active === 1 ? "" : "s"} · last {weeks} weeks
</span>
</div> </div>
</div> </div>
); );
} }
/* ── the screen ──────────────────────────────────────────────────── */
export function TodayTab() { export function TodayTab() {
const { db, progress, prefs, today, revision } = useStore(); const { db, progress, prefs, today, revision } = useStore();
const nav = useNavigator();
const { start } = useReview(); const { start } = useReview();
const [stats, setStats] = useState<Counts | null>(null); const [pool, setPool] = useState<DeckEntry[] | null>(null);
const [words, setWords] = useState<DeckEntry[]>([]);
const [sentences, setSentences] = useState<DeckEntry[]>([]);
const [log, setLog] = useState<DayRow[]>([]); const [log, setLog] = useState<DayRow[]>([]);
const [grammar, setGrammar] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const [c, rows] = await Promise.all([ const [p, w, s, rows, learned] = await Promise.all([
counts(db, today, { sentences: prefs.sentences, pool: progress }), deck(db, { sentences: prefs.sentences, pool: progress }),
studyLog(db, today - HEATMAP_DAYS), deck(db),
deck(db, { only: "sentences" }),
studyLog(db, today - MAX_WEEKS * 7 - 7),
readJsonMeta<Record<string, boolean>>(db, GRAMMAR_LEARNED, {}),
]); ]);
if (cancelled) return; if (cancelled) return;
setStats(c); setPool(p);
setWords(w);
setSentences(s);
setLog(rows); setLog(rows);
setGrammar(Object.values(learned).filter(Boolean).length);
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
@@ -68,107 +168,159 @@ export function TodayTab() {
}, [db, prefs.sentences, progress, revision, today]); }, [db, prefs.sentences, progress, revision, today]);
const unit = currentUnit(progress); const unit = currentUnit(progress);
const doneUnits = Object.keys(progress.done).length;
const totalUnits = curriculum.phases.reduce((a, p) => a + p.units.length, 0); // The same number as the badge on 복습: what a review started now holds.
const streak = streakFrom(log, today); const due = pool?.filter((e) => isDue(e, today)).length ?? 0;
const fresh = pool?.filter((e) => e.status === "new").length ?? 0;
const newToday = Math.max(0, Math.min(prefs.newPerDay, fresh));
const total = due + newToday;
const nextDue = (pool ?? [])
.filter((e) => e.card && e.status !== "new" && e.card.due > today)
.reduce((min, e) => Math.min(min, e.card!.due), Infinity);
const todayRow = log.find((r) => r.day === today); const todayRow = log.find((r) => r.day === today);
const answered = (todayRow?.reviews ?? 0) + (todayRow?.drills ?? 0); const reviewedToday = todayRow?.reviews ?? 0;
const goalPct = Math.min(100, Math.round((answered / Math.max(1, prefs.goal)) * 100)); const streak = streakFrom(log, today);
const secure = words.filter((e) => e.status === "secure").length;
const goalPct = Math.min(100, Math.round((reviewedToday / Math.max(1, prefs.goal)) * 100));
const sub = !pool
? "…"
: total === 0
? `nothing due — next on ${Number.isFinite(nextDue) ? short(dateOf(nextDue)) : "your next new card"}`
: [due ? `${due} due` : "", newToday ? `${newToday} new` : ""].filter(Boolean).join(" · ") +
` · about ${Math.max(1, Math.round((total * SECONDS_PER_CARD) / 60))} min`;
return ( return (
<> <>
<div className="hero panel"> <div className="card due-card">
<div className="hero-l"> <div className="due-top">
<span className="eyebrow"> <span className="due-n tnum">{pool ? total : "—"}</span>
Phase {unit.phase} · unit {unit.id} <span className="due-x">{sub}</span>
</span>
<h1 className="ko serif">{unit.ko}</h1>
<p className="hero-sub">{unit.goal}</p>
<div className="hero-acts">
<button
className="btn big primary"
disabled={!stats?.due && !stats?.fresh}
onClick={() => void start()}
>
Start review{stats ? ` · ${stats.due + Math.min(stats.fresh, prefs.newPerDay)}` : ""}
</button>
<button className="btn big" onClick={() => nav.go("lesson")}>
Go to
</button>
</div>
<div className="goal">
<div className="goalbar">
<i style={{ width: `${goalPct}%` }} />
</div>
<span className="tnum">
{answered} / {prefs.goal} today
</span>
</div>
</div> </div>
<button className="cta" disabled={!pool || total === 0} onClick={() => void start()}>
{pool && total === 0 ? "Nothing due" : `Start review${pool ? ` · ${total}` : ""}`}
</button>
</div>
<div className="tiles"> <div className="strip">
<div className="tile accent"> <span>
<span className="v tnum">{stats?.due ?? ""}</span> 🔥 <b>{streak}</b> day{streak === 1 ? "" : "s"}
<span className="k">Due</span> </span>
</div> <span>
<div className="tile"> <b>{(todayRow?.reviews ?? 0) + (todayRow?.drills ?? 0)}</b> answered today
<span className="v tnum">{streak}</span> </span>
<span className="k">Day streak</span> <span>
</div> <b>{secure}</b> words secure
<div className="tile"> </span>
<span className="v tnum">{stats?.secure ?? "—"}</span> </div>
<span className="k">Secure</span>
<span className="x">interval 21 days</span> <div className="card goal-card">
</div> <div className="goal-h">
<div className="tile"> <span>Today's reviews</span>
<span className="v tnum"> <span className="tnum">
{doneUnits}/{totalUnits} <b>{reviewedToday}</b> / {prefs.goal}
</span> </span>
<span className="k">Units done</span> </div>
</div> <div className="goalbar">
<i style={{ width: `${goalPct}%` }} />
</div> </div>
</div> </div>
<div className="panel"> <details className="fold">
<div className="panel-h"> <summary>
<h2> </h2> <span>
<span className="note">The road to reading manhwa</span> <span className="ko"></span> · Where you are
</div> </span>
<div className="panel-b phases"> <span className="note tnum">
{curriculum.phases.length} phases · {UNITS.length} units
</span>
</summary>
<div className="fold-b phases">
{curriculum.phases.map((p) => { {curriculum.phases.map((p) => {
const done = p.units.filter((u) => progress.done[u.id]).length; const done = p.units.filter((u) => progress.done[u.id]).length;
const isNow = p.units.some((u) => u.id === unit.id); const isNow = p.units.some((u) => u.id === unit.id);
const state = done === p.units.length ? "done" : isNow ? "now" : "todo"; const state = isNow ? "now" : done === p.units.length ? "done" : "next";
return ( return (
<div className="phase" data-st={state} key={p.phase}> <div className="phase" data-st={state} key={p.phase}>
<span className="n serif">{p.phase}</span> <div className="ph-top">
<div className="ph-body"> <span className="num serif">{p.phase}</span>
<div className="ph-title"> <span className="ko">{p.ko}</span>
<span className="ko">{p.ko}</span> <span className="nm">{p.name}</span>
<span className="nm">{p.name}</span>
<span className="ph-badge">
{state === "done" ? "complete" : state === "now" ? "you are here" : "ahead"}
</span>
</div>
<p className="dt">
{isNow ? `${unit.ko}${unit.goal}` : p.units.map((u) => u.ko).join(" · ")}
</p>
<div className="pbar">
<i style={{ width: `${(done / p.units.length) * 100}%` }} />
</div>
<span className="ph-count tnum">
{done} of {p.units.length} units
</span>
</div> </div>
<span className="ph-badge">
{state === "done" ? "complete" : state === "now" ? "you are here" : "up next"}
</span>
<span className="dt ko">
{isNow ? `${unit.ko}${unit.goal}` : p.units.map((u) => u.ko).join(" · ")}
</span>
<span className="units tnum">
{done} of {p.units.length} units
</span>
<span className="pbar">
<i style={{ width: `${(done / p.units.length) * 100}%` }} />
</span>
</div> </div>
); );
})} })}
</div> </div>
</div> </details>
<Heatmap rows={log} today={today} /> <details className="fold">
<summary>
<span>
<span className="ko"></span> · Study log
</span>
<span className="note tnum">{streak ? `${streak}-day streak` : "no streak yet"}</span>
</summary>
<div className="fold-b">
<Heatmap rows={log} today={today} />
</div>
</details>
<details className="fold">
<summary>
<span>
<span className="ko"></span> · Deck breakdown
</span>
<span className="note tnum">{words.length} words</span>
</summary>
<div className="fold-b">
<table className="breakdown tnum">
<tbody>
<tr>
<td>Words, not yet seen</td>
<td>{words.filter((e) => e.status === "new").length}</td>
</tr>
<tr>
<td>Words in review</td>
<td>{words.filter((e) => e.status === "learning" || e.status === "review").length}</td>
</tr>
<tr>
<td>Words secure ( 21 days)</td>
<td>{secure}</td>
</tr>
<tr>
<td>Sentences started</td>
<td>
{sentences.filter((e) => e.status !== "new").length} / {sentences.length}
</td>
</tr>
<tr>
<td>Grammar points learned</td>
<td>
{grammar} / {GRAMMAR_POINTS}
</td>
</tr>
</tbody>
</table>
</div>
</details>
<p className="today-foot tnum">
{words.length} words · {sentences.length} sentence cards · {GRAMMAR_POINTS} grammar points ·{" "}
{SOUND_WORDS} sound words.
</p>
</> </>
); );
} }

View File

@@ -1,60 +1,92 @@
/* 단어 — the reviewable deck, filtered and searchable. /* 단어 — his words: the deck, filtered, searched, added to.
Rows come from `lemma` joined to `card`, not from an in-memory array, so Rows come from `lemma` joined to `card`, not from an in-memory array, so
this is the same data the tutor's word rail and the review overlay see. */ this is the same data the lesson's word list and the review see.
Below 840px it is a list, not a table: several hundred rows is a browse-
and-act job, and a grid only pays for itself when columns are compared.
A row opens its actions rather than crowding three buttons into 360px.
The artifact chose between list and table once, at first render; turning
a tablet round left the wrong one. */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useStore } from "../../state/store.js"; import { useStore } from "../../state/store.js";
import { useReview } from "../review/useReview.js"; import {
import { deck, forget, markAsKnown, type CardStatus, type DeckEntry } from "../../domain/cards.js"; deck,
forget,
isMine,
markAsKnown,
type CardStatus,
type DeckEntry,
} from "../../domain/cards.js";
import { editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js"; import { editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js";
import { search, type Entry } from "../../domain/lexicon.js"; import { search, type Entry } from "../../domain/lexicon.js";
import { ensureReferenceBand } from "../../domain/dictionary.js"; import { ensureReferenceBand } from "../../domain/dictionary.js";
import { statusOf } from "@lib/srs.js"; import { RouteHead, Scroll } from "../shell/Route.js";
import { Pop } from "../shell/Pop.js";
import { WIDE, useMedia } from "../shell/useMedia.js";
import "./vocab.css"; import "./vocab.css";
const STATUSES: (CardStatus | "all")[] = ["all", "new", "learning", "review", "secure"]; type Filter = "mine" | "all" | "due" | CardStatus;
const FILTERS: [Filter, string][] = [
["mine", "내 진도 My units"],
["all", "전체 All"],
["due", "Due"],
["new", "New"],
["learning", "Learning"],
["review", "In review"],
["secure", "Secure"],
];
const STATUS_LABEL: Record<CardStatus, string> = { const STATUS_LABEL: Record<CardStatus, string> = {
new: "New", new: "New",
learning: "Learning", learning: "Learning",
review: "Review", review: "In review",
secure: "Secure", secure: "Secure",
}; };
function statusText(e: DeckEntry): string { /** His own words, and dictionary words he added, have no topic of their own. */
if (!e.card || e.status === "new") return STATUS_LABEL.new; const OWN_TOPIC = "내 단어 My words";
if (e.status === "learning") return STATUS_LABEL.learning; const topicOf = (e: DeckEntry) => e.topic ?? OWN_TOPIC;
const days = e.card.interval;
return `${STATUS_LABEL[e.status]} · every ${days} d`; const PAGE = 40;
const POSES = ["noun", "verb", "adj", "adv", "pron", "phrase", "particle"];
function detail(e: DeckEntry): string {
if (e.card && e.status !== "new" && e.status !== "learning") return `every ${e.card.interval} d`;
return "";
} }
export function VocabTab() { export function VocabTab() {
const { db, today, revision, invalidate } = useStore(); const { db, today, progress, revision, invalidate } = useStore();
const { start } = useReview(); const wide = useMedia(WIDE);
const [entries, setEntries] = useState<DeckEntry[]>([]); const [entries, setEntries] = useState<DeckEntry[]>([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [status, setStatus] = useState<CardStatus | "all">("all"); const [filter, setFilter] = useState<Filter>("mine");
const [pos, setPos] = useState<string>("all"); const [topic, setTopic] = useState<string | null>(null);
const [shownRows, setShownRows] = useState(PAGE);
const [dict, setDict] = useState<Entry[] | null>(null); const [dict, setDict] = useState<Entry[] | null>(null);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState({ headword: "", gloss: "", pos: "noun" }); const [draft, setDraft] = useState({ headword: "", gloss: "", pos: "noun" });
const [addNote, setAddNote] = useState<string | null>(null); const [addNote, setAddNote] = useState<string | null>(null);
/** The row whose actions are open, below 840px. */
const [row, setRow] = useState<DeckEntry | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { void deck(db).then((rows) => {
const rows = await deck(db, { sentences: true });
if (!cancelled) setEntries(rows); if (!cancelled) setEntries(rows);
})(); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [db, revision]); }, [db, revision]);
/* Searching past the deck reaches into the whole dictionary — for looking /* Searching past the deck reaches into the whole dictionary — a word met
something up, not for studying it. Those rows are read-only here. */ in the wild can be looked up, and added. */
useEffect(() => { useEffect(() => {
const q = query.trim(); const q = query.trim();
if (q.length < 2) { if (q.length < 2) {
@@ -73,233 +105,300 @@ export function VocabTab() {
}; };
}, [db, query]); }, [db, query]);
const positions = useMemo( const passes = useMemo(() => {
() => ["all", ...[...new Set(entries.map((e) => e.pos))].sort()], const due = (e: DeckEntry) => e.card !== null && e.status !== "new" && e.card.due <= today;
[entries], return (e: DeckEntry, f: Filter) =>
); f === "all" ? true : f === "mine" ? isMine(e, progress) : f === "due" ? due(e) : e.status === f;
}, [progress, today]);
const shown = useMemo(() => { const counts = useMemo(() => {
const byFilter = new Map<Filter, number>(FILTERS.map(([f]) => [f, 0]));
const byTopic = new Map<string, number>();
for (const e of entries) {
for (const [f] of FILTERS) if (passes(e, f)) byFilter.set(f, byFilter.get(f)! + 1);
byTopic.set(topicOf(e), (byTopic.get(topicOf(e)) ?? 0) + 1);
}
return { byFilter, byTopic };
}, [entries, passes]);
const matching = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
return entries.filter((e) => { return entries.filter(
if (status !== "all" && e.status !== status) return false; (e) =>
if (pos !== "all" && e.pos !== pos) return false; passes(e, filter) &&
if (!q) return true; (topic === null || topicOf(e) === topic) &&
return `${e.headword} ${e.glossEn}`.toLowerCase().includes(q); (!q || `${e.headword} ${e.glossEn}`.toLowerCase().includes(q)),
}); );
}, [entries, pos, query, status]); }, [entries, filter, passes, query, topic]);
// A new filter starts the list from the top again.
useEffect(() => setShownRows(PAGE), [filter, topic, query]);
const inDeck = useMemo(() => new Set(entries.map((e) => e.lemmaId)), [entries]); const inDeck = useMemo(() => new Set(entries.map((e) => e.lemmaId)), [entries]);
const extra = dict?.filter((d) => !inDeck.has(d.lemmaId)) ?? []; const extra = dict?.filter((d) => !inDeck.has(d.lemmaId)) ?? [];
return ( const act = async (fn: () => Promise<void>) => {
await fn();
setRow(null);
invalidate();
};
const actions = (e: DeckEntry) => (
<> <>
<div className="panel"> {e.status === "new" ? (
<div className="panel-h"> <button className="btn sm" onClick={() => void act(() => markAsKnown(db, e.lemmaId, today))}>
<h2></h2> Know it
<span className="note tnum"> </button>
{shown.length} of {entries.length} shown ) : (
</span> <button className="btn sm" onClick={() => void act(() => forget(db, e.lemmaId))}>
</div> Reset
</button>
<div className="panel-b"> )}
<div className="toolbar"> {e.source === "custom" && (
<input <button
className="grow" className="btn sm"
type="search" title="Remove this word entirely"
value={query} onClick={() => void act(() => editRemoveCustomWord(db, e.lemmaId))}
placeholder="Search 한글 or English…" >
aria-label="Search your words in 한글 or English" Delete
onChange={(e) => setQuery(e.target.value)} </button>
/>
<button className="btn" onClick={() => setAdding((a) => !a)} aria-expanded={adding}>
{adding ? "Cancel" : "+ Add a word"}
</button>
<button className="btn primary" onClick={() => void start()}>
Review these
</button>
</div>
{adding && (
<form
className="add-word"
onSubmit={async (e) => {
e.preventDefault();
if (!draft.headword.trim() || !draft.gloss.trim()) return;
try {
const { created } = await editAddCustomWord(db, draft);
setAddNote(
created
? `Added ${draft.headword.trim()}.`
: `${draft.headword.trim()} was already in the dictionary — added to your deck.`,
);
setDraft({ headword: "", gloss: "", pos: "noun" });
} catch (err) {
setAddNote(err instanceof Error ? err.message : "Could not add that word.");
}
invalidate();
}}
>
<input
className="ko"
value={draft.headword}
placeholder="한글"
aria-label="The word, in 한글"
required
onChange={(e) => setDraft({ ...draft, headword: e.target.value })}
/>
<input
value={draft.gloss}
placeholder="What it means"
aria-label="What it means"
required
onChange={(e) => setDraft({ ...draft, gloss: e.target.value })}
/>
<select
aria-label="Part of speech"
value={draft.pos}
onChange={(e) => setDraft({ ...draft, pos: e.target.value })}
>
{["noun", "verb", "adj", "adv", "pron", "phrase", "particle"].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<button className="btn primary" type="submit">
Add
</button>
<p className="add-note">
{addNote ??
"Your own words sit alongside the dictionary and survive a rebuild of it."}
</p>
</form>
)}
<div className="topics" style={{ marginTop: 11 }}>
{STATUSES.map((s) => (
<button key={s} aria-pressed={status === s} onClick={() => setStatus(s)}>
{s === "all" ? "전체 All" : STATUS_LABEL[s]}
</button>
))}
</div>
<div className="topics" style={{ marginTop: 7 }}>
{positions.map((p) => (
<button key={p} aria-pressed={pos === p} onClick={() => setPos(p)}>
{p === "all" ? "품사 All" : p}
</button>
))}
</div>
</div>
<div className="tbl-scroll">
<table className="words">
<thead>
<tr>
<th></th>
<th>Meaning</th>
<th>State</th>
<th />
</tr>
</thead>
<tbody>
{shown.map((e) => (
<tr key={e.lemmaId}>
<td>
<div className="w-ko ko">{e.headword}</div>
</td>
<td>
<div className="w-en">{e.glossEn}</div>
<div className="w-ro">{e.pos}</div>
</td>
<td>
<span className={`state ${e.status}`}>{statusText(e)}</span>
</td>
<td>
<div className="rowacts">
{e.status === "new" ? (
<button
className="btn sm"
onClick={async () => {
await markAsKnown(db, e.lemmaId, today);
invalidate();
}}
>
Know it
</button>
) : (
<button
className="btn sm"
onClick={async () => {
await forget(db, e.lemmaId);
invalidate();
}}
>
Reset
</button>
)}
{e.source === "custom" && (
<button
className="btn sm"
title="Remove this word entirely"
onClick={async () => {
await editRemoveCustomWord(db, e.lemmaId);
invalidate();
}}
>
Delete
</button>
)}
</div>
</td>
</tr>
))}
{!shown.length && (
<tr>
<td colSpan={4}>
<p className="empty">Nothing matches.</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{extra.length > 0 && (
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note">
{extra.length} more in the dictionary reference only, not in the deck
</span>
</div>
<div className="tbl-scroll">
<table className="words">
<tbody>
{extra.map((d) => (
<tr key={d.lemmaId}>
<td>
<div className="w-ko ko">{d.headword}</div>
</td>
<td>
<div className="w-en">{d.glossEn}</div>
<div className="w-ro">
{d.pos}
{d.freqRank ? ` · rank ${d.freqRank}` : ""}
</div>
</td>
<td>
<span className="state new">{statusOf(null) === "new" ? "Reference" : ""}</span>
</td>
<td />
</tr>
))}
</tbody>
</table>
</div>
</div>
)} )}
</> </>
); );
const universe = filter === "mine" ? counts.byFilter.get("mine")! : entries.length;
return (
<>
<RouteHead title="단어" sub="Vocabulary">
<button
className="iconbtn"
title="Add a word"
aria-label="Add a word"
aria-pressed={adding}
onClick={() => setAdding((a) => !a)}
>
</button>
</RouteHead>
<div className="listhead">
<input
type="search"
value={query}
placeholder="Search 한글 or English…"
aria-label="Search your words in 한글 or English"
autoComplete="off"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="filters" role="group" aria-label="Filter">
{FILTERS.map(([f, label]) => (
<button
key={f}
className="ko"
aria-pressed={filter === f && (f !== "all" || topic === null)}
onClick={() => {
setFilter(f);
if (f === "all") setTopic(null);
}}
>
{label} · {counts.byFilter.get(f)}
</button>
))}
{[...counts.byTopic].map(([t, n]) => (
<button
key={t}
className="ko"
aria-pressed={topic === t}
onClick={() => setTopic((cur) => (cur === t ? null : t))}
>
{t} · {n}
</button>
))}
</div>
</div>
<Scroll bare onNearEnd={() => setShownRows((n) => (n < matching.length ? n + PAGE : n))}>
{adding && (
<form
className="wrap add-word"
onSubmit={async (e) => {
e.preventDefault();
if (!draft.headword.trim() || !draft.gloss.trim()) return;
try {
const { created } = await editAddCustomWord(db, draft);
setAddNote(
created
? `Added ${draft.headword.trim()}.`
: `${draft.headword.trim()} was already in the dictionary — it is in your deck now.`,
);
setDraft({ headword: "", gloss: "", pos: "noun" });
} catch (err) {
setAddNote(err instanceof Error ? err.message : "Could not add that word.");
}
invalidate();
}}
>
<div className="card add-card">
<label className="field">
<span className="ko"></span>
<input
className="ko"
value={draft.headword}
required
onChange={(e) => setDraft({ ...draft, headword: e.target.value })}
/>
</label>
<label className="field">
Meaning
<input
value={draft.gloss}
required
onChange={(e) => setDraft({ ...draft, gloss: e.target.value })}
/>
</label>
<label className="field">
Part of speech
<select value={draft.pos} onChange={(e) => setDraft({ ...draft, pos: e.target.value })}>
{POSES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<button className="cta add-go" type="submit">
Add to deck
</button>
<p className="add-note">
{addNote ?? "Your own words sit alongside the dictionary and survive a rebuild of it."}
</p>
</div>
</form>
)}
<p className={`count tnum${wide ? " wrap" : ""}`}>
{matching.length} of {universe} words
</p>
{wide ? (
<div className="wrap">
<div className="tbl-scroll">
<table className="words">
<thead>
<tr>
<th className="shrink"></th>
<th>Meaning</th>
<th className="shrink">Topic</th>
<th className="shrink">State</th>
<th className="shrink" />
</tr>
</thead>
<tbody>
{matching.slice(0, shownRows).map((e) => (
<tr key={e.lemmaId}>
<td>
<div className="w-ko ko">{e.headword}</div>
</td>
<td>
<div className="w-en">{e.glossEn}</div>
<div className="w-pos">{e.pos}</div>
</td>
<td className="w-tag ko">{topicOf(e)}</td>
<td>
<span className={`state ${e.status}`}>
{STATUS_LABEL[e.status]}
{detail(e) && ` · ${detail(e)}`}
</span>
</td>
<td>
<div className="rowacts">{actions(e)}</div>
</td>
</tr>
))}
{!matching.length && (
<tr>
<td colSpan={5}>
<p className="empty">No words match that filter.</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="vlist">
{matching.slice(0, shownRows).map((e) => (
<button className="vrow" key={e.lemmaId} onClick={() => setRow(e)}>
<span className="tx">
<span className="k ko">{e.headword}</span>
<span className="m">
{e.glossEn}
<span className="meta">
{" "}
· {[e.pos, e.status === "learning" ? "learning" : detail(e)].filter(Boolean).join(" · ")}
</span>
</span>
</span>
<span className={`dot ${e.status}`} title={STATUS_LABEL[e.status]} />
</button>
))}
{!matching.length && <p className="empty">No words match that filter.</p>}
</div>
)}
{extra.length > 0 && (
<div className="wrap dict-more">
<p className="count">
<span className="ko"></span> {extra.length} more in the dictionary, not in your deck
</p>
<div className="rows">
{extra.map((d) => (
<div className="row dict-row" key={d.lemmaId}>
<span className="tx">
<span className="t1 ko">{d.headword}</span>
<span className="t2">
{d.glossEn} · {d.pos}
{d.freqRank ? ` · rank ${d.freqRank}` : ""}
</span>
</span>
<button
className="btn sm"
onClick={async () => {
await editAddCustomWord(db, { headword: d.headword, pos: d.pos, gloss: d.glossEn });
invalidate();
}}
>
+ Add
</button>
</div>
))}
</div>
</div>
)}
</Scroll>
<Pop id="word-row" open={row !== null && !wide} onClose={() => setRow(null)} label="Word">
{row && (
<>
<div className="pop-b">
<span className="pop-k ko">{row.headword}</span>
<span className="pop-m">{row.glossEn}</span>
<span className="pop-n ko">
{topicOf(row)} · {row.pos} · {STATUS_LABEL[row.status]}
{detail(row) && ` · ${detail(row)}`}
</span>
</div>
<div className="pop-f">
{actions(row)}
<button className="btn sm" onClick={() => setRow(null)}>
Close
</button>
</div>
</>
)}
</Pop>
</>
);
} }

View File

@@ -1,224 +1,312 @@
/* 오늘 — hero, tiles, phase cards, heatmap. */ /* 오늘 — the due card, the strip, the goal, and the folds. */
.hero { .due-card {
display: grid; display: flex;
grid-template-columns: 1.1fr 0.9fr; flex-direction: column;
gap: 20px; align-items: center;
padding: 20px; gap: 14px;
padding: 22px 18px;
text-align: center;
} }
.hero-l { .due-top {
display: flex;
flex-direction: column;
gap: 2px;
}
.due-n {
font-family: var(--serif);
font-size: 52px;
font-weight: 700;
line-height: 1;
color: var(--jade);
}
.due-x {
font-size: 13px;
color: var(--ink3);
}
.goal-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 9px; gap: 9px;
min-width: 0; padding: 14px 16px;
} }
.hero-l h1 { .goal-h {
font-size: 34px;
font-weight: 600;
line-height: 1.2;
}
.hero-sub {
font-size: 14px;
color: var(--ink2);
}
.hero-acts {
display: flex; display: flex;
gap: 9px; justify-content: space-between;
flex-wrap: wrap; font-size: 12.5px;
margin-top: 5px;
}
.goal {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
font-size: 12px;
color: var(--ink3); color: var(--ink3);
} }
.goal-h b {
color: var(--ink);
font-weight: 600;
}
.goalbar { .goalbar {
flex: 1; height: 8px;
height: 5px; position: relative;
overflow: hidden;
border-radius: 4px;
background: var(--sunk); background: var(--sunk);
border: 1px solid var(--line);
} }
.goalbar i { .goalbar i {
position: absolute;
inset: 0 auto 0 0;
display: block; display: block;
height: 100%;
background: var(--jade); background: var(--jade);
} }
.tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
align-content: start;
/* The background IS the hairline between tiles, so the box must not be
taller than its rows: as a stretched grid item it filled the rest of
the hero's height with a solid slab of line colour. */
align-self: start;
}
.tile {
background: var(--paper);
padding: 14px;
display: flex;
flex-direction: column;
gap: 1px;
}
.tile.accent {
background: var(--jade-soft);
}
.tile .v {
font-family: var(--serif);
font-size: 28px;
line-height: 1.1;
}
.tile .k {
font-size: 12px;
color: var(--ink2);
}
.tile .x {
font-size: 10.5px;
color: var(--ink3);
}
/* ── phases ──────────────────────────────────────────────────────── */ /* ── phases ──────────────────────────────────────────────────────── */
details.fold .fold-b.phases {
padding: 0;
}
.phases { .phases {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
} }
.phase { .phase {
display: flex; position: relative;
gap: 13px;
padding: 13px;
border: 1px solid var(--line);
background: var(--raise);
}
.phase[data-st="now"] {
box-shadow: inset 3px 0 0 0 var(--hwang);
}
.phase[data-st="done"] {
box-shadow: inset 3px 0 0 0 var(--jade);
}
.phase .n {
font-size: 30px;
color: var(--ink3);
line-height: 1;
}
.ph-body {
flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px; gap: 6px;
padding: 15px 16px;
border: solid var(--line);
border-width: 0 1px 1px 0;
} }
.ph-title { .ph-top {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 8px; gap: 9px;
flex-wrap: wrap;
} }
.ph-title .ko { .phase .num {
font-size: 16px; font-size: 26px;
font-weight: 600; font-weight: 700;
line-height: 1;
color: var(--line2);
} }
.ph-title .nm { .phase .ko {
font-size: 17px;
font-weight: 500;
}
.phase .nm {
font-size: 12px; font-size: 12px;
color: var(--ink3); color: var(--ink3);
} }
.ph-badge { .phase .dt {
margin-left: auto; max-width: 38ch;
font-size: 10.5px; font-size: 12.5px;
padding: 1px 7px; line-height: 1.5;
border: 1px solid var(--line2); color: var(--ink2);
color: var(--ink3);
white-space: nowrap;
} }
.phase[data-st="now"] .ph-badge { .phase .units {
border-color: var(--hwang); font-size: 11.5px;
color: var(--hwang); color: var(--ink3);
background: var(--hwang-soft); }
.ph-badge {
align-self: flex-start;
padding: 2px 7px;
border: 1px solid var(--line2);
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink3);
}
.phase .pbar {
height: 4px;
position: relative;
overflow: hidden;
background: var(--sunk);
}
.phase .pbar i {
position: absolute;
inset: 0 auto 0 0;
display: block;
background: var(--jade);
}
.phase[data-st="done"] .num {
color: var(--jade);
} }
.phase[data-st="done"] .ph-badge { .phase[data-st="done"] .ph-badge {
border-color: var(--jade); border-color: var(--jade);
color: var(--jade-ink);
background: var(--jade-soft); background: var(--jade-soft);
color: var(--jade-ink);
} }
.ph-body .dt { .phase[data-st="now"] {
font-size: 12.5px; background: var(--raise);
color: var(--ink2); box-shadow: inset 3px 0 0 var(--hwang);
} }
.pbar { .phase[data-st="now"] .num {
height: 4px; color: var(--hwang);
background: var(--sunk);
border: 1px solid var(--line);
} }
.pbar i { .phase[data-st="now"] .ph-badge {
display: block; border-color: var(--hwang);
height: 100%; background: var(--hwang-soft);
background: var(--jade); color: var(--hwang);
} }
.ph-count { /* ── the study log ───────────────────────────────────────────────── */
font-size: 11px;
.hm-scroll {
overflow-x: auto;
padding-bottom: 4px;
}
.hm {
display: flex;
gap: 9px;
min-width: max-content;
}
.hm-days {
display: grid;
grid-template-rows: repeat(7, 18px);
gap: 4px;
padding-top: 18px;
}
.hm-days span {
font-size: 10px;
line-height: 18px;
color: var(--ink3); color: var(--ink3);
} }
/* ── heatmap ─────────────────────────────────────────────────────── */ .hm-grid-wrap {
display: flex;
.hm { flex-direction: column;
display: grid;
grid-template-rows: repeat(7, 14px);
grid-auto-flow: column;
grid-auto-columns: 14px;
gap: 3px; gap: 3px;
overflow-x: auto;
} }
.hm i { .hm-months {
position: relative;
height: 14px;
font-size: 10.5px;
color: var(--ink3);
}
.hm-months span {
position: absolute;
white-space: nowrap;
}
.hm-grid {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, 18px);
gap: 4px;
}
.hm-grid i,
.hm-legend i {
display: block; display: block;
width: 18px;
height: 18px;
border-radius: 3px;
background: var(--h0); background: var(--h0);
} }
.hm i[data-level="1"] { background: var(--h1); } .hm-grid i[data-l="1"],
.hm i[data-level="2"] { background: var(--h2); } .hm-legend i[data-l="1"] {
.hm i[data-level="3"] { background: var(--h3); } background: var(--h1);
.hm i[data-level="4"] { background: var(--h4); }
@media (max-width: 820px) {
.phases { grid-template-columns: 1fr; }
} }
@media (max-width: 760px) { .hm-grid i[data-l="2"],
.hero { grid-template-columns: 1fr; } .hm-legend i[data-l="2"] {
.hero-l h1 { font-size: 28px; } background: var(--h2);
}
.hm-grid i[data-l="3"],
.hm-legend i[data-l="3"] {
background: var(--h3);
}
.hm-grid i[data-l="4"],
.hm-legend i[data-l="4"] {
background: var(--h4);
}
.hm-grid i[data-future="1"] {
opacity: 0.35;
}
.hm-grid i[data-today="1"] {
outline: 1.5px solid var(--ink2);
outline-offset: 1px;
}
.hm-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
font-size: 11.5px;
color: var(--ink3);
}
.hm-legend i {
width: 13px;
height: 13px;
}
.hm-total {
margin-left: auto;
}
/* ── the deck in numbers ─────────────────────────────────────────── */
.breakdown {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.breakdown td {
padding: 7px 0;
border-bottom: 1px solid var(--line);
color: var(--ink2);
}
.breakdown tr:last-child td {
border-bottom: none;
}
.breakdown td:last-child {
text-align: right;
font-weight: 500;
color: var(--ink);
}
.today-foot {
padding: 2px 2px 0;
font-size: 12px;
color: var(--ink3);
}
@media (max-width: 599px) {
.phases {
grid-template-columns: 1fr;
}
} }

View File

@@ -1,9 +1,143 @@
/* 단어 — the word table. */ /* 단어 — the fixed search and filters, the list, the table. */
.listhead {
flex: none;
padding: 10px var(--gutter) 0;
border-bottom: 1px solid var(--line);
background: var(--paper);
}
.listhead input[type="search"] {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
}
.filters {
display: flex;
gap: 6px;
margin: 9px calc(-1 * var(--gutter)) 0;
padding: 0 var(--gutter) 9px;
overflow-x: auto;
scrollbar-width: none;
}
.filters::-webkit-scrollbar {
display: none;
}
.filters button {
flex: 0 0 auto;
min-height: 32px;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--raise);
font-size: 12.5px;
color: var(--ink2);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.filters button[aria-pressed="true"] {
border-color: var(--jade);
background: var(--jade);
color: var(--on-jade);
}
.count {
padding: 8px var(--gutter) 2px;
font-size: 12px;
color: var(--ink3);
}
.wrap > .count,
.wrap .count {
padding-inline: 2px;
}
@media (min-width: 600px) {
.count.wrap {
padding-inline: 24px;
}
}
/* ── the list ────────────────────────────────────────────────────── */
.vlist {
background: var(--paper);
border-top: 1px solid var(--line);
}
.vrow {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: var(--row-h);
padding: 10px var(--gutter);
border-bottom: 1px solid var(--line);
text-align: left;
}
.vrow:hover {
background: var(--raise);
}
.vrow .tx {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.vrow .k {
font-size: 18.5px;
font-weight: 500;
line-height: 1.35;
}
.vrow .m {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 14px;
}
.vrow .meta {
font-size: 12px;
color: var(--ink3);
}
.dot {
width: 9px;
height: 9px;
flex: none;
border-radius: 50%;
background: var(--line2);
}
.dot.learning {
background: var(--hwang);
}
.dot.review {
background: var(--jade);
}
.dot.secure {
background: var(--jade);
box-shadow: 0 0 0 3px var(--jade-soft);
}
/* ── the table, from 840px ───────────────────────────────────────── */
.tbl-scroll { .tbl-scroll {
max-height: 62vh; overflow-x: auto;
overflow: auto; border: 1px solid var(--line);
border-top: 1px solid var(--line); border-radius: var(--radius);
background: var(--paper);
} }
table.words { table.words {
@@ -15,74 +149,91 @@ table.words th {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
background: var(--sunk); padding: 9px 12px;
border-bottom: 1px solid var(--line);
background: var(--paper);
text-align: left; text-align: left;
font-size: 11px; font-size: 10.5px;
letter-spacing: 0.08em; font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink3); color: var(--ink3);
font-weight: 600; }
padding: 8px 12px;
border-bottom: 1px solid var(--line); table.words th.shrink {
width: 1%;
white-space: nowrap;
} }
table.words td { table.words td {
padding: 8px 12px; padding: 9px 12px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
vertical-align: top; vertical-align: middle;
}
table.words tr:last-child td {
border-bottom: none;
} }
.w-ko { .w-ko {
font-size: 18px; font-size: 19px;
font-weight: 500;
white-space: nowrap;
} }
.w-en { .w-en {
font-size: 14px; font-size: 14px;
} }
.w-ro { .w-pos {
font-size: 11.5px;
color: var(--ink3);
}
.w-tag {
font-size: 11px; font-size: 11px;
color: var(--ink3); color: var(--ink3);
font-family: var(--mono); white-space: nowrap;
} }
.rowacts { .rowacts {
display: flex; display: flex;
gap: 6px; gap: 5px;
justify-content: flex-end; justify-content: flex-end;
} }
@media (max-width: 640px) { .rowacts .btn {
table.words td:nth-child(3), white-space: nowrap;
table.words th:nth-child(3) {
display: none;
}
} }
/* Add-your-own-word form. */ /* ── adding a word, and the dictionary beyond the deck ───────────── */
.add-word { .add-word {
display: flex; padding-top: 14px;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 12px;
padding: 12px;
background: var(--raise);
border: 1px solid var(--line);
} }
.add-word input { .add-card {
flex: 1 1 160px; display: grid;
min-width: 0; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
align-items: end;
padding: 14px;
} }
.add-word input.ko { .add-go {
font-size: 17px; min-height: 44px;
flex: 0 1 150px;
} }
.add-note { .add-note {
flex-basis: 100%; grid-column: 1 / -1;
font-size: 11.5px; font-size: 11.5px;
color: var(--ink3); color: var(--ink3);
} }
.dict-more {
padding-top: 16px;
}
.dict-row {
min-height: 56px;
}