From 90dd9c91bc6abbe3577bc631b642452166c210da Mon Sep 17 00:00:00 2001 From: renrenmimi Date: Tue, 25 Aug 2026 01:59:37 -0700 Subject: [PATCH 1/5] feat(demo): add a built-in, privacy-safe repository demo The landing page previously offered four of the author's own repositories. It now offers one curated synthetic history instead, so the tool can demonstrate itself without reading anybody's account and without spending GitHub quota. - `src/lib/builtin/learning-platform.ts` holds the fixture: sixteen commits with literal object ids, timestamps, subjects and per-file line counts. - `src/lib/builtin/provider.ts` replays it into the same domain objects the GitHub adapter produces, so the projector, statistics, milestones and panels all run unchanged. Trees are materialised at every commit, so no position on the timeline is ever an approximation. - The service layer resolves `demo/learning-platform` before anything else, in every environment. Nothing else is special: an arbitrary `demo/*` falls through to the ordinary GitHub path and gets the ordinary not-found result. - `RepoMeta.dataSource` is `builtin` or `github`, set by the server, so the UI never has to infer which mode it is in. Built-in metadata and commits carry a null `htmlUrl`, because there is no repository to link to. Co-Authored-By: Claude Opus 5 (1M context) --- fixtures/drilllab/bundle.json | 1 - fixtures/index.json | 6 +- fixtures/renren-across-tabs/bundle.json | 1 - fixtures/sample-app/bundle.json | 1 + fixtures/tiny-app/bundle.json | 1 + scripts/live-smoke.mjs | 21 +- scripts/record-fixtures.mjs | 154 ++++++++-- scripts/screenshots.mjs | 7 +- src/components/CommitPanel.tsx | 16 +- src/components/InsightsPanel.tsx | 15 +- src/components/States.tsx | 83 +++-- src/components/TimeMachine.tsx | 68 +++-- src/components/commit-panel.module.css | 12 + src/components/insights-panel.module.css | 9 + src/components/shell.module.css | 75 +++-- src/components/states.module.css | 133 ++++---- src/lib/builtin/learning-platform.ts | 345 +++++++++++++++++++++ src/lib/builtin/provider.ts | 284 +++++++++++++++++ src/lib/client/history-controller.ts | 37 ++- src/lib/demos.ts | 70 ++--- src/lib/domain/types.ts | 14 +- src/lib/github/adapter.ts | 1 + src/lib/github/service.ts | 26 ++ src/lib/repo-ref.ts | 2 +- tests/builtin-demo.test.ts | 374 +++++++++++++++++++++++ tests/history-controller.test.ts | 97 +++++- tests/repo-ref.test.ts | 18 +- tests/service-routing.test.ts | 152 +++++++++ tests/ui/panels.test.tsx | 12 +- tests/url-state.test.ts | 6 +- 30 files changed, 1793 insertions(+), 248 deletions(-) delete mode 100644 fixtures/drilllab/bundle.json delete mode 100644 fixtures/renren-across-tabs/bundle.json create mode 100644 fixtures/sample-app/bundle.json create mode 100644 fixtures/tiny-app/bundle.json create mode 100644 src/lib/builtin/learning-platform.ts create mode 100644 src/lib/builtin/provider.ts create mode 100644 tests/builtin-demo.test.ts create mode 100644 tests/service-routing.test.ts diff --git a/fixtures/drilllab/bundle.json b/fixtures/drilllab/bundle.json deleted file mode 100644 index d3afa31..0000000 --- a/fixtures/drilllab/bundle.json +++ /dev/null @@ -1 +0,0 @@ -{"repo":{"name":"DrillLab","full_name":"renrenmimi/DrillLab","owner":{"login":"renrenmimi"},"description":"Practising until you can do it alone — four tracks that hand you less each time, ending in an empty folder and a clock","default_branch":"main","html_url":"https://github.com/renrenmimi/DrillLab","created_at":"2026-08-21T08:27:42Z","pushed_at":"2026-08-25T03:26:06Z","language":"TypeScript","stargazers_count":0,"forks_count":0,"size":10076,"license":null,"topics":[],"archived":false,"fork":false},"commits":[{"sha":"e5d430d4012c2fea6bf011a5c481d8ff53534b38","html_url":"https://github.com/renrenmimi/DrillLab/commit/e5d430d4012c2fea6bf011a5c481d8ff53534b38","commit":{"message":"fix(mobile): lock the page behind the drawer, and restore it instantly (#16)\n\nOpening the drawer on a phone left the page behind it scrollable. The scrim\nstops clicks, not scrolling, so dragging over it moved the background about\n88px, and closing the drawer left the page somewhere other than where it\nstarted.\n\nThe body is now position: fixed with a negative top while the drawer is\nopen — not overflow: hidden, which iOS Safari ignores on body, and a phone\nis where this bug lives. Three things come with it:\n\n- Restore the position on close. While the body is fixed the document's\n scroll is 0, so without restoring it, closing snaps to the top.\n- Restore it instantly. html carries scroll-behavior: smooth, so scrollTo\n animated: closing at 2055 dropped scrollY to 0 and crawled back to 2054.5\n over about 1.5 seconds. The cleanup sets documentElement's inline\n scrollBehavior to auto around the call and puts the previous inline value\n back, leaving the global rule in force everywhere else. Focus returns to\n the hamburger with preventScroll for the same reason.\n- Do not restore when navigating away: a new page starts at the top. The\n sidebar's onNavigate marks \"do not restore\" before closing, and so does\n the [path] effect, which covers the browser Back button. Exactly one place\n marks \"do restore\": the moment the lock goes on.\n\nA matching padding-right absorbs the scrollbar that disappears when a\ndesktop window is narrow enough to show the drawer at all.\n\nTwo probes, both verified in both directions:\n\n lock.mjs 4 widths x 9 wheel and scrollTo cannot move the background,\n the drawer itself still scrolls, Escape lands\n on the same pixel. Without the fix the\n background goes from 374 to 1115; 12 fail.\n lock2.mjs 4 widths x 3 ways to close, 64 assertions. Samples\n requestAnimationFrame rather than waiting for a\n smooth scroll to settle, and asserts the\n computed scroll-behavior really is smooth first\n so it cannot pass vacuously. Without the fix\n frames read 0 -> 0 -> 1 -> 4 -> 9 and 48 fail.","author":{"name":"renrenmimi","date":"2026-08-25T03:26:04Z"},"committer":{"name":"GitHub","date":"2026-08-25T03:26:04Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"3bb5b03a63957850ed07d3a399b7696e342c061b"}]},{"sha":"3bb5b03a63957850ed07d3a399b7696e342c061b","html_url":"https://github.com/renrenmimi/DrillLab/commit/3bb5b03a63957850ed07d3a399b7696e342c061b","commit":{"message":"feat(ui): tracks vs plans, one h1 per page, and a top bar that fits 360px (#15)\n\nSeven things that were still unclear, on top of the dial dashboard. The\ndesign, the dials, the sidebar architecture, the alignment axes and all\ncontent, plans, progress and routes are unchanged.\n\nTracks are not plans. Three different things were called \"track\"; now a\ntrack is one of the five subject categories, a guided plan is one order\nacross Learn / Review / Practice / Code / Assess, and the drill and coding\nfilter is a topic. Today carries the one line that separates the first two,\nunder \"Five categories\", because that is where a first visit sees the five\ncards and the sidebar's \"My plan\" at the same time.\n\n/plans has one h1 per state. It used to render the picker, the catalogue\nand the active dashboard at once, and the sidebar's \"Change\" pointed at\n/plans — a link to the page you were already on. Changing now has its own\naddress, /plans/choose, which states that switching erases nothing.\n\nToday is three sections. The two that went were repeats of the sidebar and\nof the dials; they are one disclosure whose summary names what is inside,\nincluding \"clear progress\".\n\nThe five cards say what they mean. Summaries are written by hand rather\nthan course blurbs cut off by -webkit-line-clamp; neither the title nor\nthe \"pick up at\" line truncates; the footer is a fixed two lines, because\nit is the last grid row and its height decides where the divider sits —\ntwo cards in one row had dividers 18px apart. At zero progress no arc is\ndrawn and the ring is a step thinner, its colour untouched: it is a\nmeaningful graphic that wants 3:1, so the quiet comes from weight. A\nfinished track shows a check, and its accessible name follows the\ninterface language.\n\nThe top bar keeps \"DrillLab\" at 390px. That turned out to be arithmetic:\none row has to hold 341px of controls, which measured 377 in a 360px\nviewport, and every candidate for removal is spoken for. Below 520px it\nwraps — brand and tools on row one, Continue across row two, where it can\nshow the task name again.\n\nAlso fixed: the continue line fell back to \"start at the foundations,\nlesson 1 of 9\" whenever there was no visit record, while the card below it\nsaid \"4 / 9, pick up at lesson 5\".\n\nValidation: typecheck, lint, audit:code, build (260 pages). First Load JS\nhome 153, lesson 144, /plans 145, plan detail 176 — all inside budget.\nContrast 84 combinations, overflow 105, alignment axes 39, focus 558\ncontrols driven by real Tab presses, headings across 18 routes and two\nprofiles, flows 176 assertions, cards 84, mobile 17 pages by 3 widths.\nZero failures.","author":{"name":"renrenmimi","date":"2026-08-24T22:41:42Z"},"committer":{"name":"GitHub","date":"2026-08-24T22:41:42Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"36105245c2f613120923b0dac61584e8d945410b"}]},{"sha":"36105245c2f613120923b0dac61584e8d945410b","html_url":"https://github.com/renrenmimi/DrillLab/commit/36105245c2f613120923b0dac61584e8d945410b","commit":{"message":"feat(home): circular dials for the five categories\n\nTwo changes you asked for: they are categories, not courses, and the\ngauges are rings rather than bars.\n\nThe ring is the better read for five cards side by side. A bar asks you to\ncompare five different lengths against each other; a ring shows how far\nround it has gone, and it holds its own \"n / N\" in the middle instead of\nspending a row on it. Each card also states its category — foundations,\nfrontend, backend, full stack — which was already in the content and had\nnowhere to appear.\n\nThree implementation details worth not undoing:\n\n- The arc starts at twelve o'clock (rotate -90deg). Without that it starts\n at three, which reads wrong.\n- Progress is stroke-dashoffset, so a change animates over --t-prog.\n- A finished ring turns --ok, not the accent. The accent means \"look here\n now\"; a finished category is the opposite of that.\n\nThe ring's track needed its own token. It is a meaningful graphic — it is\nwhat tells you where the total is — so WCAG 1.4.11 asks for 3:1, and\nmeasured on a card --rule gives 1.29 and --rule-strong 1.71: at those\nvalues you cannot see that it is a ring at all. --dial-track is 3.05:1\ndark and 2.98:1 light. My first attempt dimmed --ink-4 with opacity 0.45,\nwhich threw the computed value straight back under the line; there is a\nnote in the token telling the next person not to.\n\nThe parallel track spans the full row. Five cards in two columns leave the\nlast one alone on half a row, and it genuinely is a different kind of\nthing — no prerequisites, start whenever. So the span is both layout and\nmeaning.\n\nThe dial numbers live in the DOM and in an aria-label; the two spans in\nthe middle are aria-hidden, because their text reads as \"3 /9\".","author":{"name":"WEIREN FENG","date":"2026-08-24T00:50:27Z"},"committer":{"name":"renrenmimi","date":"2026-08-24T00:51:32Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"8b32b4b0269b883acbee33e1c71bfcd7277f78ff"}]},{"sha":"8b32b4b0269b883acbee33e1c71bfcd7277f78ff","html_url":"https://github.com/renrenmimi/DrillLab/commit/8b32b4b0269b883acbee33e1c71bfcd7277f78ff","commit":{"message":"docs: record the dashboard home, the rail fix and the mobile scan","author":{"name":"WEIREN FENG","date":"2026-08-23T06:49:43Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T06:51:13Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"e430935a4ddde6722b9c12cd8601bd1fc7b97763"}]},{"sha":"e430935a4ddde6722b9c12cd8601bd1fc7b97763","html_url":"https://github.com/renrenmimi/DrillLab/commit/e430935a4ddde6722b9c12cd8601bd1fc7b97763","commit":{"message":"fix(mobile): three real defects behind \"nothing is centred on my phone\"\n\nMeasured every clickable control across 17 pages at 360, 390 and 414 for\nfour things: clipped content, escaping the viewport, claiming to centre\nwhile padding is lopsided, and line-height bursting the box. Three were\nreal.\n\n- The global :focus-visible rule set border-radius. That changes the\n geometry of the focused element, not just the ring. The search input grew\n a 6px radius the moment you focused it, nested at the top of a panel with\n a 14px radius and overflow: hidden — two radii on the same corner. Never\n put geometry in :focus-visible.\n- The search placeholder carried three examples and was cut mid-word at\n 360px (\"…(try \"filte\"). The examples were always demonstrated by the six\n default results right underneath.\n- In the ladder's 2x2, \"Lesson exercises 148\" wraps to two lines while the\n other three fit on one, so the four descriptions sat on two different\n baselines. The name now reserves two lines — with an explicit\n line-height, because inheriting the body's 1.68 makes two lines 47px\n while min-height: 2.7em is 38, leaving the wrapping cell 9px taller.\n\nThe mobile probe is now part of the suite and reports 0 across 51\npage/width combinations. It has to skip the closed drawer (off-screen by\ndesign) and anything inside a horizontal scroller — code windows and wide\ntables legitimately extend past the viewport, and page-level overflow is\npoliced separately. Without those two exemptions it produced 167 false\npositives.","author":{"name":"WEIREN FENG","date":"2026-08-23T06:49:43Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T06:51:13Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"c6ea36c62dbb0cc30fd7202c42734fa12eb194f1"}]},{"sha":"c6ea36c62dbb0cc30fd7202c42734fa12eb194f1","html_url":"https://github.com/renrenmimi/DrillLab/commit/c6ea36c62dbb0cc30fd7202c42734fa12eb194f1","commit":{"message":"fix(path): one rail, drawn once, that does not jog at the current step\n\nThe vertical line on /path has been ugly for a long time. Measured, it was\ntwo faults stacked:\n\n- the current step pulled its card 14px left with a negative margin and\n compensated with padding, so the text lined up but the box did not;\n- the line was drawn by each step's own ::before at left: 8px, relative to\n that step's box — so the current step's segment sat at x=278 while every\n other sat at 292. The rail visibly jogged 14px exactly where the eye was\n already being drawn.\n\nNow every step has the same padding (the current one only changes colour),\nand the line is a single element on the container, so it cannot break or\nshift. It carries z-index 1 to sit above the current card's fill —\notherwise the segment crossing that card is painted over and the rail\nlooks broken again — with the dots at 2.\n\nNo negative margins on steps. That is where this came from.","author":{"name":"WEIREN FENG","date":"2026-08-23T06:49:43Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T06:51:13Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"5f22b633f28569806c158035fdd957ab64590472"}]},{"sha":"5f22b633f28569806c158035fdd957ab64590472","html_url":"https://github.com/renrenmimi/DrillLab/commit/5f22b633f28569806c158035fdd957ab64590472","commit":{"message":"feat(home): a dashboard of tracks, and no question at the door\n\nYou asked what happens if someone today only wants to revise the question\nbank. The answer was: nothing. The first screen asked \"what do you want to\nbe ready for?\" and its three answers were 42, 15 and several hours of\ncommitment. That intent had no entry point at all.\n\nIt was also a one-way door. That screen only rendered while `plan` was\nundefined, and picking anything set it forever — so clicking the logo,\nclicking Today, or typing / all failed to bring it back. You were right to\ncall that confusing; it was a straightforward defect.\n\nThe home page is now three bands and asks nothing:\n\n one line pick up where you left off (the plan's next item if you follow one)\n five cards one per course: where you are, and the lesson you would resume\n counts question bank, exercises, coding, arena, mocks\n\nThe chooser moved to /plans, where it is reachable whenever you want it,\nwith the six full cards below it behind an anchor.\n\nThings worth not undoing:\n\n- The home page has no filled accent block. The five cards are links, and\n the resume line is outlined with an accent edge — so /, /plans and\n /plans/[id] all cede the sidebar's Continue to the page's own actions.\n- The dashboard reads a new generated content/track-manifest.ts (22 KB:\n course names, one-liners, lessons in order) instead of content/nav.ts\n (134 KB). Home's First Load JS went 178 -> 152 kB. The plan manifest\n could not be reused: it covers 65 of 80 lessons, because the interview\n course reaches plans through its questions rather than its lessons.\n- Anything without a notion of \"which lesson am I on\" gets a count and no\n progress bar. The question bank is a queue ordered by how well you know\n each one, coding is 25 independent problems, the arena is 7 separately\n timed sittings. Drawing a percentage across those invents an order that\n does not exist.\n- Course blurbs clamp to two lines. The full text is three to five lines\n because it was written for the course overview; a dashboard you cannot\n scan is not a dashboard.","author":{"name":"WEIREN FENG","date":"2026-08-23T06:49:43Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T06:51:13Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"8255bec9ca99586712bd18869ece5610fc81d6ad"}]},{"sha":"8255bec9ca99586712bd18869ece5610fc81d6ad","html_url":"https://github.com/renrenmimi/DrillLab/commit/8255bec9ca99586712bd18869ece5610fc81d6ad","commit":{"message":"feat(plans): let you run a plan again, and make switching findable\n\nYou asked whether you can change track after picking one, and said you\ncould not find a way to start over. Both halves were fair, for different\nreasons.\n\n**Switching existed but you could not find it.** The sidebar's \"Change\"\nwas 11px, --ink-3, thin underline — the lightest element in the column,\nbecause I had pushed it down to obey \"one dominant action per screen\". My\nregression asserted it was *visible*, and it passed. Visible is not the\nsame as findable, and only the first of those is something a script can\ncheck. It now uses the accent text colour at 600 weight.\n\n**Running a plan again genuinely did not exist**, and could not be built\nby deleting records: plan completion is derived from lesson, exercise and\nquestion records that plans *share* — the React plan and the complete path\nboth count the same nine Foundations lessons, so zeroing React by deletion\nwould drag the complete path down with it.\n\nSo a round is a timestamp, not a deletion. ProgressData.planRounds[planId]\nmarks when this round began; that plan counts only records at or after it,\nevery other plan still counts everything, and nothing is ever removed. To\nmake that possible the four completion bags now store the moment you ticked\nsomething instead of the literal 1. No migration: 1 is smaller than any real\ntimestamp, so old records land on the correct side of any restart, and every\nreader only ever tested truthiness.\n\nTwo things the tests caught that I had wrong:\n\n- The round predicate was `roundStart === undefined || stamp >= roundStart`,\n which returns true when *both* are undefined — that is, a plan you have\n never restarted reported every item you have never done as done. 4/130\n rendered as 92/130 on the first run.\n- A plan dropping to 0/130 looks like lost progress, so the confirmation\n states all three facts (counts from zero, ticks stay, other plans\n unaffected) and the page carries a line saying which date the round\n started, plus a way back.\n\nAlso: the fold hiding \"clear progress\" on the home page was labelled\n\"More: reference, mock scoring, sources, how progress is stored\" — the one\nthing you might be hunting for was the one thing the label omitted.\n\nrounds.mjs, 13 assertions: legacy `1` values still count, restart zeroes the\nplan, no record is deleted, the complete path still counts those lessons,\nlesson ticks are untouched, work done after the restart counts, undo\nrestores. Full suite: flow 69/69, change-plan 12/12, language 49/49, focus\n565 controls across 12 pages, axes 39/39, contrast 0 of 84, overflow 0 of\n105, class coverage difference 0.","author":{"name":"WEIREN FENG","date":"2026-08-23T05:51:22Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T05:52:46Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"37ea25484d4153f521cad123677077200a390be0"}]},{"sha":"37ea25484d4153f521cad123677077200a390be0","html_url":"https://github.com/renrenmimi/DrillLab/commit/37ea25484d4153f521cad123677077200a390be0","commit":{"message":"fix(a11y): restore visible focus on two inputs, refresh the stale baselines\n\nTabbing through 565 focusable controls across 12 pages found two that had\ntheir focus ring removed:\n\n- The search input had `outline: 0` with nothing in its place. The search\n panel has more than one focusable thing in it, so a keyboard user loses\n their place. It gets a 2px accent bottom border, and one pixel of\n padding back so the field does not jump when it takes focus.\n- The sandbox editor replaced its outline with an inset ring in\n --accent-line, which is the dim olive used for hairlines. On the code\n window's near-black background that is not a focus indicator. It uses\n --accent.\n\nBoth keep `outline: 0` deliberately — one sits flush against the panel\nedge and the other against a rounded window, so an outer ring is clipped.\nThe rule is now written down: removing the outline obliges you to supply\na replacement, in the bright accent, and the tab-through regression\nenforces it.\n\nCLAUDE.md's First Load JS baselines still quoted the numbers from before\nthe redesign, and its scan checklist listed three scans when there are\nnow eight. Both corrected, and the archived after-screenshots recaptured\n— they predated the component pass by half an hour.\n\nNote for whoever writes the next focus test: el.focus() from JavaScript\ndoes not match :focus-visible in Chromium, so a probe built on it reports\nevery control that relies on the global rule as failing. Drive it with\nreal Tab presses.","author":{"name":"WEIREN FENG","date":"2026-08-23T04:32:27Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T04:33:32Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"2b654cebf08610eab0f938a49570d23e4d23f321"}]},{"sha":"2b654cebf08610eab0f938a49570d23e4d23f321","html_url":"https://github.com/renrenmimi/DrillLab/commit/2b654cebf08610eab0f938a49570d23e4d23f321","commit":{"message":"fix(ui): reach the contract everywhere and close three regressions\n\n- .lnext-cta restated the primary button's padding, weight, colour and\n hover on top of the class it now carries. Deleted; only its position in\n the panel and its arrow remain. The two other places that render it\n (the plan kit, the lesson plan step) now carry btn btn-primary too.\n- --code-chrome-2 was scoped to .codewin/.term/.editor, but .blank-code\n hosts the same bar. Inside it the variable did not resolve, so\n `color: var(--code-chrome-2)` was invalid, color fell back to inherit,\n and in light mode the language label rendered near-black on the dark\n bar at 1.12:1. Dark mode happened to look fine, so it only showed up in\n one cell of the scan. .blank-code joins the scope and every one of the\n 15 usages gains a fallback value.\n- The search trigger's 44px touch rule lived in layout.css while its\n min-height lived in shell.css, which is imported later and won. It was\n 32px at 430, 620 and 768px.\n- Five filled buttons had var(--accent-ink) as their hover background —\n that token is the bright lime used as *text*, so hovering barely\n changed anything. They use --accent-h.\n- /plans showed seven filled chartreuse actions at once, six of them the\n Start button on six equally weighted cards. Those are outlined now; the\n filled one belongs to the plan you are actually following.\n\nRendered font sizes across 19 pages: 23 distinct values down to 9, two of\nwhich are em-derived from inline code.","author":{"name":"WEIREN FENG","date":"2026-08-23T04:19:58Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T04:21:36Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"795407906d94f5c0cf9015265ea796f0c8244ccb"}]},{"sha":"795407906d94f5c0cf9015265ea796f0c8244ccb","html_url":"https://github.com/renrenmimi/DrillLab/commit/795407906d94f5c0cf9015265ea796f0c8244ccb","commit":{"message":"refactor(assess): one template and one primary action for arena, mock and coding\n\n32 hardcoded font sizes and 63 spacing literals became tokens. Two\nbespoke media queries that re-set the h1 and h2 sizes on small screens\nare gone — the type scale already steps down at 720px to exactly the\nvalues they were restating.\n\n/reference, /guide, /exams/[id] and /mock hand-rolled their page headers;\nthey now use the shared container and header, so they sit on the same\nleft axis as everything else. The same container went onto the arena and\ncoding detail pages. Five \"lesson-meta\" divs carried an inline margin-top\nof 12, 14 or 16px on top of the header's own gap — three different\nhelpings of the same space, now none.\n\nOne filled action per page: start the timer, hand in, record the score,\nopen the workspace, pick a course. A real double is fixed on the arena\nrun page, where opening the hand-in confirmation showed both \"hand in\"\nand \"yes, hand in\" filled at once — the toggle drops to outlined while\nthe confirmation is open, so the filled one is always the one to press.\n\"Mark done\" on a coding page is outlined, except on the four problems\nwith no sandbox, where it is the only action the page has.\n\nThe sandbox mode switch was borrowing .filter-btn from the exercise\nstylesheet; it is its own segmented control now, because it has an\non-state and is not a button.","author":{"name":"WEIREN FENG","date":"2026-08-23T04:19:58Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T04:21:36Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"b8a23768b3534a85e8198e606bffd94d8bdb5414"}]},{"sha":"b8a23768b3534a85e8198e606bffd94d8bdb5414","html_url":"https://github.com/renrenmimi/DrillLab/commit/b8a23768b3534a85e8198e606bffd94d8bdb5414","commit":{"message":"refactor(lesson): normalise the code window chrome and lesson internals\n\n71 hardcoded font sizes across code.css and lesson.css became tokens, as\ndid 131 spacing literals; everything left is 7px or under, which is\noptical rather than layout. Seven copies of the monospace stack became\n--font-mono.\n\nThe copy buttons in the code window and the terminal were two rules with\nslightly different padding; they are one rule with .btn-sm geometry. They\ncannot literally take .btn, because that class uses --ink and\n--rule-strong, which are dark-on-dark inside a code window — the window\nstays dark in both themes. Their colours come from four variables scoped\nto the window instead, which only name values the file already used.\n\nThe answer-language tabs gain a permanent transparent border: the checked\nrule adds one, so switching language used to jog the whole tablist by 2px.\n\nThe end-of-lesson \"next lesson\" link is the page's one filled action and\nnow carries btn btn-primary, so its height, radius and weight come from\nthe contract instead of being restated. \"Mark as finished\" drops to\noutlined — it is bookkeeping, not the next step — and the recap block\nloses its accent wash, which was a second chartreuse panel competing with\nthe CTA on the same screen.","author":{"name":"WEIREN FENG","date":"2026-08-23T04:19:35Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T04:21:36Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"860d2b8613ae95c49f7b8daee6db0838cf3c1203"}]},{"sha":"860d2b8613ae95c49f7b8daee6db0838cf3c1203","html_url":"https://github.com/renrenmimi/DrillLab/commit/860d2b8613ae95c49f7b8daee6db0838cf3c1203","commit":{"message":"refactor(exercise): put the six exercise types on the shared contract\n\n47 hardcoded font sizes and 30 off-scale spacing values became tokens; two\n100px pills became --r-pill. The ordering controls are real buttons now\n(btn btn-sm, squared by a local width rather than by overriding the\nradius, which the geometry regression checks). .opt and .filter-btn keep\ntheir own class — one has to wrap to three lines, the other is rendered\nby five components outside this scope — but they copy the button geometry\nexactly, so they measure the same.\n\nNo exercise keeps a filled submit. A lesson page carries several\nexercises, so one filled button each is exactly the flooding the redesign\nis trying to remove; the outlined submit is still the strongest control\ninside its own card, because everything around it is ghost or plain text.\nThe data-flow autoplay drops to outlined, and its 6x6px step dot gets a\n14x18px hit area.\n\nTwo more colour reductions. .ex-level no longer changes hue by difficulty\n— L1 accent, L3 warn, L4 danger is the rainbow-per-tier the tokens file\nrules out, and the number already says which tier it is. .practice-focus\nwas a full accent wash for a notice rather than a state, so it becomes a\nsunken block with a 2px accent edge.","author":{"name":"WEIREN FENG","date":"2026-08-23T04:19:35Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T04:21:36Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"902fde753bd7110d54d0be4dc48c140e1bf5db9e"}]},{"sha":"902fde753bd7110d54d0be4dc48c140e1bf5db9e","html_url":"https://github.com/renrenmimi/DrillLab/commit/902fde753bd7110d54d0be4dc48c140e1bf5db9e","commit":{"message":"feat(ui): one button system, eight type steps, one pill radius\n\nAn audit found 32 rules that are padded, rounded and clickable spread\nacross nine stylesheets, exactly one of which was .btn — so the same kind\nof action appeared at three heights, four radii and two border colours\ndepending on which page you were on. It also found 28 hardcoded font\nsizes (10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 15.5, 16,\n16.5, 17, 18, 19, 26 …) and two ways of writing a pill.\n\n.btn is now the single button system with four variants that mean 'how\nheavy is this on this screen': outlined by default, one filled primary\nper screen, ghost for secondary actions in a row, and a compact size.\nHeights come from --h-ctl / --h-ctl-sm and rise to 44px on narrow screens\nor a coarse pointer, so 'same importance, same height' holds by\nconstruction rather than by hand.\n\nThe type scale is eight steps, --r-pill replaces 100px/999px, and\nCLAUDE.md carries the contract so the next component has something to\ncheck itself against.","author":{"name":"WEIREN FENG","date":"2026-08-23T03:45:17Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T04:21:36Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"e20b57d9e5b6dd566b530483e68444bb48cfcb92"}]},{"sha":"e20b57d9e5b6dd566b530483e68444bb48cfcb92","html_url":"https://github.com/renrenmimi/DrillLab/commit/e20b57d9e5b6dd566b530483e68444bb48cfcb92","commit":{"message":"docs: correct the top bar description and refresh the after screens\n\nTwo things I left inaccurate in the last commit.\n\nThe README, docs/ui-v2.md and CLAUDE.md still described the top bar as\ncarrying four tools including help. Help was removed from the top bar and\n\"How to use\" moved into the sidebar next to Reference, so the top bar\ncarries three: search, language and theme.\n\nThe archived \"after\" screenshots were captured eight minutes before that\nchange, so they showed the old top bar — including the mobile one, which\nwas the whole point of the fix. Recaptured from the current build.","author":{"name":"WEIREN FENG","date":"2026-08-23T03:38:17Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T03:39:25Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"ecb282d08522608886c21d89d71622edb1de0012"}]},{"sha":"ecb282d08522608886c21d89d71622edb1de0012","html_url":"https://github.com/renrenmimi/DrillLab/commit/ecb282d08522608886c21d89d71622edb1de0012","commit":{"message":"fix(mobile): keep the language switch visible instead of hiding it in a menu\n\nReported: you cannot change language on a phone. The cause was mine —\nraising touch targets to 44px made the 390px top bar overflow by 38px, and\nI solved that by folding language and theme into the help menu below\n620px. The buttons worked, but nobody would find them: this site has a\nChinese and an English version of every sentence, so language is a switch\nyou reach for constantly, not a setting you configure once.\n\nWidth is reclaimed two other ways instead. The brand keeps only its mark\non narrow screens, and \"How to use\" moves into the sidebar next to\nReference — both are things you look up when you need them, so neither\ndeserves a 44px slot in the top bar. The help popover is gone entirely,\nwhich also removes one more component language from the shell.\n\nAt 390px: menu 44 + mark 44 + Continue ~90 + search 44 + language 44 +\ntheme 44, plus five 8px gaps = 350 in a 358px content box.\n\nVerified at 360, 390, 430, 620, 768, 1024 and 1440: the language button\nis visible without opening anything, one tap switches, the choice is\nstored, it survives a reload, the top bar does not overflow, and every\ncontrol is at least 44px on narrow screens. 49/49.","author":{"name":"WEIREN FENG","date":"2026-08-23T02:51:01Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T02:52:11Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76"}]},{"sha":"1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76","html_url":"https://github.com/renrenmimi/DrillLab/commit/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76","commit":{"message":"docs: record the three axes and the two sidebar primitives\n\nAdds the measurements that motivated the change, the rule that content is\nnever centred, the reason the current-row bar cannot be a border-left,\nand the regression each of them needs.","author":{"name":"WEIREN FENG","date":"2026-08-23T02:43:55Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T02:52:11Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"53438553e3dfc1c56d1dacfb5d14e102139b2d9a"}]},{"sha":"53438553e3dfc1c56d1dacfb5d14e102139b2d9a","html_url":"https://github.com/renrenmimi/DrillLab/commit/53438553e3dfc1c56d1dacfb5d14e102139b2d9a","commit":{"message":"fix(nav): label the top bar on /guide\n\n/guide is not in the sidebar — it lives behind the help menu — so\nsectionOf returned undefined and the top bar's location line was empty\nthere, which looks like a rendering gap rather than a deliberate absence.","author":{"name":"WEIREN FENG","date":"2026-08-23T02:43:55Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T02:52:11Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"39b537dbc989abfc7c68b07bbdb1ad2ff851b415"}]},{"sha":"39b537dbc989abfc7c68b07bbdb1ad2ff851b415","html_url":"https://github.com/renrenmimi/DrillLab/commit/39b537dbc989abfc7c68b07bbdb1ad2ff851b415","commit":{"message":"fix(plans): make changing your plan reachable from where you are\n\nTraced from a clean browser: after picking a plan, neither the home page\nnor the sidebar offered any way to change it. The only route was to think\nof clicking \"My plan\" — which reads as \"look at my plan\", not \"pick a\ndifferent one\".\n\nTwo permanent affordances now: \"Change\" beside the plan name in the\nsidebar, and \"View the full plan / Change plan\" under the Continue button\non the home dashboard.\n\nVerified from a fresh profile at 1440 and 390: pick a plan, find the\nentry on the home page, find it in the sidebar, actually switch, and\nconfirm no progress record is touched.","author":{"name":"WEIREN FENG","date":"2026-08-23T02:43:55Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T02:52:11Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"adc4505c3fbd8ffb75299e31286a337dfc5879ab"}]},{"sha":"adc4505c3fbd8ffb75299e31286a337dfc5879ab","html_url":"https://github.com/renrenmimi/DrillLab/commit/adc4505c3fbd8ffb75299e31286a337dfc5879ab","commit":{"message":"refactor(sidebar): two kinds of thing, not eight\n\nOne 252px column carried: a brand lockup, nav rows, group titles, a\nbordered plan card, the Continue button, a mode eyebrow, a global\nprogress bar, and course rows with a lime \"Start here\" pill — then below\nthe divider, three bordered stat cells, a bordered \"next up\" card and two\nbordered sub-mode boxes. Four kinds of border and three text axes.\n\nThere are now two: a row you can go to, and the one primary action.\n.snav-item, .ctx-item, .ctx-course-top and .ctx-sub-btn share one\ntreatment — 34-36px tall, text at 28, and an absolutely positioned 2px\naccent bar when current. The bar cannot be border-left: that shifts the\ntext 2px, so the current row sits off the axis the others share.\n\nRemoved along the way:\n- the ✦ before parallel-track courses. The geometric display stack has\n no glyph for U+2726, so it rendered as an empty box.\n- the lime \"Start here\" pill. The only filled thing in the sidebar is\n Continue.\n- the \"LEARN / REVIEW / ...\" heading above each contextual section. The\n mode is already the highlighted item in the main nav.\n- the global \"3 / 80 lessons\" bar. The plan's own 4 / 130 sits directly\n above it, and each course already shows 3 / 9.\n\nRows also switch from flex-wrap to grid, because a wrapping label pushed\nits count onto the next line.","author":{"name":"WEIREN FENG","date":"2026-08-23T02:43:55Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T02:52:11Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"24f5290b4e2eb369b66865c87a91c93c034df681"}]},{"sha":"24f5290b4e2eb369b66865c87a91c93c034df681","html_url":"https://github.com/renrenmimi/DrillLab/commit/24f5290b4e2eb369b66865c87a91c93c034df681","commit":{"message":"fix(layout): put every page on one axis instead of centring each one\n\nMeasured at 1440px, the h1 landed at 284, 326, 433 or 453 depending on\nthe page, while the top bar sat at 276 and the sidebar never moved. Two\ncauses stacked: .ui-page was centred, and each page had its own\nmax-width. Nothing was individually wrong, which is why it read as\n\"skinned\" rather than \"broken\".\n\nThree numbers now, in tokens.css: --gutter (content axis = sidebar width\nplus this, and the top bar's padding), --rail-pad (a block's edge inside\nthe sidebar) and --rail-inset (a text row's edge inside a block).\n\nContent is left-aligned, never centred; reading width is capped by each\nblock's own max-width. Centring is what let the axis drift with the\nviewport while the chrome stayed put.\n\n13 pages x 3 widths: top bar 284, content 284, sidebar text 28, on all\n39 combinations.","author":{"name":"WEIREN FENG","date":"2026-08-23T02:43:55Z"},"committer":{"name":"renrenmimi","date":"2026-08-23T02:52:11Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"8f46ce6689ce0a574bf2f9b27c1bc6727f572386"}]},{"sha":"8f46ce6689ce0a574bf2f9b27c1bc6727f572386","html_url":"https://github.com/renrenmimi/DrillLab/commit/8f46ce6689ce0a574bf2f9b27c1bc6727f572386","commit":{"message":"UI v2: one navigation, one next action, dark high-contrast\n\nRepositions DrillLab as a focused training command center. The sidebar is\nthe only navigation hierarchy and its positions are identical on every\npage; the top bar carries where you are plus search, language, theme and\nhelp. Continue exists exactly once, and the two pages whose main content\nis that action hand it to the page.\n\nA first visit asks one question with three answers instead of showing six\ndense plan cards; choosing 'prepare for an assessment' is what reveals the\nfour assessments. With a plan active the home page becomes a dashboard:\nplan, stage, progress, time left, and one card giving the next task's\ntype, name, estimate, position and why it is next. The plan page collapses\nevery stage but the current one, and shows three items inside it with the\nrest behind a fold. Lesson pages drop to one content column with a\none-line position strip, and the four mode pages share one template.\n\nDark, high-contrast, with chartreuse reserved for current location,\nprogress, the single primary action and small highlights.\n\nNo lesson content, plan definition, progress semantic, URL, bilingual pair,\nsearch coverage or assessment rule changed.\n\n68/68 guided-plan flow assertions; 0 of 84 contrast combinations below AA;\n0 of 105 overflow combinations; class coverage difference 0. First Load JS:\nlesson 144 kB, home 178, /plans 141, plan detail 175 — all within budget.","author":{"name":"renrenmimi","date":"2026-08-22T19:34:56Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"e911e2960dbe1cb549cd0a6eab1beec9b06457e5"}]},{"sha":"e911e2960dbe1cb549cd0a6eab1beec9b06457e5","html_url":"https://github.com/renrenmimi/DrillLab/commit/e911e2960dbe1cb549cd0a6eab1beec9b06457e5","commit":{"message":"a11y(plans): give the collapsed plan chip an explicit name\n\nAt 390px the chip hides the plan name and keeps only the counter, so the\nlink's computed accessible name was \"0/130\" — a screen reader user could\nnot tell which plan it was, or that it was a plan at all. The empty-state\nchip collapsed to no name whatsoever.\n\nBoth now carry an explicit localized aria-label (\"React Assessment plan,\n0 of 130 completed\"). The visuals are unchanged.","author":{"name":"WEIREN FENG","date":"2026-08-22T17:29:27Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"cb77d966c78c39870fb30dd78e68374ad8246c08"}]},{"sha":"cb77d966c78c39870fb30dd78e68374ad8246c08","html_url":"https://github.com/renrenmimi/DrillLab/commit/cb77d966c78c39870fb30dd78e68374ad8246c08","commit":{"message":"fix(plans): keep one continuation action when a plan is active\n\nWith a plan active the sidebar stacked two filled continuation cards on\ntop of each other: the guided-plan \"Next\" card and the ordinary Learn\n\"Resume\" card, almost always pointing at the same lesson. Two entrances\nto one destination is no entrance.\n\nThe plan panel is lazily loaded and the context sidebar renders on every\nroute, so the sidebar cannot compute the plan's next item itself without\ndragging the manifest into the first load. lib/plan-signal.tsx inverts\nit: the panel registers its next href, the Learn sidebar reads it.\n\n no active plan the Resume card, unchanged\n same destination only the plan card\n different destination a quiet secondary text link, labelled as being\n outside the plan\n plan complete the Resume card again, since the plan has no\n next step left\n\nDesktop rail and mobile drawer render the same tree, so both follow.","author":{"name":"WEIREN FENG","date":"2026-08-22T17:29:27Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"78d1f5490f2ff02fa1bab638aba0396508c95ea1"}]},{"sha":"78d1f5490f2ff02fa1bab638aba0396508c95ea1","html_url":"https://github.com/renrenmimi/DrillLab/commit/78d1f5490f2ff02fa1bab638aba0396508c95ea1","commit":{"message":"perf(plans): drive global plan controls from a generated lite manifest\n\nGlobally mounted plan controls (top-bar chip, sidebar panel, in-page\nposition strip, end-of-lesson step) used to reach content/plans, which\npulls content/nav (134 KB) plus content/nav-exercises (39 KB) to expand\na stage query into real items. That dependency in a component the root\nlayout mounts made webpack split nav out of the shared-by-all chunk, so\nevery route downloaded it again. Measured on the lesson route: First\nLoad JS 142 -> 195 kB (+37%).\n\nnpm run gen:nav now also emits content/plan-manifest.ts: one deduped\nitem table (350 rows: kind, id, examId, label, href, exercise kind,\nminutes) plus per-plan stage index arrays. lib/plan-lite.ts expands it\nback into objects; lib/plan-progress.ts is generic over the plan shape\nso both the manifest plans and the fully resolved ones fit.\n\nFull resolution, estimated time, per-stage rationale and the integrity\nassertions stay server-side and build-time only. /plans/[planId] keeps\nreading content/plans directly, since the roadmap needs all three.\nSearch pulls its plan entries lazily, alongside the deep index.\n\nLesson route First Load JS: 195 -> 144 kB (main baseline: 142 kB).\nThe manifest chunk is now eager only on /, /plans and /plans/[planId].","author":{"name":"WEIREN FENG","date":"2026-08-22T17:29:15Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"522d561139cd9ab3babc87440b4b729f44b5d418"}]},{"sha":"522d561139cd9ab3babc87440b4b729f44b5d418","html_url":"https://github.com/renrenmimi/DrillLab/commit/522d561139cd9ab3babc87440b4b729f44b5d418","commit":{"message":"Document the guided plans and refresh the screenshots\n\ndocs/guided-plans.md records what the four modes left unsolved, the mapping\nfor each of the six plans and the judgements behind them, the derivation\ntable for progress, why every plan component is lazily loaded and what\nloading them eagerly cost, the assertions that fail the build, and the\nmeasured results — including the two places where bytes did not come down\nand what would fix them.\n\nCLAUDE.md gains the rules a later change has to respect. README leads with\nthe two ways in.\n\ndocs/plans holds seventeen before/after pairs against the same seeded\nprogress on both branches, at 1440, 1024 and 390, light and dark.","author":{"name":"WEIREN FENG","date":"2026-08-22T16:31:00Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"2aa28c1c302d45630f26562f6b7112535bf31f43"}]},{"sha":"2aa28c1c302d45630f26562f6b7112535bf31f43","html_url":"https://github.com/renrenmimi/DrillLab/commit/2aa28c1c302d45630f26562f6b7112535bf31f43","commit":{"message":"Bump comment contrast on highlighted code lines\n\n--tk-com is 5.24:1 against the plain code background but a highlighted line\nadds rgba(255,255,255,0.055), which drops it to 4.45:1. Line numbers were\nalready bumped for exactly this reason; comments were missed, and a comment\non a highlighted line is usually the sentence pointing at what to look at.\nFound by the contrast scan now that it walks 26 pages.","author":{"name":"WEIREN FENG","date":"2026-08-22T16:30:48Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"017f77a3b730c7843ad146866432f61a6811cefb"}]},{"sha":"017f77a3b730c7843ad146866432f61a6811cefb","html_url":"https://github.com/renrenmimi/DrillLab/commit/017f77a3b730c7843ad146866432f61a6811cefb","commit":{"message":"Make lessons, questions, practice, coding and the arena plan-aware\n\nEach of these pages now says where it sits in the active plan and what\ncomes next, and renders nothing at all when no plan is active — free\nbrowsing should not be interrupted by a banner.\n\nA lesson shows its stage and item position under the header, and the end-of\n-lesson panel's second step becomes \"continue your plan\" with the ordinary\nnext lesson demoted to a line of small text when the two differ. That step\nis split into a light shell and a lazily loaded plan variant, because the\ndefault wording is above-the-fold content that has to be server-rendered\nwhile the plan version needs content/nav. Both render and CSS hides the\nfallback once the plan version arrives; display:none elements do not\nincrement the CSS counter, so the numbering stays correct.\n\nA question page shows its plan position and offers the next step at the\nfoot — after the answer, never before, and it never navigates on its own.\nList pages show a strip only when the plan's next item belongs to that\nmode. The arena brief shows position before you start; the run page shows\nposition only, with no way out, because that rule predates plans and still\nholds; the review page offers the next step after the attempt is recorded,\noutside the checkoff component so it cannot reach hints or solutions.\n\nSearch indexes the six plans and all 45 stages, the latter linking to a\nstage anchor. Existing coverage is unchanged.","author":{"name":"WEIREN FENG","date":"2026-08-22T16:30:48Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"ae3ed59cdf29d58cdef2e376da6a73bef9b63e8b"}]},{"sha":"ae3ed59cdf29d58cdef2e376da6a73bef9b63e8b","html_url":"https://github.com/renrenmimi/DrillLab/commit/ae3ed59cdf29d58cdef2e376da6a73bef9b63e8b","commit":{"message":"Lead the home page with the outcome question\n\nThe first screen used to ask what kind of work you wanted to do, and the\nfour modes answered it. That was right, but only Learn is linear: someone\nwho says \"I am preparing for the React assessment\" and opens Review finds a\nbank of 105 cards and a row of filters. What they wanted was a route.\n\nSo the first question is now what you are preparing for, and six plan cards\nare the answers. Someone already following a plan gets that plan instead:\nits name, overall progress, current stage, the exact next item, one primary\nbutton, and links to the full plan and to changing plans. Someone who has\nturned guiding off gets the previous continue card back, plus one quiet line\npointing at /plans.\n\nThe four modes are all still there, under \"Explore freely\". The old\n\"suggested order\" section is gone: a plan says the same thing far more\nconcretely — read these 21 lessons, then these 36 questions, then these 54\nexercises — so a Learn-to-Assess arrow strip was saying it twice, vaguely.\n\ncontinue.tsx keeps no plan knowledge on purpose. It ships in the shell on\nevery route, and resolving a plan means loading content/nav; when a plan is\nactive the header button is replaced by a lazily loaded plan-aware one.","author":{"name":"WEIREN FENG","date":"2026-08-22T16:30:48Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"e37d490baeae8be4443c27c4c6eb2b168425e81a"}]},{"sha":"e37d490baeae8be4443c27c4c6eb2b168425e81a","html_url":"https://github.com/renrenmimi/DrillLab/commit/e37d490baeae8be4443c27c4c6eb2b168425e81a","commit":{"message":"Add /plans and the roadmap that is the plan's signature view\n\n/plans asks what you are preparing for and shows six compact cards, each\nwith its outcome, who it is for, the stages it is made of, its scope and\nits progress. /plans/[planId] is the roadmap.\n\nThe roadmap is a vertical rail of numbered stage markers. State is carried\nby shape and words before colour: a finished stage is a filled marker with\na tick and a \"stage complete\" chip, the current one is an accent marker\nwith a halo and \"you are here\" and is the only one expanded, and later\nstages are outlined and collapsed. Nothing is ever locked — every stage is\na native details element, so keyboard and screen reader behaviour comes\nfree and any stage opens on a click.\n\nItems are rows when they have names worth reading — lessons, coding\nproblems, arena challenges, mocks — and squares when a stage holds dozens\nof exercises or questions. Squares show a number when unfinished and a tick\nwhen done, with the real title on aria-label and title, and the stage\nalways renders its next item as a titled row above the grid, so even a\n54-square stage has one obvious target.\n\nPlan components are loaded lazily and gated on whether a plan is active,\nbecause they render nothing at all otherwise. plan-slots.tsx imports no\ncontent module: it reads one field from progress and only then pulls\nplan-kit in. Loading them eagerly cost /drill/[id] 152 kB and the lesson\npage 155 kB of client bytes that users not following a plan would never\nuse. The static \"Plans\" entry in the header stays server-rendered so the\nfour modes do not shift after hydration.","author":{"name":"WEIREN FENG","date":"2026-08-22T16:30:24Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"ada860994382405835d822da7ecdcfd3bdd1cdc8"}]},{"sha":"ada860994382405835d822da7ecdcfd3bdd1cdc8","html_url":"https://github.com/renrenmimi/DrillLab/commit/ada860994382405835d822da7ecdcfd3bdd1cdc8","commit":{"message":"Derive plan progress from the completion records that already exist\n\nA plan stores an id and two timestamps. Nothing else. Every square in a\nplan is a view of progress the user already has: lessons read lessons,\nexercises read exercises (or rebuilds for the from-scratch kind), questions\nread drills, coding reads coding, arena reads a passed attempt, mocks read\nmocks.\n\nThis has to be derivation rather than a second ledger. Someone may rate\nthirty questions on their own and pick a plan a week later; with its own\nledger the plan would show 0 / 105 and those thirty would be wasted. In the\nother direction, a lesson finished inside a plan has to stay ticked in the\ncourse. One set of facts, two views.\n\nTwo judgements worth stating. A question counts as reviewed after a single\nrating, so a stage clears without forcing every card to \"confident\" — the\nconfident count is shown separately. An arena stage clears on a passed\nattempt, and never locks anything: later stages stay open.\n\nThe next item is one deterministic function: first incomplete stage, then\nthe first incomplete item in that stage's own order, except for question\nstages where the order is unseen, missed, shaky, confident. Every place\nthat says \"next\" reads that one value, so the header button, the sidebar\npanel, the plan header, the roadmap highlight and the end of a lesson can\nnever disagree.\n\nlocalStorage stays on drilllab-progress-v1. Legacy data without the three\nnew fields simply reads as \"not following a plan\".","author":{"name":"WEIREN FENG","date":"2026-08-22T16:30:24Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"09b11bdaf58e8494af9015c85796d1e1444e5dd4"}]},{"sha":"09b11bdaf58e8494af9015c85796d1e1444e5dd4","html_url":"https://github.com/renrenmimi/DrillLab/commit/09b11bdaf58e8494af9015c85796d1e1444e5dd4","commit":{"message":"Add a plan registry that only references existing content\n\nSix guided plans, each an ordered sequence of stages that crosses the four\nproduct modes: prerequisites, lessons, questions, exercises, coding\nproblems, rebuilds, mock. A stage is a query rather than a copy —\n{from:\"lessons\",examId:\"react\"}, {from:\"drills\",tracks:[\"react\"]},\n{from:\"coding\",ids:[...]} — so titles, estimates and problem text are read\nfrom content/nav at render time and exist in exactly one place.\n\nThe only prose a plan owns is why each stage belongs where it does. That\nsentence does not exist in content/exams, because it describes the role of\na step inside a route, and a lesson only describes itself.\n\nMappings were taken from the real content, id by id. Where a stage has no\nhonest material the stage is omitted rather than padded: the Spring Boot\nplan has four stages because the question bank has no Java track and there\nis no Java mock. Prerequisite stages come from exam.prerequisites, which is\na relationship the content already declares.\n\nIntegrity assertions live in content/plans-assert.ts and are imported by\nboth prerendered plan routes, so next build reaches them: a reference to a\nlesson, exercise, coding problem, arena challenge, mock, drill track,\nmodule or course that does not exist fails the build, as does a stage that\nresolves to zero items, a stage missing its why, a duplicate item inside\none plan, and a duplicate plan or stage id. They deliberately do not run\nat module scope in content/plans.ts — the header carries a plan chip on\nevery page, and that would expand a thousand items in the browser on every\nroute.\n\nThe 148 exercise ids the plans need go into a separate generated file,\ncontent/nav-exercises.ts, rather than into content/nav.ts. Folding them in\ngrew nav from 133 to 160 kB, which changed webpack's chunking and cost the\nlesson page 145 kB it had no use for. nav.ts is now byte-identical to main.","author":{"name":"WEIREN FENG","date":"2026-08-22T16:30:01Z"},"committer":{"name":"renrenmimi","date":"2026-08-22T19:38:33Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"54c4df0b86dd9adafda177907aee222de00c17ae"}]},{"sha":"54c4df0b86dd9adafda177907aee222de00c17ae","html_url":"https://github.com/renrenmimi/DrillLab/commit/54c4df0b86dd9adafda177907aee222de00c17ae","commit":{"message":"Intent-first navigation: four product modes with contextual sidebars (#5)\n\nThe header now asks what kind of work you want to do — Learn, Review,\nPractice, Assess — and the sidebar carries only the active mode's\nstructure. Adds a Continue action fed by a new per-mode recent record in\nlocalStorage (existing progress preserved, key unchanged), turns the home\npage into an action dashboard, and gives lesson pages their position,\ncompletion state and a single next-action panel.\n\nVerified: typecheck, lint, 252 pages prerendered, audit:code, class\ncoverage 0 missing, WCAG AA 0 failures across 24 pages x 3 viewports x\n2 themes x 2 languages, 0 overflow at 390px, 112 flow/keyboard/legacy-data\nassertions passing. Rationale and measurements in docs/intent-nav.md.","author":{"name":"renrenmimi","date":"2026-08-22T06:15:17Z"},"committer":{"name":"GitHub","date":"2026-08-22T06:15:17Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"970a4212fe009039623ca8879566c5eb95fb3ea4"}]},{"sha":"970a4212fe009039623ca8879566c5eb95fb3ea4","html_url":"https://github.com/renrenmimi/DrillLab/commit/970a4212fe009039623ca8879566c5eb95fb3ea4","commit":{"message":"Translate the exercises, the teaching code and the interface; default to English (#4)\n\n* Add English for the two useState snippets in react-part1\n\nThe plain-variable-versus-useState comparison and the functional-update\ncomparison now carry codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part1: English for the resolver-arguments lesson\n\nAdds titleEn/promptEn/explainEn to the two recognition exercises and the\nordering exercise (5 labelEn), plus codeEn/filenameEn/explanationEn for the\nseed-data and context-shape snippets and filenameEn for src/index.js.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part2: English for the effect-loop Debug Lab and its mistake snippets\n\nAdd titleEn, promptEn, classify and locate labels, questionEn,\nrootCauseEn, verifyEn and the two code-window titles, plus codeEn for\nthe two wrong-example snippets in the useEffect lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part2: English for the derived-values lesson code windows\n\nAdd codeEn for the isFormInvalid excerpt, plus filenameEn and\nexplanationEn. The \"where the four values belong\" block is a prose\ntable, so it keeps only an English window title.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part2: English for the derived-data and lifting recognition exercises\n\nAdd titleEn, promptEn, four option labelEn each, and explainEn for\nr-derived-or-state and r-where-state-lives.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the state lesson exercises in react-part1\n\nThe ordering exercise gets English for its title, prompt, items and\nexplanation. The code-completion exercise gets an English starter, three\nrequirements, six check labels and four hints, all the same length as the\nChinese arrays. The two setState mistakes now carry codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part1: English for the nullability lesson\n\nAdds titleEn/promptEn/explainEn to the two recognition exercises (4 labelEn),\nhintEn/whyEn to all three blanks of the fallback fill-blank, and codeEn plus\nfilenameEn/explanationEn for the schema, createOrder and getProductPrice snippets.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part2: English for the derived-data code-completion exercise\n\nAdd titleEn, promptEn, starterEn (18 lines, same as the Chinese),\nrequirementsEn (5 for 5), seven check labelEn, hintsEn (4 for 4) and the\nreference-answer window title.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the three read-the-task exercises in react-part3\n\nr-q1-forbidden, r-q1-hidden-req and r-q1-workflow now carry English for\ntheir titles, prompts, every option or item label, and their explanations.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the Node and npm install snippets\n\nAdd codeEn and explanationEn for the node -v check and the npm install\noutput in the \"how a project runs\" lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part1: English for the why-Federation lesson\n\nAdds titleEn/promptEn/explainEn and 8 labelEn to the two recognition exercises,\ncodeEn for the @external entity snippet, and filenameEn/explanationEn for the\nschema, correlationId and three-services code windows.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the Task 1 teaching snippets in react-part3\n\nThe baseline terminal transcript, the two NoteForm/NoteManager excerpts and\nthe two derivation steps now carry codeEn and English window titles. Two\nlines of the terminal transcript keep their Chinese side notes: the\nannotation sits after an arrow rather than inside a comment, so the\nstructure check cannot accept a translated version there.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the dependency-placement and lockfile exercises\n\nAdd titleEn, promptEn, explainEn and the three Chinese option labels for\nf-dep-place and f-lockfile-rule.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the npm scripts snippet and the two tree titles\n\nAdd codeEn for the \"what each script really runs\" block. The two\ndirectory trees annotate their paths with bare Chinese text rather than\ncomments, so translating them would break the structural check in\naudit:code; they get only an English window title for now.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the Task 1 exercises in react-part3\n\nr-t1-blank gets English hints and reasons for both blanks. r-t1-write gets\nan English starter, five requirements, six check labels, four hints and an\nEnglish reference answer. Both English arrays match the Chinese ones in\nlength.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part1: English for the subgraph-startup lesson\n\nAdds titleEn/promptEn/explainEn and 8 labelEn to the two recognition exercises,\ncodeEn for the curl recipe and the 42-line verify-schema script, and\nfilenameEn/explanationEn for the four code windows in this lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the package.json fill-blank exercise\n\nAdd titleEn, promptEn and hintEn/whyEn for all three blanks of\nf-pkg-blanks. The template is a real package.json, so no field name or\npath was touched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the three Task 1 wrong examples in react-part3\n\nThe duplicate list in NoteForm, the missing spread and the duplicated id\nsnippets now carry codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the npm-scripts lesson exercises\n\nAdd titleEn, promptEn and explainEn for f-how-to-test and f-debug-order,\nlabelEn for the three ordering items, and an English window title and\nexplanation for the measured npm test output.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the project-orientation exercises\n\nAdd titleEn, promptEn, explainEn and the four option labels for\nf-which-file, plus titleEn, promptEn and explainEn for f-distractor.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part1: English for the entity and @key lesson\n\nAdds titleEn/promptEn/explainEn and 8 labelEn to the two recognition exercises,\nhintEn/whyEn to all three blanks of the entity fill-blank, and codeEn plus\nfilenameEn/explanationEn for the @key schema and resolver-test snippets.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the array-and-object snippets in the JS lesson\n\nAdd codeEn for the mutate-vs-copy demo, the three NoteManager\noperations, the orderDataSource excerpt and the priced-items answer,\nplus the matching window titles and explanations.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the Task 2 exercises in react-part3\n\nThe delete-by-id excerpt gets codeEn and an English note. r-t2-blank gets\nEnglish hints and reasons for all three blanks. r-t2-write gets an English\nstarter, five requirements, six check labels and four hints, each array the\nsame length as its Chinese twin.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the Q1 data-operations fill-blank exercise\n\nAdd titleEn, promptEn and hintEn/whyEn for all four blanks of\nf-crud-blanks, plus codeEn and explanationEn for the destructuring\nsnippet above it.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the delete-by-title Debug Lab in react-part3\n\nr-debug-filter-title now carries English for its title, prompt, both sets\nof options, the locate question, the root cause, the verify command and\nboth code window titles. errorOutput stays as it is, as agreed.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the mutation Debug Lab and its mistake snippets\n\nAdd titleEn, promptEn, classify and locate labels, questionEn,\nrootCauseEn and the two window titles for f-debug-push, plus codeEn for\nthe two wrong-example snippets. errorOutput is left as recorded.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the Task 3 fill-blank in react-part3\n\nr-t3-blank now carries an English title and prompt, plus an English hint\nand reason for each of its four blanks.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part1: English for the N+1 and DataLoader lesson\n\nAdds titleEn/promptEn/explainEn and labelEn to the two recognition exercises,\nhintEn/whyEn to both blanks of the loader fill-blank, and the full English side\nof the Debug Lab (classify, locate, rootCause, verify, broken and fixed code).\nAlso adds codeEn for the five N+1 and per-request-loader snippets.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the async lesson code windows\n\nAdd codeEn for the async data-source shape and the function-vs-Promise\ndemo, plus English window titles and explanations. The npm run q2\noutput block annotates its lines with bare Chinese text, so it keeps\nonly the English title and explanation.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the full Task 3 exercise in react-part3\n\nr-t3-write now carries an English title, prompt and starter, five\nrequirements, nine check labels, four hints and an English window title on\nthe reference answer. Every English array matches the Chinese one in length.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the import and export snippets\n\nAdd codeEn for the default-export and named-export examples, plus an\nEnglish window title for the Q2 type definitions.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part1: English for the last two code-window titles\n\nAdds filenameEn and explanationEn to the createShippingInfoLoader window.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the all-vs-allSettled and task-shape exercises\n\nAdd titleEn, promptEn, explainEn for f-async-all and f-async-fn, plus\nthe four option labels of f-async-fn.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the regenerated-id Debug Lab in react-part3\n\nr-debug-new-id now carries English for its title, prompt, both sets of\noptions, the locate question, the root cause, the verify command and both\ncode window titles. The two Task 3 wrong examples get codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the DataLoader batch fill-blank exercise\n\nAdd titleEn, promptEn and hintEn/whyEn for both blanks of\nf-async-blanks.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the delete-then-add and missing-await snippets in react-part3\n\nBoth teaching snippets now carry codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part2: English for the read-the-exam-text exercises\n\nAdds titleEn/promptEn/explainEn to the two recognition exercises and the\nTask 1 ordering exercise, with 14 labelEn across the three.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the ESM Debug Lab exercise\n\nAdd titleEn, promptEn, classify and locate labels, questionEn,\nrootCauseEn, verifyEn and the two window titles for f-debug-esm, plus\ncodeEn for the import type example.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the two test-reading exercises in react-part3\n\nr-test-blindspot and r-test-await now carry English titles, prompts, option\nlabels where the label is not pure code, and explanations.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the TypeScript lesson code windows\n\nAdd codeEn for the props interface, the non-null assertion and the three\nuseState calls, plus English window titles. The tsc output block ends in\na bare Chinese summary line, so it keeps only the English title.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the NoteTable props fill-blank exercise\n\nAdd titleEn, promptEn and hintEn/whyEn for all three blanks of\nf-ts-props.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: finish the English for the NoteTable props fill-blank\n\nAdd the titleEn, promptEn and the remaining hintEn/whyEn that the\nprevious commit left out.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the \"whose problem is this\" exercise\n\nAdd titleEn, promptEn, the four option labels and explainEn for\nf-whose-fault, plus an English title and explanation for the tsconfig\nwindow above it.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the generics fill-blank exercise\n\nAdd titleEn, promptEn, filenameEn, templateEn (6 lines, same as the\nChinese) and hintEn/whyEn for all three blanks of f-generic-blanks.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the write-your-own-test exercise in react-part3\n\nr-write-own-test now carries an English title, prompt and starter, five\nrequirements, six check labels, four hints and an English reference answer.\nThe Chinese test data stays untranslated in both versions: the note titles\nand bodies are fixture values that the checks and the answer depend on.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part2: English for the User.orders lesson\n\nAdds hintEn/whyEn to all four blanks of the fill-blank, and the full English\nside of the code-completion exercise: starterEn (10 lines, matching),\nrequirementsEn (7), hintsEn (4), 9 check labelEn, and the solution codeEn.\nAlso adds codeEn and filenameEn for the five derivation and verification windows.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: drop templateEn and filenameEn from the fill-blank exercise\n\nFillBlankExercise has no templateEn or filenameEn field, so tsc rejected\nthem. The exercise keeps titleEn, promptEn and the per-blank English.\n\nCo-Authored-By: Claude Opus 5 \n\n* Give the two text diagrams in react-part3 English window titles\n\nThe README excerpt and the three-versions comparison are text diagrams, so\ntheir bodies stay Chinese: every line is prose, and the structure check\ncompares those lines whole. The window titles do get English.\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English titles for the four remaining code windows\n\nAdd filenameEn where the window title was Chinese: the Promise.all\nexample, the Q2 try/catch excerpt, the missing-.js error and the\ntaskRunner generic.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the test-reading exercises in lesson 1\n\nAdd titleEn, promptEn, explainEn for cb-tests-recognition and\ntitleEn, promptEn plus hintEn/whyEn for the three blanks of\ncb-testid-fill. Also an English title for the test-file window.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the toggle-and-bulk fill-blank in react-part5\n\nr-var-todo-blank now carries an English title and prompt, plus an English\nhint and reason for each of its four blanks.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English titles for the code windows\n\nEvery code window whose title carried Chinese now has a filenameEn, and\nthe mis-placed-testid mistake snippet gets a codeEn. Pure paths are left\nalone.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the derived-list exercise in react-part5\n\nr-var-todo-write now carries an English title, prompt and starter, five\nrequirements, nine check labels, four hints and an English window title on\nthe reference answer. The two wrong examples of the lesson get codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the Context fill-blank exercise\n\nAdd titleEn, promptEn and hintEn/whyEn for all four blanks of\ncb-context-fill.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the TodoList teaching snippets in react-part5\n\nThe two map updates, the not-really-immutable version, the derived values\nand the two bulk actions now carry codeEn and English window titles.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the write-the-whole-Context exercise\n\nAdd titleEn, promptEn, starterEn (11 lines, same as the Chinese),\nrequirementsEn (5 for 5), six check labelEn and hintsEn (4 for 4) for\ncb-context-write.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part2: English for the Order.shippingInfo lesson\n\nAdds hintEn/whyEn to the three blanks, titleEn/promptEn/explainEn plus 4 labelEn\nto the why-the-loader recognition exercise, and codeEn for the loader-vs-data-source,\ntest, reference-answer, batching-evidence and wrong-example windows.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part1: reword the ASCII-diagram note in English\n\nThe note explains why that code window has no codeEn. Everything else added\nin this pass is English, so the note is too.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the Context lesson wrong-example snippets\n\nAdd codeEn and filenameEn for the misplaced Provider, the push append\nand the guard-turned-fallback snippets.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the timer teaching snippets in react-part5\n\nThe nine core lines, the stale-closure comparison and the measured failure\noutput now carry codeEn. Two lines of that output keep their Chinese notes:\nthe note sits inside the test name rather than in a comment, so the\nstructure check cannot accept a translated version.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: keep the JSX comment byte-identical in the English snippet\n\naudit:code compares the code structure and only ignores // and #\ncomments, so a {/* ... */} JSX comment has to match exactly. The rest of\nthe misplaced-Provider snippet is English; that one marker stays as it\nis so the structural check passes.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the booking-flow ordering exercise\n\nAdd titleEn, promptEn, explainEn and labelEn for the three Chinese step\nlabels of cb-flow-order.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the timer fill-blank in react-part5\n\nr-var-timer-blank now carries an English title and prompt plus an English\nhint and reason for all three blanks. The verify command and the test file\nget English window titles.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the App state-machine fill-blank exercise\n\nAdd titleEn, promptEn and hintEn/whyEn for all five blanks of\ncb-app-fill.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the write-the-timer exercise in react-part5\n\nr-var-timer-write now carries an English title, prompt and starter, six\nrequirements, eight check labels, four hints and an English window title on\nthe reference answer.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part2: English for the two Query resolvers lesson\n\nAdds hintEn/whyEn to the four blanks, and the English side of the second\ncode-completion exercise: starterEn (15 lines, matching), requirementsEn (7),\nhintsEn (4), 9 check labelEn. Also codeEn for the ErrorCodes table and\nQuery.order reference answer.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the key-choice and CabCard exercises\n\nAdd titleEn, promptEn, explainEn for cb-keys-recognition, and titleEn,\npromptEn, starterEn (13 lines), requirementsEn (5 for 5), five check\nlabelEn and hintsEn (4 for 4) for cb-card-write. Also codeEn for the\nfour-boolean mistake snippet.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the runaway-timer Debug Lab in react-part5\n\nr-var-timer-debug now carries English for its title, prompt, both sets of\noptions, the locate question, the root cause, the verify command, and the\nfixed snippet.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the CabOptions mistake snippets\n\nAdd codeEn and filenameEn for the unnecessary sort and the missing arrow\nfunction snippets.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the three timer wrong examples in react-part5\n\nThe stale closure, the reset that never stops the clock and the interval id\nkept in a state now carry codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the fetch state and res.ok snippets in react-part5\n\nBoth snippets now carry codeEn, English window titles and an English note.\nTwo lines of the three-state snippet keep their Chinese: the text sits\ndirectly in the JSX rather than in a comment or a string, so the structure\ncheck compares those lines whole.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the Loading fill-blank exercise\n\nAdd titleEn, promptEn and hintEn/whyEn for all four blanks of\ncb-loading-fill, plus codeEn for the fake-timer example above it.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the race and abort snippets in react-part5\n\nThe ignore-flag solution and the AbortController pairing now carry codeEn.\nThe race timeline is a text diagram, so only its window title gets English.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the timer Debug Lab exercise\n\nAdd titleEn, promptEn, classify and locate labels, questionEn,\nrootCauseEn and codeEn for the broken Loading component.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the fetching fill-blank in react-part5\n\nr-var-fetch-blank now carries an English title and prompt plus an English\nhint and reason for all four blanks. The verify command and the test file\nget English window titles.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the setInterval mistake and the slice walkthrough\n\nAdd codeEn and filenameEn for the setInterval-without-cleanup snippet\nand the four-record slice walkthrough. The car names are fixture data\nand stay as they are.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the four-versions reverse comparison\n\nAdd codeEn and filenameEn for the safe-vs-dangerous reverse snippet in\nthe RideHistory lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the write-the-fetching-effect exercise in react-part5\n\nr-var-fetch-write now carries an English title, prompt and starter, six\nrequirements, eleven check labels, four hints and an English window title\non the reference answer.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the stale-response Debug Lab in react-part5\n\nr-var-fetch-debug now carries English for its title, prompt, both sets of\noptions, the locate question, the root cause, the verify command, and the\nfixed snippet.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the slice exercise and the RideHistory rewrite\n\nAdd titleEn, promptEn, explainEn for cb-slice-recognition, and titleEn,\npromptEn, starterEn (12 lines), requirementsEn (6 for 6), eight check\nlabelEn and hintsEn (4 for 4) for cb-history-write.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the three fetch wrong examples in react-part5\n\nThe async effect, the missing dependency array and the loading flag turned\noff only on success now carry codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the RideHistory mistake snippets\n\nAdd codeEn and filenameEn for the reverse-on-state and\nmissing-optional-chain snippets.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the comment tree snippets in react-part5\n\nThe Comment type, the recursive JSX and the recursive count now carry\ncodeEn. The JSX block comment and the JSDoc line keep their Chinese: the\nstructure check does not recognise /* */ as a comment, so a translation\nthere would look like changed code.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part2: English for the three planted bugs, and the last code windows\n\nCompletes both Debug Labs (classify, locate, rootCause, verify, broken and\nfixed code) and adds codeEn for the remaining wrong-example and fix windows.\n\nAlso adds STARTER_RESOLVERS_EN (85 lines) and DATA_SOURCE_TABLE_EN (19 lines).\naudit:code only pairs inline template literals, so it cannot see a codeEn passed\nby constant; both pairs were checked separately for line count and structure.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the wrong-extension Debug Lab exercise\n\nAdd titleEn, promptEn, classify and locate labels, questionEn,\nrootCauseEn and verifyEn for cb-ext-debug, plus codeEn for the\nuseMemo/useCallback comparison snippet.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the could-be-better recognition exercise\n\nAdd titleEn, promptEn, five option labelEn and explainEn for\ncb-better-recognition, plus codeEn for the global-loader mistake\nsnippet.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the recursion exercises and snippets in react-part5\n\nThe two wrong ways to add a reply and the reference addReply now carry\ncodeEn, and r-var-tree-blank gets an English title, prompt, and an English\nhint and reason for each of its four blanks.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3: English for the OrderController reference answer\n\nAdds CONTROLLER_SOLUTION_EN (95 lines, verified line-for-line) and wires it\ninto both code windows that show the solution, with filenameEn on each.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the from-scratch exercise\n\nAdd titleEn, promptEn, requirementsEn (10 for 10), roleEn on the nine\nfile entries that carried Chinese, expectEn on all four commands, and\nhintsEn with exactly four entries.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3: English for the read-the-Java-project lesson\n\nAdds titleEn/promptEn/explainEn and 8 labelEn to the two recognition exercises,\nplus codeEn and filenameEn for the CorrelationIdFilter window.\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking: English for the write-it-all-at-once mistake snippet\n\nAdd codeEn and filenameEn for the four-red-tests output block.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the immutable-tree exercise in react-part5\n\nr-var-tree-write now carries an English title, prompt and starter, five\nrequirements, eight check labels and four hints.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the mutated-tree Debug Lab in react-part5\n\nr-var-tree-debug now carries English for its title, prompt, both sets of\noptions, the locate question, the root cause, the verify command, and both\ncode snippets. The author value stays as it is: it is fixture data, not\ncopy.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the three tree wrong examples in react-part5\n\nStoring depth in the data, lifting the collapsed state, and reaching for a\ndeep copy now carry codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the Context and theme snippets in react-part5\n\nThe prop-drilling comparison, the custom hook with its guard, toggleTheme,\nthe closure version, the unmemoized Provider value, the memoized version\nand the measured failure output all carry codeEn now. The guard's error\nmessage is translated, since it is written for the reader. The two JSX\nblock comments and one Chinese test name stay as they are: the structure\ncheck compares those lines whole.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the ThemeContext fill-blank in react-part5\n\nr-var-theme-blank now carries an English title and prompt plus an English\nhint and reason for each of its four blanks.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3: English titles for the Java code windows\n\nAdds filenameEn to 18 code windows whose filename is a title rather than a path,\nexplanationEn for the OrderService extract, and codeEn for the mvn baseline,\naudit output, manual curl self-check and the Debug Lab fix.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the write-the-Provider exercise in react-part5\n\nr-var-theme-write now carries an English title, prompt and starter, six\nrequirements, twelve check labels, four hints and an English window title\non the reference answer.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the missing-Provider Debug Lab in react-part5\n\nr-var-theme-debug now carries English for its prompt, both sets of options,\nthe locate question, the root cause, the verify command, and both code\nsnippets. Its title is already English.\n\nCo-Authored-By: Claude Opus 5 \n\n* Close four plumbing gaps that were blocking translation work\n\nAn agent reported four things it could not translate. All four were my\ngaps, not its.\n\n**The auditor rejected text blocks.** Directory trees, aligned tables and\ncommand traces carry their annotations as bare text, not as // comments,\nso normalize() could not strip them and the structural check failed on\nevery line. Those blocks have no executable code to protect -- the\n\"structure\" is the paths and the indentation -- so language: \"text\" now\nskips the structure check and keeps only the line-count rule. Two\ndirectory trees in foundations had been stuck in Chinese because of this.\n\n**The auditor did not treat block comments as comments.** {/* … */} and\n/* … */ were compared as code, which forced one Chinese fragment to stay\ninside an otherwise English snippet in cab-booking. Both forms are\nstripped now.\n\n**Three fields had no English on the type.** Callout.title and .body,\nLesson.sourceFiles[].role, and FillBlankExercise.filename and .template.\nThe agent tried the last one, hit a type error, and correctly reverted\nrather than edit types.ts.\n\nThe fill-blank template resolves through t() rather than . The template\nis parsed into input fields before rendering, so rendering both languages\nwould produce two sets of inputs with one hidden, and Tab would walk into\nthe invisible one.\n\nVerified with a four-case probe: a translated text block and a translated\nJSX block comment pass; a text block with a changed line count and a\nnon-text block with a changed identifier are still caught. 163 pairs pass\nthe audit, tsc and build clean.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English for the four Context wrong examples in react-part5\n\nThe object literal passed straight through, the reasonable-looking default,\nuseTheme called inside its own Provider, and the button text that says the\ncurrent theme all carry codeEn now.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3: English for the six-endpoints exercises\n\nAdds titleEn/promptEn/explainEn and 8 labelEn to the two recognition exercises,\nand hintEn/whyEn to all five blanks of the status-code fill-blank.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3: English for the six-endpoints code-completion\n\nAdds titleEn/promptEn, starterEn (35 lines, matching), requirementsEn (7),\nhintsEn (4) and 10 check labelEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3: English for the swallowed-404 Debug Lab\n\nAdds titleEn/promptEn, 8 labelEn across classify and locate, questionEn,\nrootCauseEn, verifyEn and codeEn for the broken implementation.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3: English for the written-questions lesson\n\nAdds titleEn/promptEn/explainEn and 8 labelEn to the two recognition exercises,\nand the English side of the actuator code-completion: starterEn (8 lines),\nrequirementsEn (4), hintsEn (4), 6 check labelEn, plus codeEn for the solution.\nAlso codeEn for the four Java wrong-example snippets.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3: English note under the application.properties window\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English twins for the shared code constants in react-part5\n\nThe twelve reusable code blocks (four reference solutions, the tree helpers\nand six test files) now have _EN versions wired in through codeEn, plus\nEnglish window titles at every call site. Every pair has the same line\ncount, checked line by line.\n\nOnly comments are translated, along with the guard message in ThemeContext\nand the assertion that matches it. Test names, fixture values and the demo\napp's own on-screen text stay in Chinese: the test names are quoted\nverbatim by the errorOutput of the Debug Lab entries, and the fixtures are\ndata rather than copy.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part4: English for the full resolvers and the verify script\n\nAdds FULL_RESOLVERS_EN (177 lines) and VERIFY_SCRIPT_EN (55 lines), both\nverified line-for-line against the Chinese originals, and wires them into the\nthree code windows that use them.\n\nCo-Authored-By: Claude Opus 5 \n\n* Drop the duplicated codeEn and filenameEn lines in react-part5\n\nA rerun of the wiring pass inserted a second copy of codeEn at fifteen call\nsites, which TypeScript rejects as a repeated property. One copy each is\nleft; tsc and the code audit are both clean again.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part4: English for Debug Lab 1 (resolver name mismatch)\n\nCo-Authored-By: Claude Opus 5 \n\n* Translate the code comments in the HTML and CSS interview lessons\n\nSix snippets in iv-basics.tsx: event capture and bubbling order, the head\nmeta tags, the box-sizing reset, a Grid skeleton with Flex inside the\ncells, SCSS variables against native custom properties, and a responsive\ngrid with no media query.\n\nThe console.log labels in the capture-and-bubble snippet are translated,\nbecause they are what the reader compares against the stated output order,\nnot fixture data.\n\nLine counts are identical and no highlight moved. npm run audit:code\npasses at 182 pairs.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to fed-part4 debug exercise 2 (non-null violation)\n\nAdds titleEn, promptEn, codeEn for the broken and fixed snippets,\nlabelEn on classify/locate options, questionEn, rootCauseEn and verifyEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* Translate the code comments in the backend interview lessons\n\nFour snippets in iv-backend.tsx: the Node event loop phases with\nnextTick, an Express middleware chain with its four-argument error\nhandler, a SQL schema with a foreign key and its missing index, and CORS\nwith credentials next to a dev proxy.\n\nThe console.log labels in the event loop snippet are translated: they are\nthe thing the reader matches against the stated output order.\n\n188 pairs pass npm run audit:code, no line moved.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to fed-part4 debug exercise 3 (DataLoader order)\n\nAdds titleEn, promptEn, codeEn for the broken and fixed snippets,\nlabelEn on classify/locate options, questionEn, rootCauseEn and verifyEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the values-and-addresses snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the coercion quick reference\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English twins for the debounce and throttle reference code\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the == comparison table\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to fed-part4 debug exercise 4 (lowercase status 500)\n\nAdds titleEn, promptEn, filenameEn, labelEn on classify/locate options,\nquestionEn, rootCauseEn and verifyEn. Both Java snippets already had no\nChinese, so no codeEn was needed.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the debounce write-it-yourself exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* Translate the React basics interview code, and treat JSX text as content\n\nSeven snippets in iv-react1.tsx: what JSX compiles to, the virtual DOM\nand why an index makes a bad key, passing a callback up versus composing\nwith children, controlled against uncontrolled inputs, when memo achieves\nnothing, fragments in a table row, and a HOC next to the same thing as a\nhook.\n\nThe auditor caught a real gap while I was doing it. A button label written\nas a JSX text child -- -- is neither a string\nliteral nor a comment, so it was being compared as structure and blocked.\nIt is reader-facing text and should be translated, so normalize() now\nblanks text between > and < as well, but only when it contains no braces:\n{r.term} inside
{r.term}
is code and still has to match.\n\nVerified with a four-case probe: a translated label passes, while a\nchanged expression, a changed tag name and a changed attribute name are\nall still caught. 199 pairs pass the audit.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the short-circuiting snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the throttle write-it-yourself exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the var/let/const examples\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the pass-by-value snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the wrong-place-state debounce counter-example\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the Set snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* Translate the React hooks and ecosystem interview code\n\nNine snippets in iv-react2.tsx: why hooks cannot be conditional, useMemo\nagainst useCallback and the dependency that defeats both, a useLocalStorage\nhook, CSS custom properties inline versus a full inline style, React 18\nbatching with useTransition, lazy with Suspense and an error boundary,\nthe error boundary class itself, an RTK slice, and a Redux middleware with\na thunk.\n\nButton labels and fallback text inside JSX are translated -- \"Retry\",\n\"Loading failed, please refresh\", \"Updating…\" -- since those are what the\nreader sees on screen. Action type strings like \"user/loading\" are left\nalone: they are identifiers the reducer matches on.\n\n212 pairs pass npm run audit:code.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to fed-part4 from-scratch exercise 1 (Orders subgraph)\n\nAdds titleEn, promptEn, requirementsEn (13/13), roleEn on all 7 fileList\nentries, expectEn on the 3 commands that had Chinese, hintsEn (4/4) and\none filenameEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the Map vs Object snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the timing-test callout\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the three function forms snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the higher-order function snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the pure function snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the function vs block scope snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English twins for the deepClone and curry reference code\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the four hoisting behaviours snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the scope chain snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the deepClone write-it-yourself exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the closure snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to fed-part4 from-scratch exercise 2 (Spring Boot controller)\n\nAdds titleEn, promptEn, requirementsEn (11/11), roleEn on the 10 fileList\nentries that had Chinese, expectEn on the 6 commands that had Chinese,\nhintsEn (4/4), plus codeEn and filenameEn for the two solution snippets\nthat carried Chinese comments.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the flatten write-it-yourself exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the currying snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js1): add codeEn for the IIFE snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the curry write-it-yourself exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the JSON deep-copy counter-example\n\nCo-Authored-By: Claude Opus 5 \n\n* Add codeEn to the first two react-part4 teaching snippets\n\nAdds codeEn, filenameEn and explanationEn for the makeTask snippet and\nthe discriminated-union snippet in the Q2 reading lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the class and prototype chain snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the four this-binding rules snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the call/apply/bind snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English twins for the Promise.all, EventEmitter and LRU reference code\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English labels to react-part4 lesson 4.1 code windows\n\nAdds roleEn on the two sourceFiles entries, filenameEn on the two code\nwindow titles, and explanationEn on the npm run q2 note.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the event loop ordering snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the Promise.all and allSettled exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the Promise.all parallelism snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to the two react-part4 recognition exercises\n\nAdds titleEn, promptEn, labelEn on every option and explainEn for\nr-q2-why-fn and r-q2-not-allsettled.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the callbacks vs async/await snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the EventEmitter exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the finally snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* Add codeEn to the react-part4 worker pool teaching snippets\n\nCovers the batching-vs-pool timing table, the shared cursor snippet and\nthe three step-by-step derivation snippets, plus their filenameEn titles.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the async error handling snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the Promise combinators snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the event delegation snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* Add codeEn to the react-part4 measured output snippet\n\nAdds codeEn for the npm run q2 output block plus filenameEn on the full\nimplementation window and the output window. TASK_RUNNER_HEADER and\nTASK_RUNNER_SOLUTION contain no Chinese, so they need no codeEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the LRUCache exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-js2): add codeEn for the fetch vs axios snippet\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: English for the push-instead-of-index Promise.all counter-example\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to the react-part4 fill-blank exercise\n\nAdds titleEn, promptEn and hintEn/whyEn for all five blanks. The template\ncontains no Chinese, so it needs no templateEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to the react-part4 code-completion exercise\n\nAdds titleEn, promptEn, requirementsEn (5/5), labelEn on all 10 checks,\nhintsEn (4/4) and filenameEn on the solution window. The starter has no\nChinese, so it needs no starterEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English twin for the Dropdown component code\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to the react-part4 missing-parentheses debug exercise\n\nAdds titleEn, promptEn, filenameEn on both code windows, codeEn for the\nfixed snippet, labelEn on classify/locate options, questionEn, rootCauseEn\nand verifyEn. errorOutput is left as the real output.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add codeEn to the three react-part4 worker pool mistake snippets\n\nThe push, return-after-catch and per-worker-cursor counterexamples now\ncarry codeEn. Their whyEn text was already in place.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to react-part4 debug lab fault 1 (path case)\n\nAdds titleEn, promptEn, labelEn on classify/locate options, questionEn,\nfilenameEn, rootCauseEn and verifyEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English twins for the Tabs and StarRating component code\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to react-part4 debug lab fault 2 (prop names)\n\nAdds titleEn, promptEn, codeEn and filenameEn on the code windows,\nlabelEn on classify/locate options, questionEn, rootCauseEn and verifyEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to react-part4 debug lab fault 3 (data-testid)\n\nAdds titleEn, promptEn, labelEn on classify options, questionEn,\nfilenameEn and rootCauseEn. The two code windows and verify hold no\nChinese, so they need no English twin.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to react-part4 debug lab fault 4 (silent mutation)\n\nAdds titleEn, promptEn, filenameEn on both code windows, labelEn on\nclassify/locate options, questionEn, rootCauseEn and verifyEn.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the click-outside fill-blank exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the star-rating code-completion exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to react-part4 from-scratch exercise Q1\n\nAdds titleEn, promptEn, requirementsEn (11/11), roleEn on the 11 fileList\nentries with Chinese, expectEn on the 3 commands with Chinese, hintsEn\n(4/4), plus filenameEn and explanationEn on the reference answer windows.\n\nCo-Authored-By: Claude Opus 5 \n\n* Let the auditor see inside escaped template literals\n\nAn agent reported that three template literals with Chinese in them could\nnot be translated, and worked around it by adding an English gloss comment\nnext to each. The cause was mine.\n\nThe code snippets are themselves written inside a template literal, so a\ntemplate literal within a snippet is escaped: \\`user \\${id} 不存在\\`. The\nbacktick branch of normalize() only recognised bare backticks, so the text\ninside an escaped one was compared as structure and any translation failed\nthe check.\n\nFixing it took three passes, and the middle two are worth recording\nbecause each was a real weakening rather than a syntax slip:\n\nBlanking the whole template literal made the check pass, but it also made\n\\${id} to \\${uid} invisible -- that is a code change and must be caught.\nSo only the literal text is blanked and the interpolations are kept.\n\nHandling escaped and bare backticks as two consecutive rules did not work\neither: the \\` left behind by the first rule was read as an opening\nbacktick by the second, which swallowed the interpolations the first rule\nhad deliberately preserved. One regex now handles both, with the escape\ncaptured and required to match at the closing end.\n\nVerified by extracting normalize() straight out of the file and running it\non the real strings: translating the text passes, changing id to uid is\ncaught. The three literals in iv-js2 are now properly translated. The\nChinese dog name stays as data, and the audit caught me putting it into an\nEnglish comment, which is exactly what that rule is for.\n\n253 pairs pass.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the three widget counter-examples\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English fields to react-part4 from-scratch exercise Q2\n\nAdds titleEn, promptEn, requirementsEn (6/6), roleEn on all 4 fileList\nentries, expectEn on all 3 commands and hintsEn (4/4).\n\nCo-Authored-By: Claude Opus 5 \n\n* Add roleEn to the react-part4 sourceFiles entries\n\nCo-Authored-By: Claude Opus 5 \n\n* Add roleEn to the fed-part4 sourceFiles entries\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English twins for the Player component and the useLocalStorage hook\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the player test stub and the useLocalStorage counter-examples\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English twin for the 24-test Coding.test.tsx file\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the useLocalStorage code-completion exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the useRef and onEnded counter-examples\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English twins for the todos slice and the store setup\n\nCo-Authored-By: Claude Opus 5 \n\n* Translate the file-role descriptions, and add errorOutputEn\n\nAn agent noticed that a few field kinds were still Chinese in files the\nearlier rounds had signed off, and that it was systematic rather than\nspecific to one file. A sweep across content/exams found 105 such values:\n37 file roles, 31 code-window filenames, 24 explanations and 13 error\noutputs.\n\nThis commit does the roles and adds the missing type field for error\noutput.\n\nAll 33 string-valued roles now have English. Three more are already\n{ zh, en } objects, because MockSetup.files types role as Bilingual --\nthat part was done properly from the start. A third kind turned up during\nthe sweep, Exam.sourceProjects[].role, which had no English field at all;\nit has one now and is plumbed through nav generation, since the exam page\nreads the generated NavExam rather than the content type.\n\nDebugExercise.errorOutput gained errorOutputEn. The error text itself is\nkept verbatim, but these blocks carry Chinese annotation lines mixed in\n(\"# 更严重的情况:…\", \"# 复现:…\") which are notes to the reader, not part\nof the error. Same split as codeEn: the error lines are copied, the\nannotations are translated. Values still to fill.\n\ntsc, build and the 260-pair code audit all clean.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the RTK TodoApp and the useSelector trap\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English twin for the 8-test Rtk.test.tsx file and its verify command\n\nCo-Authored-By: Claude Opus 5 \n\n* Translate the code-window captions\n\n28 of them, the second category in the sweep. Most are a real path with a\nChinese note after it -- src/App.tsx(全文), src/NoteManager.test.tsx\n(第 3 个测试) -- so the path stays and the note is translated. A few are\ncaptions with no path at all: \"本机实测输出\", \"受控的 select\", \"key 的选择\".\n\nTwo of the 28 already had English that my duplicate check missed. It looked\nfor an existing filenameEn within four lines, and in these two the existing\none sits after the code block, 27 lines below the filename it belongs to.\ntsc caught both as duplicate keys; the extra lines are removed.\n\nCodeCompletionExercise also needed filenameEn on the type -- it has its own\nfilename field, separate from the one on CodeExample.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the createSlice fill-blank exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the three Redux Toolkit counter-examples\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English twin for the Kanban component and its two wrong versions\n\nCo-Authored-By: Claude Opus 5 \n\n* graphql-federation: English explanations for the five code-window captions\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the moveCard code-completion exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: English for the name-the-point recognition exercise\n\nCo-Authored-By: Claude Opus 5 \n\n* react, react-part1: English code-window explanations and one error block\n\nCo-Authored-By: Claude Opus 5 \n\n* foundations: English for the seven code-window explanations and the silent-state error block\n\nCo-Authored-By: Claude Opus 5 \n\n* cab-booking, iv-basics, iv-react1: English code-window explanations\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part3: English for one explanation and the two error blocks\n\nNote titles kept in the original language: they are fixture data the\nreader types in, not prose.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part3, fed-part4: English for four Debug Lab error blocks\n\nError and stack-trace lines are copied byte for byte; only the Chinese\nannotation lines are translated. Line counts match.\n\nCo-Authored-By: Claude Opus 5 \n\n* fed-part2, fed-part4: English for three more Debug Lab error blocks\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part2, react-part4: English for four Debug Lab error blocks\n\nCo-Authored-By: Claude Opus 5 \n\n* Fix the audit's blind spots: it was skipping a quarter of the pairs\n\nAn agent reported that pairs written as `codeEn: SOME_CONST_EN` were never\nchecked, and that starterEn and templateEn were not checked at all. It was\nright, and it is the worst kind of bug for a tool like this: the script\nprinted \"✓ all pass\" while quietly checking only 267 of 346 pairs. A false\nassurance is worse than no script.\n\nThe extractor now covers four fields (codeEn, starterEn, templateEn,\nerrorOutputEn) and both ways a value can be written (an inline template\nliteral, or a reference to a module-level constant). Coverage went from\n267 to 360.\n\nCrucially, anything it cannot pair up is now counted and printed instead\nof dropped. That silence is what let 79 pairs go unchecked.\n\nWidening it surfaced 38 real failures, and every one was a gap in\nnormalize() rather than bad translation:\n\n- The language was lost when the Chinese came from a constant, so the\n `text` exemption never applied and every method-name table with a \"→ 中文\"\n annotation was read as a structure mismatch.\n- A multi-line JSDoc block only has /* on its first line and */ on its\n last, so the ` * text` lines in between were compared as code.\n- A JSX block comment spanning lines has the same problem from both ends.\n- Comments quoting a Chinese fixture (\"会议\" / \"内容A\") were reported as\n untranslated. Those strings are asserted on and must not change, so\n quoted content inside a comment no longer counts.\n\nTwo pairing bugs also came out of it. Choosing between a constant\nreference and an inline literal by asking \"is there a backtick nearby\"\nfails in both directions -- it either blocks real(\"text\", TABLE, { codeEn:\nTABLE_EN }) or lets a snippet borrow a constant from further up the file,\nwhich produced a 49-line Chinese half against a 4-line English one. It now\ncompares positions and takes whichever is closer. And the language regex\nused exec, returning the first constructor in the window rather than the\nnearest, so real(\"text\", …) inherited the language of an earlier\nreal(\"ts\", …).\n\nVerified with a 14-case probe: eight that must pass (translated comments,\nstring labels, JSX text, bare text in a text block, JSDoc bodies, multi-line\nJSX comments, retained Chinese data, a quoted fixture in a comment) and six\nthat must fail (line-count mismatch, changed identifier, untranslated\ncomment, changed JSX tag, changed template expression, text block with a\nchanged line count). All eight pass, all six are caught.\n\nOne real problem was found by the widened audit and fixed: a comment in\nreact-part3 read \"内容A is gone, 内容B is still there\", a Chinese fixture\nname inside an English sentence. Quoted now, since the name itself cannot\nchange.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part5: English for the four Debug Lab error blocks\n\nTest names stay verbatim, matching how the *_TEST_EN code twins already\nkeep them. Only the reader-facing annotation lines are translated.\n\nCo-Authored-By: Claude Opus 5 \n\n* Catch whole lines of Chinese left on the English side\n\nThe audit only looked for Chinese inside // comments, so a block whose\nlanguage has no comment syntax was invisible to it. An agent found four\nlines that had slipped through in react-part3, annotations attached to\nterminal output with an arrow: \"← 没有这个 script,用 npx\".\n\nA count of the whole tree found 129 English-side lines containing Chinese,\nand splitting them by whether the line is byte-identical to its Chinese\ncounterpart turned out to be the useful signal. The 12 that differ are all\ncomments quoting a fixture by name, which is correct. The 117 identical\nones are where an untranslated line hides among deliberately retained\ndata.\n\nThe new check flags an identical-and-Chinese line, minus three exemptions,\neach of which is a case where the Chinese must not change:\n\n- lines containing a quote or code punctuation, which are data or code;\n- test result lines (✓ / ✕ / Expected / Received / Unable to find),\n because a test name appears both in the source and in the output and the\n two have to match character for character -- each of these names occurs\n four times in the file, once per language per place;\n- and terminal-session blocks are now exempt from the structure check as\n well as text blocks, since a bash block is commands plus output with bare\n prose annotations and has no executable structure to protect.\n\nThat left three genuine misses, now translated: the arrow annotation in\nreact-part3, a JSDoc line in react-part5, and a

卡片内容

placeholder.\n\n363 pairs pass, and the 14-case probe still reports exactly its six\nintended failures and none of its eight intended passes.\n\nCo-Authored-By: Claude Opus 5 \n\n* Translate the mock exam task titles and command expectations\n\nA final sweep across every bilingual field pair found 26 values still\nChinese-only. Eleven were mock exam task titles -- \"Task 1 · Author 上的\n两个字段\" and its five siblings in the Federation mock, three more in the\nReact mock -- plus two callout headings and four command expectations.\n\ncommands[].expect needed the type widening in three places: MockExam,\nFromScratchExercise and DebugExercise each declare their own commands\narray. The arena run page and the arena checkoff panel read the field too\nand now pass the English through; the checkoff panel declares its own prop\ntype, so that widened as well.\n\nCo-Authored-By: Claude Opus 5 \n\n* Finish the last field gaps: two iv-ts exercises and one verification note\n\nThe iv-ts exercises were missed when I did that file: I translated its\neleven code snippets and stopped there, leaving both recognition exercises\nChinese-only. Title, prompt, all eight option labels and both explanations\nnow have English.\n\nAlso the remaining verification note in the Federation mock, and a\nfilenameEn line whose indentation was two spaces short of its siblings.\n\nOne thing to record, because I made the same mistake twice in this sweep:\nchecking \"does this object already have the English field\" with a window\nof 50 lines is not enough. A rootCause holds a paragraph of JSX, so its\nEnglish twin can sit a hundred lines below. My first attempt inserted a\nduplicate rootCauseEn into the wrong object, and tsc caught it. The window\nis 120 lines now, and with that the count of remaining gaps drops from 14\nto 4 -- all four being false positives: three roles that are already\n{ zh, en } objects, and one `role: \"admin\" | \"member\"` inside a code\nsnippet.\n\nEvery bilingual field pair that exists in the type system is now filled.\n363 code pairs pass the audit, tsc and build clean.\n\nCo-Authored-By: Claude Opus 5 \n\n* Default to English\n\nThe seven sibling apps default to English. This one started out Chinese\nonly, so Chinese was the default; now that the titles, lesson bodies, code\ncomments and exercises all have English, keeping it would mean an English\nreader has to click the switch on every visit.\n\nFour places decide the default and all four had to move together, or the\nfirst paint would show one language and then flip:\n\n- the bootstrap script's fallback, which sets before paint\n- the server-rendered lang attribute in the layout\n- the provider's initial state, which has to match the server\n- the context default\n\nThe CSS rules are symmetric on html[lang], so they follow on their own.\n\nVerified in a browser: with no stored preference the first paint is\nlang=\"en\" with an English title, objectives and sidebar; one click on the\nswitch gives Chinese and stores \"zh\"; a reload then stays Chinese.\n\nWorth being aware of: falls back to Chinese when the English is\nmissing. That was the safe direction before and it is the visible one now\n-- any field still lacking English shows Chinese to an English reader\nrather than an empty space. Every bilingual field pair in the type system\nis filled, so what remains is retained data (fixtures, test names) and\ntext nested inside body JSX.\n\nCo-Authored-By: Claude Opus 5 \n\n* Translate the mock exam requirements, and let arena titles pick up English\n\nThe eleven mock exam tasks now have English requirements -- 41 lines\nacross the React and Federation mocks. These are the acceptance criteria\nyou work against, so without them the two mock exams were unusable in\nEnglish.\n\nArena problem titles needed one line, not a type change. All five\nfrom-scratch exercises already had titleEn from an earlier round;\ncontent/arena.ts derived the arena entry from fs.title and simply never\npassed fs.titleEn along. That was 177 of the remaining nodes.\n\nThe insertion script aborts if a Chinese and English requirement array\ndisagree in length, since they align by index and a mismatch silently\nfalls the whole list back to Chinese.\n\nVerified against a running build: the mock exam page shows no Chinese\nrequirements and the English text is present; all five arena titles read\nin English.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English acceptance criteria for the first three coding problems\n\ntodo-list, timer and fetch-user now carry requirementsEn alongside the\nChinese requirements. Also adds the requirementsEn field to the local Spec\ninterface so the data type-checks.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English acceptance criteria for comment-tree, theme-context and star-rating\n\nThree more requirementsEn arrays, each the same length as its Chinese list.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English acceptance criteria for use-local-storage, player and dropdown\n\nThree more requirementsEn arrays, each matching the length of its Chinese list.\nARIA attribute names and role values are left as written.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English acceptance criteria for rtk-todo and notes-manager\n\nTwo more requirementsEn arrays, each matching the length of its Chinese list.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English acceptance criteria for cab-booking-app\n\nAll twelve items now have an English counterpart, so every one of the twelve\ncoding problems that carries an explicit requirements list has a matching\nrequirementsEn list of the same length. data-testid values, page state strings\nand the confirmation sentence are left exactly as the tests expect them.\n\nCo-Authored-By: Claude Opus 5 \n\n* Make the coding requirements and the sidebar mock titles render\n\nAn agent filled in English for all 85 acceptance criteria across the 12\ncoding problems, then repo…","author":{"name":"renrenmimi","date":"2026-08-22T01:30:48Z"},"committer":{"name":"GitHub","date":"2026-08-22T01:30:48Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"1a60d44fdd67b268eeb4bd9a9023706a87a93987"}]},{"sha":"1a60d44fdd67b268eeb4bd9a9023706a87a93987","html_url":"https://github.com/renrenmimi/DrillLab/commit/1a60d44fdd67b268eeb4bd9a9023706a87a93987","commit":{"message":"Translate the lesson bodies across all 80 lessons (#3)\n\n* Plumb English fields for the lesson body\n\nThe navigation reads in English now, but the lesson itself does not: the\none-line hook, the objectives, the what-the-exam-tests note, the section\nheadings and ledes, the common mistakes, the transfer patterns and the\nrecap are all Chinese-only fields.\n\nThis adds the optional English field for each and wires every render\nsite. No English text yet -- the plumbing has to land before the content\ncan be filled in per course.\n\nobjectives, recap and their English counterparts are two parallel arrays,\nnot one bilingual array. Alignment is by index, so a missing English\nentry does not just leave a gap: from that point on every Chinese item is\npaired with the wrong English one, which reads plausibly and says\nsomething else. BilingualList therefore falls back to Chinese for the\nwhole list when the lengths disagree. A visibly untranslated list is far\neasier to notice than a subtly misaligned one.\n\nThree more prop types widened to ReactNode for the same reason as before,\nbecause renders two spans rather than a string: Section's title, and\nLessonToc's item labels. The right-hand outline uses rather than the\n\"中文 / English\" slash format used elsewhere, since doubling the length of\nevery outline entry would defeat the purpose of an outline.\n\nCo-Authored-By: Claude Opus 5 \n\n* Wire the mock exams and the arena, and check in partial lesson-body English\n\nTwo things in one commit because they were in flight together.\n\nMock exams and arena problems had no English fields at all, which a\nprevious pass had flagged. MockExam gains English for its title, scenario,\nmirrors note and per-task titles and requirements; ArenaChallenge gains\ntitle and scenario. The seven arena scenarios are written out in full,\nsince that table lives in content/arena.ts rather than in a course file.\n\nArena titles are only partly available by design, and the type says so:\nthe two problems derived from mock exams pick up MockExam.titleEn, while\nthe four derived from from-scratch exercises stay Chinese because the\nExercise type has no English field yet. falls back, so those four show\nChinese rather than breaking.\n\nThe rest is partial lesson-body content for nine files. Three agents were\ntranslating in parallel and all three were cut off by a watchdog after\n600 seconds with no tool call -- they had each been composing dozens of\nlong strings into a single write. The work they had finished is sound and\nis checked in here rather than thrown away.\n\nVerified before committing, per file: every Chinese value still present\nbyte for byte, no Chinese array changed length, and every English array\nexactly as long as its Chinese counterpart. That last one matters because\nalignment is by index, so a short English array silently pairs each\nChinese item with the wrong English one.\n\nWhat is done in those nine files: blurb, objectives, whyForAssessment and\nrecap for every lesson they reached, and most section headings and\ntransfer rows.\n\nWhat is still missing, and is the next round's job: section ledes are\nalmost entirely undone, a handful of headings and transfer signals, all\nof the mistakes whyEn, and six content files not started at all\n(fed-part3, fed-part4, graphql-federation, cab-booking, react-part4,\nreact-part5, iv-react1, iv-react2, iv-backend, iv-coding, iv-hand,\ninterview, foundations bodies).\n\ntsc and next build clean; nav.ts regenerated.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English body fields to React exam walkthrough\n\nFill in headingEn (4) and ledeEn (2) for the ConceptSection entries in\nthe mockExams walkthrough array. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English body fields to GraphQL federation exam walkthrough\n\nFill in headingEn (6) and ledeEn (1) for the ConceptSection entries in\nthe mockExams walkthrough array. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 1 (node-and-npm)\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 2 (package-json)\n\nCo-Authored-By: Claude Opus 5 \n\n* Stop the last hardcoded slash labels from showing Chinese in English mode\n\nSix labels were written as literal \"中文 / English\" strings. That was the\nright call when the surrounding content was Chinese-only, but section\nheadings are bilingual now, so in English mode the right-hand outline is\na clean list of English entries with three Chinese-and-English entries\nwedged in, and the breadcrumb starts with one. It reads like the\ntranslation stopped halfway.\n\nConverted: the outline entries for practice, common mistakes and transfer\npatterns, the roadmap breadcrumb, the problem-brief section on a coding\npage, and the three file-list headings. FileExplorer's title prop widened\nto ReactNode to take .\n\nThe tag tooltip in exercise.tsx keeps its slash form on purpose: an HTML\ntitle attribute can only hold a string, so both languages have to share\nit.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 3 (npm-scripts)\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 4 (project-layout)\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 5 (js-immutable-data)\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 6 (js-async)\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English body fields to fed-part4 lessons\n\nFill in the English side of the short body fields for both lessons\n(Debug Lab and the from-empty-directory rewrite):\n\n- blurbEn 2, whyForAssessmentEn 2\n- objectivesEn 2 arrays (4 + 4 items, lengths match the Chinese)\n- recapEn 2 arrays (5 + 5 items, lengths match the Chinese)\n- headingEn 6, ledeEn 3 in the concepts arrays\n- signalEn 10, reachForEn 10 in the transfer arrays\n\nThe transfer entries were single-line objects; they are expanded to\nmulti-line to fit the four fields, matching the style already used in\ncontent/exams/iv-js2.tsx. No Chinese text is changed.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 7 (js-modules)\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 8 (ts-types)\n\nCo-Authored-By: Claude Opus 5 \n\n* Replace 16 banned idioms in the English lesson bodies\n\nThe writing standard for this app bans idioms, because the readers are\nlearners whose first language is not English. Sixteen had got through into\nbodyEn: \"blows up\" / \"blow up\" / \"blew up\" eleven times, \"for free\" four,\n\"silver bullet\" once.\n\nMy earlier idiom audit missed all of them, and it is worth recording why.\nIt scanned quoted string literals, but bodyEn is JSX -- the text sits in\nelement children, not in a string -- so every one of these was invisible\nto it. The audit now scans lines rather than literals, and the banned list\ngained the singular and past-tense forms; \"blows up\" was on the list from\nthe start but \"blow up\" and \"blew up\" were not.\n\nEach one is replaced with what actually happens rather than a synonym:\n\"the last assertion fails\", \"it throws immediately\", \"fails the whole\nbatch\", \"the update does not change the original array\", \"scoping needs no\nextra work\", \"it is not the right choice everywhere\".\n\nNo Chinese was touched: every Chinese string literal in the ten files is\nstill present byte for byte.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for foundations lesson 9 (ts-generics-and-errors)\n\nCo-Authored-By: Claude Opus 5 \n\n* Replace an idiom in one foundations recap line\n\n\"You are vouching for the value\" is not plain English for a reader whose\nfirst language is not English. Say what the ! actually does instead.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for cab-booking lesson 1 (cb-read-tests)\n\nblurbEn, objectivesEn (4/4), whyForAssessmentEn, recapEn (5/5),\nheadingEn and ledeEn for both concepts, and whyEn for the one mistake.\nChinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part5: add English body fields for lesson 1 (Todo List)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn,\nmistakes whyEn, transfer signalEn/reachForEn, and recapEn for the\nr-var-todo lesson. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for cab-booking lesson 2 (cb-provider-layer)\n\nblurbEn, objectivesEn (4/4), whyForAssessmentEn, recapEn (5/5),\nheadingEn and ledeEn for all three concepts, and whyEn for both mistakes.\nChinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part5: add English body fields for lesson 2 (Timer)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn,\nmistakes whyEn, transfer signalEn/reachForEn, and recapEn for the\nr-var-timer lesson. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for cab-booking lesson 3 (cb-page-machine)\n\nblurbEn, objectivesEn (4/4), whyForAssessmentEn, recapEn (5/5),\nheadingEn and ledeEn for both concepts, and whyEn for the one mistake.\ntsc --noEmit is clean. Chinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part5: add English body fields for lesson 3 (fetch)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn,\nmistakes whyEn, transfer signalEn/reachForEn, and recapEn for the\nr-var-fetch lesson. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for cab-booking lesson 4 (cb-options-grid)\n\nblurbEn, objectivesEn (4/4), whyForAssessmentEn, recapEn (5/5),\nheadingEn and ledeEn for both concepts, and whyEn for both mistakes.\nChinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for cab-booking lesson 5 (cb-loading-timer)\n\nblurbEn, objectivesEn (4/4), whyForAssessmentEn, recapEn (5/5),\nheadingEn and ledeEn for both concepts, and whyEn for the one mistake.\ntsc --noEmit is clean. Chinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part5: add English body fields for lesson 4 (comment tree)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn,\nmistakes whyEn, transfer signalEn/reachForEn, and recapEn for the\nr-var-comment-tree lesson. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for cab-booking lesson 6 (cb-history-three)\n\nblurbEn, objectivesEn (4/4), whyForAssessmentEn, recapEn (5/5),\nheadingEn and ledeEn for all three concepts, and whyEn for both mistakes.\nChinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for cab-booking lesson 7 (cb-scaffold-bug)\n\nblurbEn, objectivesEn (4/4), whyForAssessmentEn, recapEn (5/5),\nheadingEn and ledeEn for all three concepts, and whyEn for the one mistake.\ntsc --noEmit is clean. Chinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English lesson-body fields for cab-booking lesson 8 (cb-rewrite)\n\nblurbEn, objectivesEn (4/4), whyForAssessmentEn, recapEn (5/5),\nheadingEn and ledeEn for the one concept, and whyEn for the one mistake.\nChinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* react-part5: add English body fields for lesson 5 (theme Context)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn,\nmistakes whyEn, transfer signalEn/reachForEn, and recapEn for the\nr-var-theme-context lesson. Chinese text is untouched.\n\nThis completes the short English body fields for react-part5.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English transfer cues for cab-booking lessons 1-3\n\nsignalEn and reachForEn for the transfer rows of cb-read-tests,\ncb-provider-layer and cb-page-machine (12 rows). Chinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English transfer cues for cab-booking lessons 4-5\n\nsignalEn and reachForEn for the transfer rows of cb-options-grid and\ncb-loading-timer. One reachFor value is already English\n(vi.useFakeTimers plus advanceTimersByTime), so it gets no English twin.\nChinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English transfer cues for cab-booking lessons 6-7\n\nsignalEn and reachForEn for the transfer rows of cb-history-three and\ncb-scaffold-bug (10 rows). Chinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* Add English transfer cues for cab-booking lesson 8 (cb-rewrite)\n\nsignalEn and reachForEn for the four transfer rows. tsc --noEmit is clean.\nChinese text untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: add English body fields for lesson 1 (debounce / throttle)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, signalEn,\nreachForEn, recapEn and the mistakes whyEn for the timing lesson.\nChinese text is untouched. lede values were already English, so no\nledeEn was added.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(fed-part3): add English for lesson 4.1 body fields\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, recapEn, concept\nheadingEn/ledeEn and transfer signalEn/reachForEn for the\n\"Spring annotations and one request path\" lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: add English body fields for lesson 2 (deepClone / flatten / curry)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, signalEn,\nreachForEn, recapEn and the mistakes whyEn for the data lesson.\nChinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-react1): add English body fields for lesson 1\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, recapEn and\ntransfer signalEn/reachForEn for the \"React is what\" lesson. Chinese text\nuntouched. Headings that were already English are left alone, and lede is\nleft alone because parseLede() reads only lede.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(fed-part3): add English for lesson 4.2 body fields\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, recapEn, concept\nheadingEn/ledeEn, transfer signalEn/reachForEn and the four\nmistakes whyEn for the six-endpoints lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* Use the canonical English titles where a lesson points at another task\n\nTwo passages in cab-booking refer the reader to a coding problem\nelsewhere on the site. The English described the problem instead of naming\nit -- \"the theme switching task on this site\", \"the timer variant of the\nReact task\" -- so the same problem had two English names depending on\nwhere you read about it. Both now use the title the problem actually\ncarries: \"Theme switch (Context plus a memoised value)\" and \"Timer\n(useEffect cleanup)\".\n\nChecked the other content files for the same thing. A substring search\nreports nine more, and all nine are false positives: they are lesson\ntitles or code comments that happen to contain a coding problem's title,\nnot references to it. The lesson a problem was derived from and the\nproblem itself are separate objects with separate titles, which is why\n\"变式三 · fetch 取数…\" and \"Fetching data: loading, error and race\nconditions\" both exist and both are right.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-hand: add English body fields for lesson 3 (Promise.all / EventEmitter / LRU)\n\nCompletes the module. Adds blurbEn, objectivesEn, whyForAssessmentEn,\nheadingEn, signalEn, reachForEn, recapEn and the mistakes whyEn.\nAll eight lede values in this file are already English question titles,\nso no ledeEn was added. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-react1): add English body fields for lesson 2\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, recapEn and\ntransfer signalEn/reachForEn for the components lesson. File is now\ncomplete: objectives 4/4 and 4/4, recap 7/7 and 9/9. The two headings left\nwithout headingEn are already English (\"React vs Angular\", \"props vs\nstate\"), and no lede contains Chinese.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: add English body fields for lesson 1 (the coverage map)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn and\nrecapEn. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(fed-part3): add English for lesson 5.1 body fields\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, recapEn, concept\nheadingEn/ledeEn and transfer signalEn/reachForEn for the two\nwritten questions. Completes fed-part3.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-react2): add English body fields for the hooks lesson\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, recapEn and\ntransfer signalEn/reachForEn. objectives 4/4, recap 6/6. Headings that are\nalready English (\"useMemo vs useCallback\", \"React.memo vs useMemo\") are\nleft alone, and lede is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: add English body fields for lesson 2 (dropdown / tabs / stars)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn,\nsignalEn, reachForEn, recapEn and the three mistakes whyEn.\nThe blanks why fields in exercises have no English counterpart in the\ntype, so they were left alone. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(react-part4): add English for lesson 4.1 body fields\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, recapEn, concept\nheadingEn/ledeEn and transfer signalEn/reachForEn for the\n\"reading the Q2 brief\" lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-react2): add English body fields for the performance lesson\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, recapEn and\ntransfer signalEn/reachForEn. objectives 4/4, recap 8/8. Chinese untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: add English body fields for lesson 3 (useRef and custom hooks)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn,\nsignalEn, reachForEn, recapEn and the two mistakes whyEn.\nChinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(react-part4): add English for lesson 4.2 body fields\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, recapEn, concept\nheadingEn/ledeEn, transfer signalEn/reachForEn and the three\nmistakes whyEn for the worker pool lesson.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-react2): add English body fields for the Redux and TypeScript lesson\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, recapEn and\ntransfer signalEn/reachForEn. File is now complete: objectives 4/4, 4/4,\n4/4 and recap 6/6, 8/8, 6/6. The four headings left without headingEn are\nalready English (\"useMemo vs useCallback\", \"React.memo vs useMemo\",\n\"Redux vs Context API\", \"JavaScript vs TypeScript\"), and no lede contains\nChinese.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(react-part4): add English for lesson 5.1 body fields\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, recapEn, concept\nheadingEn/ledeEn and transfer reachForEn for the Debug Lab lesson.\nSignals that are already error messages in English are left as is.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-backend): add English body fields for the Node.js lesson\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, recapEn and\ntransfer signalEn/reachForEn. objectives 4/4, recap 7/7. Chinese untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: add English body fields for lesson 4 (Redux Toolkit Todo)\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, ledeEn,\nsignalEn, reachForEn, recapEn and the three mistakes whyEn.\nChinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-backend): add English body fields for the SQL lesson\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, recapEn and\ntransfer signalEn/reachForEn. objectives 3/3, recap 4/4. Chinese untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(react-part4): add English for lesson 5.2 body fields\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, recapEn, concept\nheadingEn/ledeEn and transfer signalEn/reachForEn for the rebuild\nlesson. Completes react-part4.\n\nCo-Authored-By: Claude Opus 5 \n\n* iv-coding: add English body fields for lesson 5 (Kanban board)\n\nCompletes the module. Adds blurbEn, objectivesEn, whyForAssessmentEn,\nheadingEn, ledeEn, signalEn, reachForEn and recapEn. This lesson has no\nmistakes array. Chinese text is untouched.\n\nCo-Authored-By: Claude Opus 5 \n\n* content(iv-backend): add English body fields for the web and testing lesson\n\nAdd blurbEn, objectivesEn, whyForAssessmentEn, headingEn, recapEn and\ntransfer signalEn/reachForEn. File is now complete: objectives 4/4, 3/3,\n4/4 and recap 7/7, 4/4, 7/7. The two headings left without headingEn are\nalready English (\"HTTPS vs HTTP\", \"session vs cookie\"), and no lede\ncontains Chinese.\n\nCo-Authored-By: Claude Opus 5 \n\n* Fill in the English for the two mock exams\n\nBoth mock exam titles were showing in Chinese in the sidebar, along with\ntheir scenario and the note about which points they mirror. The type\ngained these fields earlier but no values; the content agents were told\nnot to touch mock exams, so they were left over.\n\nAlso aligns one cross-reference: iv-backend pointed at \"Task 2 of the\nFederation course\", where the course's own English short title is\n\"Federation exam\".\n\nCo-Authored-By: Claude Opus 5 \n\n---------\n\nCo-authored-by: Claude Opus 5 ","author":{"name":"renrenmimi","date":"2026-08-21T16:14:34Z"},"committer":{"name":"GitHub","date":"2026-08-21T16:14:34Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd"}]},{"sha":"b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd","html_url":"https://github.com/renrenmimi/DrillLab/commit/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd","commit":{"message":"Translate the navigation: English titles for all courses, modules and lessons (#2)\n\n* Add optional English fields for exam, module and lesson titles\n\nIn English mode the whole navigation skeleton still reads in Chinese: the\nsidebar course and lesson rows, the roadmap, the right-hand table of\ncontents, breadcrumbs and page titles all come from content fields typed\nas plain string.\n\nThese are sidecar fields rather than widening title to string | Bilingual.\n150 places already read .title, and a good number of them need a plain\nstring: generateMetadata titles, aria-labels, sort keys, the search index.\nWidening the union would force a pick() call at every one of them, which\nis a large mechanical change with real regression risk for no gain. With a\nsidecar, those 150 sites keep working untouched, and only the places that\nactually display a title change to .\n already falls back to Chinese when en is missing, so English can be\nfilled in one course at a time and the site is usable at every point in\nbetween.\n\nnav.ts and its interfaces are generated, so the two templates carry the\nfields through as well. JSON.stringify drops the undefined ones, so\nnothing is added to the payload for courses not yet translated.\n\nNo English text yet -- this is the plumbing only.\n\nCo-Authored-By: Claude Opus 5 \n\n* Render nav titles bilingually, and derive the English module stage\n\nThe sidebar, the roadmap and the home progress rows now read their titles\nthrough , so they pick up the English field once a course has one and\nkeep showing Chinese until then.\n\nModule stage labels are derived rather than stored. All 27 of them follow\none format, \" · 第 N 部分\", and the only Chinese in it is that\nsuffix plus two of the five course names -- React, Federation and Cab\nBooking are already English. Deriving it once means adding a course\ncannot leave a stage untranslated, and there is no second field to drift\nout of step with the first. An unrecognised format returns undefined so\n falls back to Chinese; showing Chinese is better than showing half an\nEnglish label.\n\nnav.ts is regenerated so the interfaces carry the new fields. No English\ncontent yet, so the payload grew by 7 lines of type declarations and\nnothing else.\n\nCo-Authored-By: Claude Opus 5 \n\n* Fill in English titles for the foundations course, and wire the exam page\n\nNine lesson titles, three module titles and summaries, and the course\ntitle, short title and description. Written as plain English for readers\nwhose first language is not English: no idioms, sentence case, and code\nand API names left as they are -- package.json, npm scripts, tsc,\nPromise, allSettled, type, interface.\n\nThe exam overview page and the remaining roadmap titles now read through\n as well, so this course displays in English end to end while the\nother four still fall back to Chinese. Exam.tests gained an English field\nfor the same reason.\n\nCo-Authored-By: Claude Opus 5 \n\n* Wire the remaining title display sites through \n\nRendering the sidebar bilingually was not enough: a scan of a course\nwhose titles are already translated showed Chinese still reaching the\nscreen from the lesson header and breadcrumbs, the previous/next footer,\nthe right-hand rail, the practice page filter chips and exercise\nattribution, the search index, and the page titles.\n\nThree prop types had to widen from string to ReactNode -- LessonHeader's\ncrumb labels, title and blurb, and NextLesson's prev/next titles --\nbecause renders two spans rather than a string.\n\nThe practice page focus line was a template string with the course name\ninterpolated into it, which would have put a Chinese course name inside\nan English sentence. It is now JSX so each language uses its own name.\n\nThe search index carries both languages in its haystack. Without that,\nsearching in English mode would silently fail to match anything whose\ntitle had just been translated.\n\nPage titles use the slash format the static pages already use, as in\n\"八股题库 / Interview drills\". A is one string and cannot hold\n<T>, and this way both readers find their half. Courses with no English\nyet get the Chinese title alone rather than it repeated twice.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* content: add English titles for the interview-questions course\n\nAdds the side-by-side English fields for the fourth course (面试八股)\nso the sidebar, roadmap, right-hand outline and breadcrumbs are usable\nin English mode. Chinese text is untouched; this commit is additions only.\n\nExam level (interview.tsx): titleEn, shortTitleEn, descriptionEn, testsEn.\nModule level (9 modules): titleEn + summaryEn.\nLesson level (25 lessons): titleEn.\n\nLesson titles of the form \"N 问\" all use one pattern, \"N questions on ...\",\nwith Arabic numerals, across all nine modules. Exercise-level and\ncallout-level titles were left alone, as they are out of scope for this\nround and have no English fields on the type.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* content: add English titles for the Federation and Cab Booking exams\n\nAdds the side-by-side English fields so the navigation, roadmap, right-hand\noutline and breadcrumbs are usable in English mode for these two exams.\nChinese text is untouched; every change is a new line only.\n\nGraphQL Federation exam (graphql-federation.tsx, fed-part1..4.tsx):\n 1 exam titleEn / shortTitleEn / descriptionEn / testsEn\n 6 module titleEn + 6 module summaryEn\n 17 lesson titleEn\n\nCab Booking exam (cab-booking.tsx):\n 1 exam titleEn / shortTitleEn / descriptionEn / testsEn\n 3 module titleEn + 3 module summaryEn\n 8 lesson titleEn\n\nTotals: 36 titleEn, 9 summaryEn, 2 shortTitleEn, 2 descriptionEn, 2 testsEn.\nTerms left in their original form: schema, resolver, subgraph, entity, @key,\n__resolveReference, DataLoader, N+1, Query, Mutation, nullable, Spring Boot,\nContext, Provider, useEffect, setTimeout, data-testid, and all HTTP status\ncodes. Titles use sentence case.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* content(react): add English titles for the React Capstone exam\n\nAdds the side-by-side English title fields for the React exam so the\nsidebar, roadmap, right-hand outline and breadcrumbs are usable in\nEnglish mode. Chinese text is untouched; the diff is additive only.\n\nCoverage (37 new fields across 6 files):\n- exam level (react.tsx): titleEn, shortTitleEn, descriptionEn, testsEn\n- module level: 6 titleEn + 6 summaryEn\n react-part1 mental model, react-part2 hooks, react-part3 Q1,\n react-part4 Q2 and mastery, react-part5 variants\n- lesson level: 21 titleEn (3 + 4 + 5 + 4 + 5 across part1..part5)\n\nReact API and tooling names are left as-is: useState, useEffect, useMemo,\nprops, state, key, Context, Promise, allSettled, AbortController.\nTitles use sentence case. No idioms; terms are explained before use\n(for example \"derived data\" is introduced as \"values you can compute\").\n\nVerified with npx tsc --noEmit: zero errors in these six files.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Add English titles for the 25 coding problems, and close two gaps\n\nThe sidebar now lists each course's coding problems, so their titles are\npart of the navigation. All 25 are Chinese. Todo List is already English\nand needs no second field.\n\nThe done-bar sentence interpolates the problem title, so it takes the\nEnglish title too; otherwise an English sentence would contain a Chinese\nproblem name.\n\nAlso fills two things missed in the foundations commit: testsEn, and\nsummaryEn on the JavaScript and TypeScript modules. Found by walking the\nassembled content rather than by grepping the source files -- an\nindentation-based check counts exercise, callout and mock-task titles as\nlessons and reports over a hundred false gaps, because those types have\nno English field by design. Against the real structure, all 5 courses,\n27 modules and 80 lessons now have their English fields.\n\nMeasured against a production build across 135 routes: text still\nrendering in Chinese in English mode went from 13,678 nodes to 8,957.\nUnique strings only dropped from 6,487 to 6,323, because titles repeat on\nevery page -- which is the point. What is gone is the navigation: the\nsidebar rows, module names, stage labels, breadcrumbs and page titles\nthat showed on all 135 pages. What remains is lesson body content.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n---------\n\nCo-authored-by: Claude Opus 5 <noreply@anthropic.com>","author":{"name":"renrenmimi","date":"2026-08-21T10:37:27Z"},"committer":{"name":"GitHub","date":"2026-08-21T10:37:27Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"2854ce1a45f8b7788979211b33a906a55ab41019"}]},{"sha":"2854ce1a45f8b7788979211b33a906a55ab41019","html_url":"https://github.com/renrenmimi/DrillLab/commit/2854ce1a45f8b7788979211b33a906a55ab41019","commit":{"message":"Rebuild navigation around one guided sidebar (#1)\n\n* Fix two broken explain lesson references in coding problems\n\norders-subgraph pointed at g-task1-loader and spring-endpoints at\ng-task2-endpoints. Neither lesson id exists, so the expand-explanation\nsection silently disappeared on both problem pages.\n\nPoint them at the lessons that actually cover the material:\ng-read-task1 walks through the four Task 1 resolvers, and g-endpoints\ncovers the six Spring Boot endpoints.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Assert that every coding problem's explain lesson exists\n\nThe from field is already checked by findExercise, which throws when the\nsource exercise is missing. The explain field had no such check: only the\nSELF_BRIEF and fill-blank branches read it, and the other branches derive\neverything from the source exercise. A wrong lesson id therefore reached\nproduction without any error, and the expand-explanation block just went\nmissing on the problem page.\n\nVerified both directions: the build passes with the 25 real ids, and\nfails with a clear message when one id is deliberately broken.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Regenerate nav.ts after the explain reference fix\n\nnav.ts is derived from the content files by npm run gen:nav, so the two\ncorrected lesson ids have to be propagated. Before this, the generated\ndata still carried the broken ids, which made the two Federation coding\nproblems impossible to attribute to any course.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Add content/path.ts: attach the question banks back to the courses\n\nnav.ts exposes two sets of data that never meet. One is five courses,\neach with modules and lessons, reachable only from the sidebar. The other\nis three flat banks -- 105 short questions, 25 coding problems, 7 arena\nproblems -- reachable only from the header. The same material is sliced\ntwo ways, so a learner has to guess which side to click.\n\nThis module answers the missing question: given a course, or a single\nlesson, which problems belong to it. The three banks attach differently,\nso the lookup belongs in one place rather than in every page:\n\n drill carries examId and lessonId, so it resolves down to one lesson\n arena carries examId only\n coding carries explainLessonId, so the course is found through it\n\npathGroups() also gives the sidebar its order. NAV is in registration\norder, which says nothing about what to study first; prerequisites do.\nfoundations has none and three courses require it. interview has none\neither, and nothing requires it, so numbering it 05 would be a lie: it\nis a parallel track that can start at any time. Hence two groups, with\nnumbers only on the main line.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Let the drill bank be filtered down to a single lesson\n\nThe 105 short questions each record the lesson they came from, but the\nlist page could only be filtered by track. So \"I just read this lesson,\nnow drill this lesson\" had no entry point in the interface, and the whole\nbank stayed disconnected from the course a learner is actually in. The\nsidebar needs somewhere to link, which is what this adds.\n\nCounts follow the filter. With a lesson selected, the track buttons and\nthe \"N of M\" line count that lesson only, and tracks the lesson does not\ncover are dropped. Otherwise the page would claim 105 while showing 10,\nand the number next to the list would be measuring a different set from\nthe list itself.\n\nAn unknown lesson id falls back to the full bank instead of erroring, so\nhand-edited URLs cannot produce a blank page. A banner names the lesson,\nlinks back to it, and always offers the way out to the full bank.\n\nVerified against a running build: full bank 12 cards on page one of 105;\n?lesson=iv-js-fn 12 cards with the banner; ?lesson=iv-js-types 10 cards\nand the track row reading \"All 10\"; an invalid id renders the full bank\nwith no banner.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Make the sidebar the one ladder, on every page\n\nThe seven sibling apps walk a learner down the left column and nothing\ngets missed. This one did not, and the reason was structural rather than\na matter of labels. The product has two axes: what you are studying\n(four courses plus the interview track) and how much scaffolding you are\nhanded (drills, lesson exercises, coding, arena -- see components/\nladder.tsx). The two are orthogonal, and navigation had split them across\ntwo controls: courses in the sidebar, tiers in the header. On any page\nthere were two defensible next steps, so a learner had to hold a matrix\nin their head to decide which side to click.\n\nThis folds the second axis into the first.\n\nThe sidebar is now permanent, so the ladder is present on every page\nrather than only inside a course. Main-line courses are ordered by their\nprerequisites, not by registration order in NAV, and numbered from 01.\nExpanding a course lays its tiers out in sequence: lessons, then coding\nproblems, then arena problems, then mock exams. A tier with no content is\nleft out entirely, so foundations shows lessons only instead of three\nzeroes.\n\nEach lesson row in the interview track carries the number of short\nquestions that came from it, linking to that lesson's slice of the bank.\nThat is what puts the 105 drills on the ladder; before this they existed\nonly in a flat table reachable from the header.\n\nThe interview track is not numbered. It has no prerequisites and nothing\ndepends on it, so calling it 05 would state an order that does not\nexist. It sits in its own group, labelled as parallel, with a line saying\nwhy.\n\nThe first unfinished main-line course is marked \"start here\" or\n\"continue\". Numbers give the order; this answers which step you are on,\nwhich is the part a learner cannot work out from numbering alone. It\nrenders only after progress has been read from localStorage, so server\nand client markup agree.\n\nThe four flat banks move to a group at the bottom. Walking one course\ndown is the main road; reading one tier across all courses is a second\nview worth keeping, but not worth placing beside the first.\n\nThe coding section is called \"整套 Coding 题\" rather than \"Coding 题\",\nbecause the interview track already has a lesson module named \"Coding\n题:对照与补缺\". Two entries opening with the same words in one column is\nthe exact confusion this change exists to remove.\n\nVerified in a browser: the sidebar renders on the home page, the bank\npages, reference and all five courses; tiers appear in order per course\nwith empty ones absent; the 17 lesson rows that have drills link to\n/drill?lesson=<id>; the marker sits on 01 at zero progress, reads\n\"continue\" at 3 of 9, and moves to 02 once 01 is complete; English\nstrings all resolve; at 375px there is no horizontal overflow and the\ndrawer still carries the main nav.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Cut the header down to the two items the sidebar does not carry\n\nWith the sidebar permanent, three of the five header items had become a\nsecond entrance to a destination already on the ladder: courses is the\nsidebar itself plus its overview link, practice and arena are both in the\nbanks group at the bottom. Two entrances to one place is what made \"left\nor top?\" a real question, so the header keeps only what has nothing to do\nwith where you are in the material: home and the usage guide, plus\nsearch, language and theme.\n\nEvery route stays where it was. Verified against a running build that\n/path, /practice, /arena, /drill, /code, /reference, /guide and /mock all\nstill answer 200, and that each one the header dropped is linked from the\nsidebar.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Give the roadmap the same order as the sidebar, and stop locking the\nparallel track behind the main line\n\nTwo problems, both from the roadmap deriving its own order.\n\nIt used the NAV array order, which put the interview track fourth and cab\nbooking fifth. The sidebar orders by prerequisites, so it says cab booking\nis 04 and the interview track is parallel. One question, two answers, from\nthe two views a learner is most likely to compare. Both now read the\norder from pathGroups(), and the roadmap numbers its main-line courses to\nmatch.\n\nThe \"you are here\" marker was computed over every module flattened into\none line. Putting the parallel track last would therefore render all nine\nof its modules as locked -- the opposite of what the same page says about\nit two lines above. State is now computed per group, so the main line and\nthe parallel track each have their own marker.\n\nVerified against a running build: the roadmap lists 01 foundations, 02\nReact, 03 Federation, 04 cab booking, then the interview track under a\nparallel-track band with no number, matching the sidebar exactly; and\nthere are exactly two current nodes, one at the head of each group.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Order the home progress rows the same way as the sidebar\n\nThe progress bars on the home page were the third place deriving course\norder from the NAV array, which lists the parallel interview track before\ncab booking. No numbers are shown here, so this was less visible than the\nroadmap, but it is the same inconsistency and the same one-line fix.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n* Put lesson exercises on the per-course ladder too\n\nThree of the four tiers were in the sidebar under each course; the second\none, lesson exercises, was missing, so the sequence read drills, then a\ngap, then coding. /practice already accepts ?exam=, so this is one entry\nrow per course rather than a new page.\n\nIt is a link, not a collapsible list: React alone has 54 exercises, and\nthey live inside lesson bodies without routes of their own, so listing\nthem in the sidebar would bury everything else.\n\nThat meant loosening three rules written for the mock-exam heading, which\nmatched on div.side-mod > .side-mod-title and were stripping the chevron,\nthe padding and the hover from anything inside a div. They now exclude\n.side-mod-link, which is what they meant in the first place.\n\nVerified in a browser: all five courses show the exercises row linking to\ntheir own ?exam= filter, tiers appear in ladder order with empty ones\nstill absent, and the chevron points right rather than down.\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n\n---------\n\nCo-authored-by: Claude Opus 5 <noreply@anthropic.com>","author":{"name":"renrenmimi","date":"2026-08-21T09:27:23Z"},"committer":{"name":"GitHub","date":"2026-08-21T09:27:23Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"7eb5240d8d4a174fbb9d2500c9bdbae0a26bf25b"}]},{"sha":"7eb5240d8d4a174fbb9d2500c9bdbae0a26bf25b","html_url":"https://github.com/renrenmimi/DrillLab/commit/7eb5240d8d4a174fbb9d2500c9bdbae0a26bf25b","commit":{"message":"Align the page title with what the app actually is\n\nThe title and meta description still described the app as training for an\nonline assessment, which is not what it is - it is a study app built\naround problems and concepts collected while learning. The README says\nso; the most visible string on the site said something else.\n\nNow: four tracks, each handing you less than the last, ending in an\nempty folder and a clock. The description also names the stacks it\nactually covers, including Spring Boot, which the old one left out.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:42:40Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:42:40Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"7e45aca2d0b7b6584f26393c9821342b848b840e"}]},{"sha":"7e45aca2d0b7b6584f26393c9821342b848b840e","html_url":"https://github.com/renrenmimi/DrillLab/commit/7e45aca2d0b7b6584f26393c9821342b848b840e","commit":{"message":"Add the README\n\nWhat it is, where the material comes from, and what each track hands you.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"6c01250eb82c32b60650234e12b7ed076678f18f"}]},{"sha":"6c01250eb82c32b60650234e12b7ed076678f18f","html_url":"https://github.com/renrenmimi/DrillLab/commit/6c01250eb82c32b60650234e12b7ed076678f18f","commit":{"message":"Add CLAUDE.md\n\nProject context for future sessions: what the four tracks are, why the\nquestion layer is derived rather than duplicated, the file ownership\nrules, and the pitfalls already paid for.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"fb93c1c0dc2dcfb125592d7bfa156317c1b7c8f2"}]},{"sha":"fb93c1c0dc2dcfb125592d7bfa156317c1b7c8f2","html_url":"https://github.com/renrenmimi/DrillLab/commit/fb93c1c0dc2dcfb125592d7bfa156317c1b7c8f2","commit":{"message":"Add screenshots and the local preview config","author":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"68c35de54571fa9d3732144ab7bb060bd4145cac"}]},{"sha":"68c35de54571fa9d3732144ab7bb060bd4145cac","html_url":"https://github.com/renrenmimi/DrillLab/commit/68c35de54571fa9d3732144ab7bb060bd4145cac","commit":{"message":"Record the information-architecture pass\n\nWhy the course archive was demoted from a main entry point to a\nreference: the four tracks are the product, and 80 lessons competing\nwith them at the top level made the app read as a textbook.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"8b45be32dffef6c8bca0fda0b9d46352d4b2e316"}]},{"sha":"8b45be32dffef6c8bca0fda0b9d46352d4b2e316","html_url":"https://github.com/renrenmimi/DrillLab/commit/8b45be32dffef6c8bca0fda0b9d46352d4b2e316","commit":{"message":"Record why the browser runtime is CodeSandbox\n\nWhat was tried, what each option could and could not do, and why 21 of\nthe 25 problems can run in the browser while 4 cannot - a JVM or Node\nserver process will not start inside an iframe, and two need fetch or\nHTMLMediaElement stubbed, which the sandbox cannot intercept.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:27:04Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"cc1f80eaa9ee810849d8f2e1087d24804214119a"}]},{"sha":"cc1f80eaa9ee810849d8f2e1087d24804214119a","html_url":"https://github.com/renrenmimi/DrillLab/commit/cc1f80eaa9ee810849d8f2e1087d24804214119a","commit":{"message":"Add the routes\n\nFive track roots plus the lesson, question, problem and sitting pages\nunder them. 252 pages prerendered.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:48Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:48Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"da75bde735af0cab5340fcf9e295ac110ac35646"}]},{"sha":"da75bde735af0cab5340fcf9e295ac110ac35646","html_url":"https://github.com/renrenmimi/DrillLab/commit/da75bde735af0cab5340fcf9e295ac110ac35646","commit":{"message":"Add the lesson and track components\n\nLesson rendering, the exercise checker, the drill session and flashcards,\nthe coding workspace with its editor and test panel, the arena timer, and\nthe progress marks that read from the shared store.\n\nThe exercise checker is regex matching after comments are stripped, not\nexecution - it can tell filter from push, it cannot tell whether the code\nworks. Every page that uses it says so.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:48Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:48Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"80e1a8194dd3538b6f60e868b575729813edb193"}]},{"sha":"80e1a8194dd3538b6f60e868b575729813edb193","html_url":"https://github.com/renrenmimi/DrillLab/commit/80e1a8194dd3538b6f60e868b575729813edb193","commit":{"message":"Add the app shell and the mark\n\nThe mark is four descending bars: the four tracks, each handing you less\nscaffolding than the last, down to a base that is just the arena floor.\nThe first three are dimmed and the fourth carries the accent - what you\nare given is background, what you write yourself is the subject.\n\napp/icon.svg is the same symbol with two constraints the component does\nnot have, both documented next to Mark: the leading comment has to stay\nshort because the metadata image loader only sniffs the first stretch of\nthe file for <svg>, and no comment anywhere may contain a double hyphen.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:48Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:48Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"cf70c0a64538af1820b32f36e093358bc4d86750"}]},{"sha":"cf70c0a64538af1820b32f36e093358bc4d86750","html_url":"https://github.com/renrenmimi/DrillLab/commit/cf70c0a64538af1820b32f36e093358bc4d86750","commit":{"message":"Add the content integrity checks\n\nThese run as part of the build. If a count is off, a number has a gap,\nor a referenced exercise cannot be found, the build fails rather than\nshipping a table that quietly disagrees with itself.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:31Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:31Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796"}]},{"sha":"32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796","html_url":"https://github.com/renrenmimi/DrillLab/commit/32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796","commit":{"message":"Generate the nav and search index\n\n5 courses, 80 lessons, 148 exercises, 105 questions, 25 coding problems,\n7 arena sittings.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:31Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:31Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"9cb4945aaa71b1a411e2e6023f4c4b8f6193edf5"}]},{"sha":"9cb4945aaa71b1a411e2e6023f4c4b8f6193edf5","html_url":"https://github.com/renrenmimi/DrillLab/commit/9cb4945aaa71b1a411e2e6023f4c4b8f6193edf5","commit":{"message":"Add the nav and search index generator\n\nWalks every course and writes content/nav.ts plus content/search-index.ts.\nGenerated rather than hand-maintained because the counts are asserted at\nbuild time, and a hand-edited index drifts within a week.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:31Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:31Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"7e80cef5133d4e31384d13106b0d110de6395fcf"}]},{"sha":"7e80cef5133d4e31384d13106b0d110de6395fcf","html_url":"https://github.com/renrenmimi/DrillLab/commit/7e80cef5133d4e31384d13106b0d110de6395fcf","commit":{"message":"Snapshot the reference projects\n\nThe three reference projects the coding and arena problems are built\naround, snapshotted so a problem page can show the exact file it is\ntalking about instead of pasting a copy into the lesson prose.\n\nRegenerate with npm run gen:src after editing them.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:31Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:31Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"df52aac130c4f990ac4c049cd143b1f9fc509577"}]},{"sha":"df52aac130c4f990ac4c049cd143b1f9fc509577","html_url":"https://github.com/renrenmimi/DrillLab/commit/df52aac130c4f990ac4c049cd143b1f9fc509577","commit":{"message":"Derive the arena track\n\n7 timed sittings: five from-scratch rebuilds and two full mocks in a\nchanged scenario. An empty folder, a clock, no hints, and the answer\nstays locked until you submit.\n\nDeliberately no runtime here. Adding one would remove the hardest and\nmost valuable tier, so instead the local instructions have to be exact:\ncopyable commands, the full file tree, and real measured numbers.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:16Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:16Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"c1a5960a1310d43452de62de0def43f682cafccc"}]},{"sha":"c1a5960a1310d43452de62de0def43f682cafccc","html_url":"https://github.com/renrenmimi/DrillLab/commit/c1a5960a1310d43452de62de0def43f682cafccc","commit":{"message":"Derive the coding track\n\n25 problems: statement, explanation, and the answer behind a door you\nhave to open on purpose. Each carries the files, dependencies and tests\nneeded to attempt it.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:16Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:16Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"938c1a230c0f297cd64ac6e277ae4076b56dba3b"}]},{"sha":"938c1a230c0f297cd64ac6e277ae4076b56dba3b","html_url":"https://github.com/renrenmimi/DrillLab/commit/938c1a230c0f297cd64ac6e277ae4076b56dba3b","commit":{"message":"Derive the drill track\n\n105 short questions, each pointing back at the lesson it came from\nrather than restating it. Two copies means two truths and the one you\nforget to update is the one people read.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:16Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:16Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"975736da17b08f903ce14bef0be52355467c9285"}]},{"sha":"975736da17b08f903ce14bef0be52355467c9285","html_url":"https://github.com/renrenmimi/DrillLab/commit/975736da17b08f903ce14bef0be52355467c9285","commit":{"message":"Wire the courses into a registry\n\nexam-list.ts holds only the array, with no query functions in it. That\nlooks redundant next to registry.ts, and it is not: the derived tracks\nneed to walk every course, registry re-exports their query functions,\nand ESM hoists export-from to the top of the module - so importing each\nother directly produced \"Cannot access EXAMS before initialization\".\nPure data in its own file makes the dependency one-way.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:16Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:16Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"13065d2b4740372ce18dcaaf29989192ec2b1de2"}]},{"sha":"13065d2b4740372ce18dcaaf29989192ec2b1de2","html_url":"https://github.com/renrenmimi/DrillLab/commit/13065d2b4740372ce18dcaaf29989192ec2b1de2","commit":{"message":"Add the Context course\n\nOne small multi-page app whose whole point is where the Provider goes,\nhow one action changes two pieces of state, and what happens when the\nconsumers are spread across three components.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:00Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:00Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"c4ecc9d9ab41ad352665019c5e2673e7491af010"}]},{"sha":"c4ecc9d9ab41ad352665019c5e2673e7491af010","html_url":"https://github.com/renrenmimi/DrillLab/commit/c4ecc9d9ab41ad352665019c5e2673e7491af010","commit":{"message":"Add the mixed interview course\n\nSeven parts: the basics people skip, two on JavaScript, two on React,\none on TypeScript, one on the back end, plus a hand-written round. This\nis where the short-answer bank mostly comes from.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:00Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:00Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"6c0cce9ba5675e41ac9b85eda3a6ee972fbac905"}]},{"sha":"6c0cce9ba5675e41ac9b85eda3a6ee972fbac905","html_url":"https://github.com/renrenmimi/DrillLab/commit/6c0cce9ba5675e41ac9b85eda3a6ee972fbac905","commit":{"message":"Add the GraphQL Federation course\n\nFour parts across a Node subgraph and a Spring Boot service: schema and\nentity resolution, resolvers and DataLoader batching, error handling and\ncorrelation IDs, and running the two services together.\n\nThis is the course where the material stops being front-end only - the\nJava side is a real Spring service, not a stub.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:00Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:00Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"65eebda2fc3e48e1d5f686157fbf965436c7d9f8"}]},{"sha":"65eebda2fc3e48e1d5f686157fbf965436c7d9f8","html_url":"https://github.com/renrenmimi/DrillLab/commit/65eebda2fc3e48e1d5f686157fbf965436c7d9f8","commit":{"message":"Add the React course\n\nFive parts built around a note manager: state and rendering, forms and\ncontrolled inputs, lists and identity, editing in place, and the tests\nthat pin the behaviour down. Ends in a from-scratch rebuild.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:26:00Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:26:00Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"efaf22d1b259e9d24a9e1f0fb437cc7e24d3c32a"}]},{"sha":"efaf22d1b259e9d24a9e1f0fb437cc7e24d3c32a","html_url":"https://github.com/renrenmimi/DrillLab/commit/efaf22d1b259e9d24a9e1f0fb437cc7e24d3c32a","commit":{"message":"Add the foundations course\n\nStarts from what npm install actually does and assumes nothing before\nthat. Fifteen lessons on the toolchain, modules, the JavaScript that\nReact needs, and the shape of a real project.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:25:44Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:25:44Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"83368188c466ba60e596382f99f4fd721b23df1b"}]},{"sha":"83368188c466ba60e596382f99f4fd721b23df1b","html_url":"https://github.com/renrenmimi/DrillLab/commit/83368188c466ba60e596382f99f4fd721b23df1b","commit":{"message":"Add the client-side state layer\n\nTheme, locale and progress, all in localStorage behind a context each.\n\nTwo notes worth keeping: the locale switch puts both languages in the\nHTML and hides one with CSS, because the title and meta description are\nserver-rendered and cannot follow a client-side language choice. And the\nprogress key keeps its -v1 suffix - new fields get fallbacks in load(),\nso only a change in what a field *means* needs a bump.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:25:44Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:25:44Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"5a9899e7c24949b68a80126ee93cf19dea6a770e"}]},{"sha":"5a9899e7c24949b68a80126ee93cf19dea6a770e","html_url":"https://github.com/renrenmimi/DrillLab/commit/5a9899e7c24949b68a80126ee93cf19dea6a770e","commit":{"message":"Define the content data model\n\nOne source of truth for every track. The types carry the invariants the\nbuild later asserts on: a question knows which exercise it came from, a\nself-authored question is flagged as such and has no source number, and\na coding problem records whether it can run in the browser.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:25:44Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:25:44Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"b9459afd3284cfd1901b2b6c944f7c8003d22a16"}]},{"sha":"b9459afd3284cfd1901b2b6c944f7c8003d22a16","html_url":"https://github.com/renrenmimi/DrillLab/commit/b9459afd3284cfd1901b2b6c944f7c8003d22a16","commit":{"message":"Add the design system\n\nA sage-and-paper palette rather than the usual blue-on-white: --paper is\nan off-white with a green cast, --accent is a muted sage. Every ink and\naccent value carries its measured contrast ratio against the surface it\nsits on, because several were tuned to clear 4.5:1 and would otherwise\nget \"simplified\" back under it.\n\nOne stylesheet per surface (shell, lesson, drill, coding, arena, code,\nexercise, viz) on top of tokens.css and base.css.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:25:30Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:25:30Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"a6adf308ba9cb31e1fe7e6da7098989c3ef7b138"}]},{"sha":"a6adf308ba9cb31e1fe7e6da7098989c3ef7b138","html_url":"https://github.com/renrenmimi/DrillLab/commit/a6adf308ba9cb31e1fe7e6da7098989c3ef7b138","commit":{"message":"Scaffold the Next.js project\n\nNext 15 App Router, React 19, TypeScript strict. No CSS framework - the\nstyling is hand-written CSS with design tokens, so there is no Tailwind\nconfig here.","author":{"name":"WEIREN FENG","date":"2026-08-21T08:25:30Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-21T08:25:30Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"6cc6ba658e798d9c34c960060c43d0a6c13e01d6"}]},{"sha":"6cc6ba658e798d9c34c960060c43d0a6c13e01d6","html_url":"https://github.com/renrenmimi/DrillLab/commit/6cc6ba658e798d9c34c960060c43d0a6c13e01d6","commit":{"message":"Add .gitignore","author":{"name":"Weiren Feng","date":"2026-08-21T08:25:21Z"},"committer":{"name":"Weiren Feng","date":"2026-08-21T08:25:21Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[]}],"commitDetail":{"6cc6ba658e798d9c34c960060c43d0a6c13e01d6":{"sha":"6cc6ba658e798d9c34c960060c43d0a6c13e01d6","stats":{"additions":9,"deletions":0,"total":9},"files":[{"filename":".gitignore","previous_filename":null,"status":"added","additions":9,"deletions":0,"changes":9,"patch":"@@ -0,0 +1,9 @@\n+node_modules/\n+.next/\n+out/\n+.DS_Store\n+*.tsbuildinfo\n+next-env.d.ts\n+# scratchpad / 本地验证脚本产物\n+shots/\n+*.log","blob_url":"https://github.com/renrenmimi/DrillLab/blob/6cc6ba658e798d9c34c960060c43d0a6c13e01d6/.gitignore"}]},"a6adf308ba9cb31e1fe7e6da7098989c3ef7b138":{"sha":"a6adf308ba9cb31e1fe7e6da7098989c3ef7b138","stats":{"additions":6214,"deletions":0,"total":6214},"files":[{"filename":".eslintrc.json","previous_filename":null,"status":"added","additions":3,"deletions":0,"changes":3,"patch":"@@ -0,0 +1,3 @@\n+{\n+ \"extends\": \"next/core-web-vitals\"\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/a6adf308ba9cb31e1fe7e6da7098989c3ef7b138/.eslintrc.json"},{"filename":".nvmrc","previous_filename":null,"status":"added","additions":1,"deletions":0,"changes":1,"patch":"@@ -0,0 +1 @@\n+22","blob_url":"https://github.com/renrenmimi/DrillLab/blob/a6adf308ba9cb31e1fe7e6da7098989c3ef7b138/.nvmrc"},{"filename":"next.config.mjs","previous_filename":null,"status":"added","additions":13,"deletions":0,"changes":13,"patch":"@@ -0,0 +1,13 @@\n+/** @type {import('next').NextConfig} */\n+const nextConfig = {\n+ reactStrictMode: true,\n+\n+ // /exams 的索引页已经并进 /path(「课程」)—— 两页原本在列同一批课程。\n+ // 保留一条重定向,老链接和书签不会断。\n+ // 注意 /exams/[examId] 不受影响,它是更深一层的动态路由。\n+ async redirects() {\n+ return [{ source: \"/exams\", destination: \"/path\", permanent: false }];\n+ },\n+};\n+\n+export default nextConfig;","blob_url":"https://github.com/renrenmimi/DrillLab/blob/a6adf308ba9cb31e1fe7e6da7098989c3ef7b138/next.config.mjs"},{"filename":"package-lock.json","previous_filename":null,"status":"added","additions":6148,"deletions":0,"changes":6148,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/a6adf308ba9cb31e1fe7e6da7098989c3ef7b138/package-lock.json"},{"filename":"package.json","previous_filename":null,"status":"added","additions":28,"deletions":0,"changes":28,"patch":"@@ -0,0 +1,28 @@\n+{\n+ \"name\": \"drill-lab\",\n+ \"version\": \"0.1.0\",\n+ \"private\": true,\n+ \"scripts\": {\n+ \"dev\": \"sh scripts/dev.sh\",\n+ \"build\": \"next build\",\n+ \"start\": \"next start\",\n+ \"lint\": \"next lint\",\n+ \"typecheck\": \"tsc --noEmit\",\n+ \"gen:nav\": \"node scripts/gen-nav.mjs\",\n+ \"gen:src\": \"node scripts/gen-source-files.mjs\"\n+ },\n+ \"dependencies\": {\n+ \"@codesandbox/sandpack-react\": \"^2.20.0\"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/a6adf308ba9cb31e1fe7e6da7098989c3ef7b138/package.json"},{"filename":"tsconfig.json","previous_filename":null,"status":"added","additions":21,"deletions":0,"changes":21,"patch":"@@ -0,0 +1,21 @@\n+{\n+ \"compilerOptions\": {\n+ \"target\": \"ES2020\",\n+ \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n+ \"allowJs\": false,\n+ \"skipLibCheck\": true,\n+ \"strict\": true,\n+ \"noEmit\": true,\n+ \"esModuleInterop\": true,\n+ \"module\": \"esnext\",\n+ \"moduleResolution\": \"bundler\",\n+ \"resolveJsonModule\": true,\n+ \"isolatedModules\": true,\n+ \"jsx\": \"preserve\",\n+ \"incremental\": true,\n+ \"plu","blob_url":"https://github.com/renrenmimi/DrillLab/blob/a6adf308ba9cb31e1fe7e6da7098989c3ef7b138/tsconfig.json"}]},"b9459afd3284cfd1901b2b6c944f7c8003d22a16":{"sha":"b9459afd3284cfd1901b2b6c944f7c8003d22a16","stats":{"additions":6669,"deletions":0,"total":6669},"files":[{"filename":"styles/arena.css","previous_filename":null,"status":"added","additions":505,"deletions":0,"changes":505,"patch":"@@ -0,0 +1,505 @@\n+/* ============================================================\n+ 考场 —— 阶段 C\n+ ------------------------------------------------------------\n+ 只有这一份是考场自己的样式。列表长条、标签、提示框、答案门、\n+ 文件树、终端全部复用现成的类(.exam-row / .tag / .callout /\n+ .minihead / .btn / .filetree / .term)。\n+ 颜色一律走 tokens.css 的变量,深色模式跟着一起翻。\n+ ============================================================ */\n+\n+/* ---------- 列表页 -----","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Farena.css"},{"filename":"styles/base.css","previous_filename":null,"status":"added","additions":343,"deletions":0,"changes":343,"patch":"@@ -0,0 +1,343 @@\n+/* ============================================================\n+ 基础与排版\n+ ============================================================ */\n+\n+*,\n+*::before,\n+*::after {\n+ box-sizing: border-box;\n+}\n+\n+html {\n+ -webkit-text-size-adjust: 100%;\n+ scroll-behavior: smooth;\n+ scroll-padding-top: calc(var(--topbar-h) + 16px);\n+}\n+\n+body {\n+ margin: 0;\n+ background: var(--paper);\n+ color: var(--i","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Fbase.css"},{"filename":"styles/code.css","previous_filename":null,"status":"added","additions":447,"deletions":0,"changes":447,"patch":"@@ -0,0 +1,447 @@\n+/* ============================================================\n+ 代码窗 / 终端命令 / 可编辑代码\n+ ------------------------------------------------------------\n+ 代码是本站的视觉主角:窗口宽、字号够大、语法色低饱和、\n+ 永远显示文件名与语言、真实来源单独标注。\n+ ============================================================ */\n+\n+.codewin {\n+ margin: 18px 0;\n+ border: 1px solid var(--rule-strong);\n+ border-radius: var(--r);\n+ overflow: hidden;\n+","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Fcode.css"},{"filename":"styles/coding.css","previous_filename":null,"status":"added","additions":322,"deletions":0,"changes":322,"patch":"@@ -0,0 +1,322 @@\n+/* ============================================================\n+ Coding 题:列表页 (/code) 与详情页 (/code/[id])\n+ ------------------------------------------------------------\n+ 【这个文件曾经是空的】原来只有一行注释,48 字节 ——\n+ coding-list.tsx 用的 .cd-list / .cd-row / .cd-row-main /\n+ .cd-row-title / .cd-row-meta,以及 .cd-badge / .cd-brief /\n+ .cd-fold / .cd-hidden,全站一条规则都没有。\n+ 于是 /code 的题目列表就是一个裸 <ol>:标题、四个标签、\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Fcoding.css"},{"filename":"styles/drill.css","previous_filename":null,"status":"added","additions":847,"deletions":0,"changes":847,"patch":"@@ -0,0 +1,847 @@\n+/* 八股题库与抽认卡 —— 阶段 B\n+ ------------------------------------------------------------\n+ 只写这一层新出现的东西:题卡、三档自评按钮、掌握情况那条、抽认卡。\n+ 按钮 / 徽章 / 提示框 / 筛选条 / 分页 全部复用 base.css、lesson.css、\n+ exercise.css 里已有的类。颜色一律走 tokens.css 的变量,\n+ 所以深色模式不用另写一份。 */\n+\n+/* ============================================================\n+ 顶部那条「你的掌握情况」\n+ ============================================================ */\n+\n+.d","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Fdrill.css"},{"filename":"styles/exercise.css","previous_filename":null,"status":"added","additions":889,"deletions":0,"changes":889,"patch":"@@ -0,0 +1,889 @@\n+/* ============================================================\n+ 练习:识别 / 排序 / 填空 / 写整块 / Debug / 从零重写\n+ ------------------------------------------------------------\n+ 练习是唯一「配得上一张卡片」的东西 —— 它是独立、可交互、\n+ 有状态的。所以这里用了框,别处不用。\n+ ============================================================ */\n+\n+.ex {\n+ margin: 26px 0;\n+ border: 1px solid var(--rule-strong);\n+ border-radius: var(--r-lg);\n+ ba","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Fexercise.css"},{"filename":"styles/lesson.css","previous_filename":null,"status":"added","additions":1261,"deletions":0,"changes":1261,"patch":"@@ -0,0 +1,1261 @@\n+/* ============================================================\n+ 课程页面原语:课头 / 学习目标 / 编号段 / 提示框 / 文件树 /\n+ 要点 / 页脚 / 首页与列表页\n+ ------------------------------------------------------------\n+ 层级靠留白 + 字号 + 细线 + 左侧标记,不靠一层套一层的圆角卡片。\n+ ============================================================ */\n+\n+/* ---------- 课头 ---------- */\n+\n+.lesson-head {\n+ padding-bottom: 22px;\n+ margin-bottom: 30px;","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Flesson.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"added","additions":1665,"deletions":0,"changes":1665,"patch":"@@ -0,0 +1,1665 @@\n+/* ============================================================\n+ 外壳:顶栏 / 左侧课程导航 / 主内容 / 右侧目录栏\n+ ------------------------------------------------------------\n+ 不做厚重三栏后台:两侧都是「无框」的,只用一条细线和留白分区。\n+ ============================================================ */\n+\n+.shell {\n+ display: grid;\n+ grid-template-columns: var(--sidebar-w) minmax(0, 1fr);\n+ min-height: 100vh;\n+}\n+\n+/* 首页 / 学习路径 / 练习","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Fshell.css"},{"filename":"styles/tokens.css","previous_filename":null,"status":"added","additions":147,"deletions":0,"changes":147,"patch":"@@ -0,0 +1,147 @@\n+/* ============================================================\n+ DrillLab · Design Tokens\n+ ------------------------------------------------------------\n+ 方向:纸感、克制、久读不累。\n+ 明确不做:紫蓝渐变 / 玻璃拟态 / 霓虹描边 / 发光阴影 /\n+ backdrop-filter / 渐变文字 / 大面积纯白。\n+ 层级只靠四件事:留白、字号、极浅的背景差、一像素细线。\n+ ============================================================ */\n+\n+:root,\n+[data-theme=\"light\"] {\n+ color","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Ftokens.css"},{"filename":"styles/viz.css","previous_filename":null,"status":"added","additions":243,"deletions":0,"changes":243,"patch":"@@ -0,0 +1,243 @@\n+/* ============================================================\n+ 数据流图 / 请求流图\n+ ------------------------------------------------------------\n+ 节点 + SVG 连线 + 逐帧高亮。不用插画,不用漂浮圆球。\n+ ============================================================ */\n+\n+.viz {\n+ margin: 20px 0;\n+ border: 1px solid var(--rule-strong);\n+ border-radius: var(--r);\n+ background: var(--raised);\n+ overflow: hidden;\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b9459afd3284cfd1901b2b6c944f7c8003d22a16/styles%2Fviz.css"}]},"5a9899e7c24949b68a80126ee93cf19dea6a770e":{"sha":"5a9899e7c24949b68a80126ee93cf19dea6a770e","stats":{"additions":494,"deletions":0,"total":494},"files":[{"filename":"content/helpers.tsx","previous_filename":null,"status":"added","additions":40,"deletions":0,"changes":40,"patch":"@@ -0,0 +1,40 @@\n+// 内容文件的小工具 —— 只为了让课程数据写起来干净,不含任何 UI。\n+//\n+// real(...) 这段代码原样来自源项目 / 已在源项目里真实跑通 → 页面显示「已验证」\n+// demo(...) 教学示意、故意写错的反例、DrillLab 自出的题 → 页面显示「示意」\n+//\n+// 规矩:凡是标 real 的,必须能在 sourceFile 指向的真实文件里找到,\n+// 或者是我在审计阶段真实跑通过的代码。不许拿没跑过的东西冒充。\n+\n+import type { CodeExample, CodeLang } from \"./types\";\n+\n+interface Opts {\n+ filename?: string;\n+ sourceFile?: string;\n+ highlight?: number[];\n+ explanation?: React","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5a9899e7c24949b68a80126ee93cf19dea6a770e/content%2Fhelpers.tsx"},{"filename":"content/types.ts","previous_filename":null,"status":"added","additions":454,"deletions":0,"changes":454,"patch":"@@ -0,0 +1,454 @@\n+// DrillLab 的内容数据模型 —— 全站唯一的事实来源。\n+//\n+// 四层:Exam → Module → Lesson → Exercise。\n+// 新增一门考试 = 在 content/exams/ 下加一个文件 + 在 registry.ts 里 import,\n+// 不需要碰任何页面、导航或组件。路由是 /exams/[examId]/[lessonId],\n+// 侧栏、学习路径、练习场、模拟考的列表全部从注册表推导出来。\n+//\n+// 硬规矩:凡是 verified: false 的代码块,页面上必须显示「未在源项目中验证」标记;\n+// 凡是 DrillLab 自己出的题(模拟题),必须带 generated: true。\n+\n+import type { ReactNode } from \"react\";\n+\n+/* ===================","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5a9899e7c24949b68a80126ee93cf19dea6a770e/content%2Ftypes.ts"}]},"83368188c466ba60e596382f99f4fd721b23df1b":{"sha":"83368188c466ba60e596382f99f4fd721b23df1b","stats":{"additions":964,"deletions":0,"total":964},"files":[{"filename":"lib/exercise-labels.ts","previous_filename":null,"status":"added","additions":41,"deletions":0,"changes":41,"patch":"@@ -0,0 +1,41 @@\n+// 练习的「难度」和「题型」两套标签 —— 全站唯一一份。\n+//\n+// 【为什么要拆成两套】\n+// 老代码把难度徽章写成 `L2 · 填空 / Fill the blanks`,可 L2 里既有填空\n+// 也有 Debug Lab —— 于是一道 Debug Lab 的题头上明晃晃写着「填空」。\n+// L1 同理(recognition 和 ordering 都在 L1)。\n+// 难度是难度(给你多少东西),题型是题型(怎么问),不是一回事。\n+//\n+// 【为什么要共享】\n+// 题型标签原来在 components/exercise.tsx 和 components/practice-page.tsx\n+// 各写了一份,改一边忘一边就会出现「筛选器叫「写整块」、题头叫别的」。\n+// 这里是唯一真相,两边都从这儿拿。\n+//\n+// 纯数据,没有 JSX,服务端组件和客户","blob_url":"https://github.com/renrenmimi/DrillLab/blob/83368188c466ba60e596382f99f4fd721b23df1b/lib%2Fexercise-labels.ts"},{"filename":"lib/highlight.ts","previous_filename":null,"status":"added","additions":305,"deletions":0,"changes":305,"patch":"@@ -0,0 +1,305 @@\n+// 零依赖语法高亮器。\n+//\n+// 思路(沿用并扩展了 Vide/ts 的做法):一个主正则扫全文,把源码切成\n+// 「注释 | 字符串 | 数字 | 标识符 | 运算符」五类 token,标识符再按\n+// 「关键字 / 类型名 / 函数调用 / 装饰器」二次分类;最后切成「行 × token」\n+// 的二维数组,交给 CodeBlock 渲染行号与行高亮。\n+//\n+// 相比参考项目新增:tsx/jsx 的 JSX 标签与属性、graphql(含 federation\n+// directive)、java(含注解)、properties。颜色一律低饱和,见 styles/code.css。\n+\n+export type Lang =\n+ | \"tsx\"\n+ | \"ts\"\n+ | \"js\"\n+ | \"jsx\"\n+ | \"graphql\"\n+ | \"java\"\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/83368188c466ba60e596382f99f4fd721b23df1b/lib%2Fhighlight.ts"},{"filename":"lib/locale.tsx","previous_filename":null,"status":"added","additions":95,"deletions":0,"changes":95,"patch":"@@ -0,0 +1,95 @@\n+\"use client\";\n+\n+// 语言(中文 / English)—— 默认中文,选择存 localStorage。\n+//\n+// 【为什么不用 /en/... 这种路由前缀,也不用 cookie】\n+// 课程正文是在服务端渲染的(见 README 里那段 784 KB 的教训)。\n+// 如果语言是客户端状态,服务端就不知道该渲染哪一版;如果做成路由前缀,\n+// 82 个静态页要变成 164 个,而且每次切换都要走一次导航。\n+//\n+// 所以用和 data-theme 完全一样的套路:\n+// · 两种语言**同时渲染**进 HTML,各自包一层 data-lang\n+// · CSS 按 html[lang] 把另一边 display: none 掉\n+// · 切换只改 <html lang>,零延迟、零请求、不用 JS 重渲染\n+//\n+// 代价是 HTM","blob_url":"https://github.com/renrenmimi/DrillLab/blob/83368188c466ba60e596382f99f4fd721b23df1b/lib%2Flocale.tsx"},{"filename":"lib/progress.tsx","previous_filename":null,"status":"added","additions":416,"deletions":0,"changes":416,"patch":"@@ -0,0 +1,416 @@\n+\"use client\";\n+\n+// 学习进度 —— localStorage,一个 Context 供全站共用。\n+//\n+// 存的都是「用户真的做过什么」,不做 XP、不做连续打卡、不做徽章:\n+// lessons 读完并勾了「学完这节」的课 键 `${examId}/${lessonId}`\n+// exercises 做对过的练习 键 `${examId}/${exerciseId}`\n+// rebuilds 完成过的从零重写 键 `${examId}/${exerciseId}`\n+// mocks 模拟考记录 键 `${examId}/${mockId}`\n+// last 最近学习位置(首页「继续」","blob_url":"https://github.com/renrenmimi/DrillLab/blob/83368188c466ba60e596382f99f4fd721b23df1b/lib%2Fprogress.tsx"},{"filename":"lib/theme.tsx","previous_filename":null,"status":"added","additions":59,"deletions":0,"changes":59,"patch":"@@ -0,0 +1,59 @@\n+\"use client\";\n+\n+// 主题(浅色 / 深色)—— 没选过就跟随系统,选过就记住。\n+// 首屏闪烁由 layout.tsx 里那段 beforeInteractive 脚本消掉。\n+//\n+// 【为什么不是「默认浅色」】老版本无视 prefers-color-scheme,\n+// 系统开着深色的人打开这个站会被一整屏米白闪一下。\n+// 现在:localStorage 里有值就用它(用户的明确选择优先),\n+// 没有就问系统。用户点过切换之后才写 localStorage,所以\n+// 「从没点过」和「点回了浅色」是两种状态,不会混。\n+\n+import {\n+ createContext,\n+ useCallback,\n+ useContext,\n+ useEffect,\n+ useState,\n+ type ReactNode,\n+} from \"rea","blob_url":"https://github.com/renrenmimi/DrillLab/blob/83368188c466ba60e596382f99f4fd721b23df1b/lib%2Ftheme.tsx"},{"filename":"lib/use-active-heading.ts","previous_filename":null,"status":"added","additions":48,"deletions":0,"changes":48,"patch":"@@ -0,0 +1,48 @@\n+\"use client\";\n+\n+// 右侧目录的 scroll-spy:观察一组锚点元素,返回「当前在看哪一段」的 id。\n+//\n+// 做法:IntersectionObserver 只负责记录每个段落的可见状态,\n+// 真正的判断是「视口上方最近的那个段落」—— 这比「第一个可见的元素」更符合直觉,\n+// 因为长段落滚过一半时,用户心里还在那一段。\n+\n+import { useEffect, useMemo, useState } from \"react\";\n+\n+export function useActiveHeading(ids: string[]): string | undefined {\n+ const [active, setActive] = useState<string | undefined>(ids[0]);\n+ // 调用方每次渲染都会传一个新数组","blob_url":"https://github.com/renrenmimi/DrillLab/blob/83368188c466ba60e596382f99f4fd721b23df1b/lib%2Fuse-active-heading.ts"}]},"efaf22d1b259e9d24a9e1f0fb437cc7e24d3c32a":{"sha":"efaf22d1b259e9d24a9e1f0fb437cc7e24d3c32a","stats":{"additions":3880,"deletions":0,"total":3880},"files":[{"filename":"content/exams/foundations.tsx","previous_filename":null,"status":"added","additions":3880,"deletions":0,"changes":3880,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/efaf22d1b259e9d24a9e1f0fb437cc7e24d3c32a/content%2Fexams%2Ffoundations.tsx"}]},"65eebda2fc3e48e1d5f686157fbf965436c7d9f8":{"sha":"65eebda2fc3e48e1d5f686157fbf965436c7d9f8","stats":{"additions":15090,"deletions":0,"total":15090},"files":[{"filename":"content/exams/react-part1.tsx","previous_filename":null,"status":"added","additions":1372,"deletions":0,"changes":1372,"patch":"@@ -0,0 +1,1372 @@\n+// React 考试 —— 模块 1 与 2:React 心智模型、Hooks 与数据流。\n+// 全部围绕 react-notes-app 的真实文件展开。\n+//\n+// 拆成 part1 / part2 / part3 三个文件只是为了单个文件不至于太长,\n+// 它们最后在 react.tsx 里被组装成同一个 Exam。\n+\n+import { DataFlowDiagram } from \"@/components/data-flow\";\n+import type { Module } from \"../types\";\n+import { demo, real } from \"../helpers\";\n+\n+/** React 的数据流:一次点击如何变成新界面 */\n+const RENDER_FLOW = (\n+ <DataFlowDiagram\n+ title=\"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/65eebda2fc3e48e1d5f686157fbf965436c7d9f8/content%2Fexams%2Freact-part1.tsx"},{"filename":"content/exams/react-part2.tsx","previous_filename":null,"status":"added","additions":1842,"deletions":0,"changes":1842,"patch":"@@ -0,0 +1,1842 @@\n+// React 考试 —— 模块 2 后半(受控输入、列表与 key)与模块 3(Hooks 与数据流)。\n+\n+import type { Module } from \"../types\";\n+import { demo, real } from \"../helpers\";\n+\n+const NOTE_FORM_FULL = `import React, { useState, useEffect } from \"react\";\n+import type { Note } from \"../../types/Note\";\n+\n+interface NoteFormProps {\n+ onSubmit: (note: Note) => void;\n+ noteToEdit: Note | null;\n+}\n+\n+const NoteForm: React.FC<NoteFormPro","blob_url":"https://github.com/renrenmimi/DrillLab/blob/65eebda2fc3e48e1d5f686157fbf965436c7d9f8/content%2Fexams%2Freact-part2.tsx"},{"filename":"content/exams/react-part3.tsx","previous_filename":null,"status":"added","additions":3348,"deletions":0,"changes":3348,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/65eebda2fc3e48e1d5f686157fbf965436c7d9f8/content%2Fexams%2Freact-part3.tsx"},{"filename":"content/exams/react-part4.tsx","previous_filename":null,"status":"added","additions":2711,"deletions":0,"changes":2711,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/65eebda2fc3e48e1d5f686157fbf965436c7d9f8/content%2Fexams%2Freact-part4.tsx"},{"filename":"content/exams/react-part5.tsx","previous_filename":null,"status":"added","additions":4913,"deletions":0,"changes":4913,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/65eebda2fc3e48e1d5f686157fbf965436c7d9f8/content%2Fexams%2Freact-part5.tsx"},{"filename":"content/exams/react.tsx","previous_filename":null,"status":"added","additions":904,"deletions":0,"changes":904,"patch":"@@ -0,0 +1,904 @@\n+// React Capstone —— 基于 react-notes-app。\n+//\n+// 模块拆在 react-part1..4.tsx 里,这个文件负责组装 + 模拟考。\n+\n+import type { Exam, MockExam } from \"../types\";\n+import { demo, tested } from \"../helpers\";\n+import { reactMentalModel } from \"./react-part1\";\n+import { reactHooks } from \"./react-part2\";\n+import { reactQ1 } from \"./react-part3\";\n+import { reactMastery, reactQ2 } from \"./react-part4\";\n+import { reactVarian","blob_url":"https://github.com/renrenmimi/DrillLab/blob/65eebda2fc3e48e1d5f686157fbf965436c7d9f8/content%2Fexams%2Freact.tsx"}]},"6c0cce9ba5675e41ac9b85eda3a6ee972fbac905":{"sha":"6c0cce9ba5675e41ac9b85eda3a6ee972fbac905","stats":{"additions":14521,"deletions":0,"total":14521},"files":[{"filename":"content/exams/fed-part1.tsx","previous_filename":null,"status":"added","additions":4211,"deletions":0,"changes":4211,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/6c0cce9ba5675e41ac9b85eda3a6ee972fbac905/content%2Fexams%2Ffed-part1.tsx"},{"filename":"content/exams/fed-part2.tsx","previous_filename":null,"status":"added","additions":3771,"deletions":0,"changes":3771,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/6c0cce9ba5675e41ac9b85eda3a6ee972fbac905/content%2Fexams%2Ffed-part2.tsx"},{"filename":"content/exams/fed-part3.tsx","previous_filename":null,"status":"added","additions":3365,"deletions":0,"changes":3365,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/6c0cce9ba5675e41ac9b85eda3a6ee972fbac905/content%2Fexams%2Ffed-part3.tsx"},{"filename":"content/exams/fed-part4.tsx","previous_filename":null,"status":"added","additions":1799,"deletions":0,"changes":1799,"patch":"@@ -0,0 +1,1799 @@\n+// Federation 考试 —— 模块 6:综合 Debug Lab + 从零重写。\n+\n+import type { Module } from \"../types\";\n+import { demo, real } from \"../helpers\";\n+\n+const FULL_RESOLVERS = `import DataLoader from 'dataloader';\n+import { GraphQLError } from 'graphql';\n+\n+const ErrorCodes = {\n+ ORDER_NOT_FOUND: 'ORDER_NOT_FOUND',\n+ INVALID_INPUT: 'INVALID_INPUT',\n+ INVENTORY_ERROR: 'INVENTORY_ERROR',\n+ SERVICE_ERROR: 'SERVICE_","blob_url":"https://github.com/renrenmimi/DrillLab/blob/6c0cce9ba5675e41ac9b85eda3a6ee972fbac905/content%2Fexams%2Ffed-part4.tsx"},{"filename":"content/exams/graphql-federation.tsx","previous_filename":null,"status":"added","additions":1375,"deletions":0,"changes":1375,"patch":"@@ -0,0 +1,1375 @@\n+// GraphQL Federation Capstone\n+// —— 基于 graphql-federation-practice。\n+//\n+// 模块拆在 fed-part1..4.tsx 里,这个文件负责组装 + 模拟考。\n+\n+import type { Exam, MockExam } from \"../types\";\n+import { demo, tested } from \"../helpers\";\n+import { fedMentalModel, gqlBasics } from \"./fed-part1\";\n+import { fedTask1 } from \"./fed-part2\";\n+import { fedTask2, fedWritten } from \"./fed-part3\";\n+import { fedMastery } from \"./fed-","blob_url":"https://github.com/renrenmimi/DrillLab/blob/6c0cce9ba5675e41ac9b85eda3a6ee972fbac905/content%2Fexams%2Fgraphql-federation.tsx"}]},"c4ecc9d9ab41ad352665019c5e2673e7491af010":{"sha":"c4ecc9d9ab41ad352665019c5e2673e7491af010","stats":{"additions":20704,"deletions":0,"total":20704},"files":[{"filename":"content/exams/interview.tsx","previous_filename":null,"status":"added","additions":102,"deletions":0,"changes":102,"patch":"@@ -0,0 +1,102 @@\n+// 面试八股 —— 第四门课。\n+//\n+// 【它和另外三门不一样,这一点写清楚很重要】\n+//\n+// 另外三门对应真实的 综合项目 项目,所以代码块能标「源项目」。\n+// 这一门的题目来自作者做过的前端面试题(99 道问答 + 16 道 coding),\n+// 编号沿用题库里的 #269 ~ #387。答案是 DrillLab 写的,不来自任何源项目,\n+// 所以:\n+// · 讲解里的代码块一律 demo()(页面显示「示意」)\n+// · 练习一律带 generated: true\n+// · coding 题里补进来的参考解法会先在本机跑通再标「已跑通」\n+//\n+// 每道题的固定格式:\n+// heading 中文问题\n+// lede 英文原题 + 题库编号(面试官会照这个念)\n+// body 一句话 → 展开 → 会追问","blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Finterview.tsx"},{"filename":"content/exams/iv-backend.tsx","previous_filename":null,"status":"added","additions":2514,"deletions":0,"changes":2514,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-backend.tsx"},{"filename":"content/exams/iv-basics.tsx","previous_filename":null,"status":"added","additions":1533,"deletions":0,"changes":1533,"patch":"@@ -0,0 +1,1533 @@\n+// 面试八股 —— HTML / CSS。\n+//\n+// 题目来自作者做过的题目(编号沿用原编号 #269 等),答案由 DrillLab 撰写。\n+// 这一门和另外三门不一样:它不对应任何源项目,所以代码块一律 demo()(「示意」)。\n+//\n+// 每道题的固定格式:\n+// heading 中文问题\n+// lede 英文原题 + 题库编号(面试官会照这个念)\n+// body 一句话 → 展开 → 会追问什么\n+\n+import type { Module } from \"../types\";\n+import { demo } from \"../helpers\";\n+\n+export const ivBasics: Module = {\n+ id: \"iv-basics\",\n+ stage: \"面试 · 第 1 部分\",\n+ title","blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-basics.tsx"},{"filename":"content/exams/iv-coding.tsx","previous_filename":null,"status":"added","additions":3523,"deletions":0,"changes":3523,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-coding.tsx"},{"filename":"content/exams/iv-hand.tsx","previous_filename":null,"status":"added","additions":1352,"deletions":0,"changes":1352,"patch":"@@ -0,0 +1,1352 @@\n+// 面试 · 第 8 部分 —— 手写题。\n+//\n+// 【为什么有这个模块】\n+// 美国面试第一轮(phone screen)最常见的形态就是手写工具函数:\n+// debounce、Promise.all、EventEmitter 这类。第 7 部分对照过题库里的\n+// 16 道 coding 题,但那份题库没有覆盖这一类 —— 所以这 8 道全部是\n+// **DrillLab 自出**,不带题库编号。\n+//\n+// 【可信度】\n+// 8 道参考解法和测试都在 scratchpad 用 vitest 真跑过:40 / 40。\n+// 起始态也跑过:33 failed / 7 passed —— 那 7 个是「半成品恰好满足断言」\n+// 的假通过(比如 flatten 的浅拷贝恰好过了 depth 0 那条),故意留着,\n+// 是主线①「测试通过 ≠ 做对了」的又一批活","blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-hand.tsx"},{"filename":"content/exams/iv-js1.tsx","previous_filename":null,"status":"added","additions":2822,"deletions":0,"changes":2822,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-js1.tsx"},{"filename":"content/exams/iv-js2.tsx","previous_filename":null,"status":"added","additions":2553,"deletions":0,"changes":2553,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-js2.tsx"},{"filename":"content/exams/iv-react1.tsx","previous_filename":null,"status":"added","additions":2489,"deletions":0,"changes":2489,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-react1.tsx"},{"filename":"content/exams/iv-react2.tsx","previous_filename":null,"status":"added","additions":2759,"deletions":0,"changes":2759,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-react2.tsx"},{"filename":"content/exams/iv-ts.tsx","previous_filename":null,"status":"added","additions":1057,"deletions":0,"changes":1057,"patch":"@@ -0,0 +1,1057 @@\n+// 面试八股 —— TypeScript 深度:utility types 与泛型。\n+//\n+// 【这个模块的题不在原题库里】\n+// 前面几个模块的题目来自用户提供的题库(#269 ~ #387);这 6 道(ts1 ~ ts6)\n+// 是 DrillLab 自出的 senior 级补充,所以 lede 里没有题库编号。\n+// 规矩不变:答案是 DrillLab 写的,代码块一律 demo()(「示意」),\n+// 练习一律 generated: true。\n+// 全部代码片段在 scratchpad 里用本项目的 tsc(strict)逐个编译验证过,\n+// 引用的报错文本(TS2536 / TS18046 / TS2353 等)也都是实际编译得到的原文;\n+// 但它们不来自任何源项目,所以按规矩不标「已跑通」。\n+\n+import type { Module } fr","blob_url":"https://github.com/renrenmimi/DrillLab/blob/c4ecc9d9ab41ad352665019c5e2673e7491af010/content%2Fexams%2Fiv-ts.tsx"}]},"13065d2b4740372ce18dcaaf29989192ec2b1de2":{"sha":"13065d2b4740372ce18dcaaf29989192ec2b1de2","stats":{"additions":5341,"deletions":0,"total":5341},"files":[{"filename":"content/exams/cab-booking.tsx","previous_filename":null,"status":"added","additions":5341,"deletions":0,"changes":5341,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/13065d2b4740372ce18dcaaf29989192ec2b1de2/content%2Fexams%2Fcab-booking.tsx"}]},"975736da17b08f903ce14bef0be52355467c9285":{"sha":"975736da17b08f903ce14bef0be52355467c9285","stats":{"additions":160,"deletions":0,"total":160},"files":[{"filename":"content/exam-list.ts","previous_filename":null,"status":"added","additions":28,"deletions":0,"changes":28,"patch":"@@ -0,0 +1,28 @@\n+// 考试清单 —— 只有这一个数组,故意不放任何查询函数。\n+//\n+// 【为什么要单独一个文件】\n+// content/coding.ts 和 content/arena.ts 需要遍历全部考试来派生题目,\n+// 而 content/registry.ts 又要 re-export 它们的查询函数。\n+// 如果它们互相 import 就成了环 —— 而 ESM 会把 `export … from` 提升到模块顶部,\n+// 于是 coding.ts 会在 registry 的 EXAMS 初始化之前执行,\n+// 报 `Cannot access 'EXAMS' before initialization`(踩过)。\n+//\n+// 把纯数据抽到这里,依赖方向就变成单向:\n+// exam-list ← registry\n+// exam-list ← drills / c","blob_url":"https://github.com/renrenmimi/DrillLab/blob/975736da17b08f903ce14bef0be52355467c9285/content%2Fexam-list.ts"},{"filename":"content/registry.ts","previous_filename":null,"status":"added","additions":132,"deletions":0,"changes":132,"patch":"@@ -0,0 +1,132 @@\n+// 考试注册表 —— 全站唯一的清单。\n+//\n+// 加一门新考试:\n+// 1. 在 content/exams/ 下写 <id>.tsx,default export 一个 Exam\n+// 2. 在下面的 EXAMS 数组里 import 进来\n+// 导航、学习路径、练习场、模拟考页面都会自动带上它,不需要改任何组件。\n+\n+import type { Exam, Exercise, Lesson, Module } from \"./types\";\n+\n+// 清单本身在 exam-list.ts —— 见那个文件顶部关于循环依赖的说明\n+export { EXAMS } from \"./exam-list\";\n+import { EXAMS } from \"./exam-list\";\n+\n+/* ---------- 基础查找 ---------- */\n+\n+expo","blob_url":"https://github.com/renrenmimi/DrillLab/blob/975736da17b08f903ce14bef0be52355467c9285/content%2Fregistry.ts"}]},"938c1a230c0f297cd64ac6e277ae4076b56dba3b":{"sha":"938c1a230c0f297cd64ac6e277ae4076b56dba3b","stats":{"additions":200,"deletions":0,"total":200},"files":[{"filename":"content/drills.ts","previous_filename":null,"status":"added","additions":200,"deletions":0,"changes":200,"patch":"@@ -0,0 +1,200 @@\n+// 八股题库 —— 从 interview 那门课**派生**出 99 道题。\n+//\n+// 【为什么是派生而不是另存一份】\n+// 每道八股题在 content/exams/iv-*.tsx 里已经是题目粒度了:\n+// 一个 ConceptSection = 一道题,heading 是中文问题,lede 是「#编号 英文原题」。\n+// 缺的只是 UI 层没有「题目」这个实体。所以这里只做一次转换,\n+// answer 直接引用原来的 body(同一个 ReactNode 引用,不是拷贝)。\n+//\n+// 抄一份出来就会有两份真相,改了一边忘了另一边 —— 那是最难查的一类问题。\n+//\n+// 【只在服务端 import】\n+// 这个文件会拖进整棵内容树。客户端组件要题目清单请读 content/nav.ts。\n+\n+import type { DrillQuestion, ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/938c1a230c0f297cd64ac6e277ae4076b56dba3b/content%2Fdrills.ts"}]},"c1a5960a1310d43452de62de0def43f682cafccc":{"sha":"c1a5960a1310d43452de62de0def43f682cafccc","stats":{"additions":1196,"deletions":0,"total":1196},"files":[{"filename":"content/coding.ts","previous_filename":null,"status":"added","additions":1196,"deletions":0,"changes":1196,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/c1a5960a1310d43452de62de0def43f682cafccc/content%2Fcoding.ts"}]},"df52aac130c4f990ac4c049cd143b1f9fc509577":{"sha":"df52aac130c4f990ac4c049cd143b1f9fc509577","stats":{"additions":191,"deletions":0,"total":191},"files":[{"filename":"content/arena.ts","previous_filename":null,"status":"added","additions":191,"deletions":0,"changes":191,"patch":"@@ -0,0 +1,191 @@\n+// 考场 —— 计时、无提示、答案锁死的从零重写。\n+//\n+// 【这一层为什么必须存在】\n+// 本项目自己定的验收标准是「在空文件夹里、没有答案的情况下写出来」。\n+// /drill 和 /code 都练不到这个:沙箱是带脚手架的 —— 文件建好了、依赖装好了、\n+// 测试写好了、点一下就跑。真实考试是空文件夹、自己配环境、自己读 schema。\n+//\n+// **沙箱跑绿 ≠ 能在空文件夹里做出来。**\n+// 这和全站主线「测试通过 ≠ 做对了」是同一类陷阱,考场就是用来堵它的。\n+//\n+// 【全部派生,不新写题】\n+// 6 道题的来源:\n+// · 4 个现成的 from-scratch 练习(已经有 requirements / fileList /\n+// commands / 四级提示 / 门后答案)\n+// · 2 套模拟考(提示从它的 wa","blob_url":"https://github.com/renrenmimi/DrillLab/blob/df52aac130c4f990ac4c049cd143b1f9fc509577/content%2Farena.ts"}]},"7e80cef5133d4e31384d13106b0d110de6395fcf":{"sha":"7e80cef5133d4e31384d13106b0d110de6395fcf","stats":{"additions":441,"deletions":0,"total":441},"files":[{"filename":"content/source-files.ts","previous_filename":null,"status":"added","additions":288,"deletions":0,"changes":288,"patch":"@@ -0,0 +1,288 @@\n+// 这个文件是生成物,不要手改。生成器:scripts/gen-source-files.mjs\n+// 重新生成:npm run gen:src\n+//\n+// 【它是什么】\n+// 课文里 sourceFiles 声明的那些真实文件的**原文快照**。页面上的\n+// 「这节课要看的真实文件」每一行都能展开看到文件内容 ——\n+// 课文说「这个 package.json 只有三个 script」,你能当场展开核对。\n+//\n+// 【它不包含什么】\n+// 「从零重写」练习里那些要你自己建的文件(package.json、src/App.tsx 这种\n+// 裸相对路径)。它们在源项目磁盘上恰好是做完的版本,收进来就是泄答案。\n+// 生成器只收「第一段是三个源项目之一」的路径。\n+//\n+// 【目录也能收】路径以 / 结尾、或者是 exam 总览页那种项目根绝对路径的,\n+// ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/7e80cef5133d4e31384d13106b0d110de6395fcf/content%2Fsource-files.ts"},{"filename":"scripts/gen-source-files.mjs","previous_filename":null,"status":"added","additions":153,"deletions":0,"changes":153,"patch":"@@ -0,0 +1,153 @@\n+// 从三个源项目里抓出课文引用到的文件原文,生成 content/source-files.ts。\n+//\n+// 【为什么要有这个】\n+// 课文里到处写着「react-notes-app 的 package.json 只有 dev / build / q2 三个 script」,\n+// 但那个文件从来没在页面上出现过 —— 读者只能选择相信。用户的原话:\n+// 「你是不是先得展示一下这个 package.json 原文是什么呀?我就很懵逼啊。」\n+// 实测全站 58 节有 sourceFiles 的课里,40 节至少有一个文件从没展示过内容。\n+//\n+// 【为什么是生成而不是手抄】\n+// 抄一份进课文就有两份真相,源项目一改就对不上。这里在构建时从磁盘读原文,\n+// 和 gen-nav 一个思路:页面上显示的永远是真实文件。\n+//\n+// 【绝对不能收进来的东西】\n+/","blob_url":"https://github.com/renrenmimi/DrillLab/blob/7e80cef5133d4e31384d13106b0d110de6395fcf/scripts%2Fgen-source-files.mjs"}]},"9cb4945aaa71b1a411e2e6023f4c4b8f6193edf5":{"sha":"9cb4945aaa71b1a411e2e6023f4c4b8f6193edf5","stats":{"additions":79,"deletions":0,"total":79},"files":[{"filename":"scripts/gen-nav.mjs","previous_filename":null,"status":"added","additions":79,"deletions":0,"changes":79,"patch":"@@ -0,0 +1,79 @@\n+// 从真实内容重新生成 content/nav.ts。\n+// 做法:临时起一个 next dev,打一个只在生成时存在的 route,取 JSON,再写文件。\n+// 这样 nav.ts 永远是内容的派生物,不会和内容脱节。\n+//\n+// npm run gen:nav\n+import { spawn } from \"child_process\";\n+import { writeFileSync, readFileSync, mkdirSync, rmSync } from \"fs\";\n+\n+const PORT = 3492;\n+const ROUTE_DIR = \"app/nav-dump\";\n+\n+const routeSrc = readFileSync(\"scripts/nav-dump-route.txt\", \"utf8\");\n+mkdirSync(ROUTE_DIR, ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/9cb4945aaa71b1a411e2e6023f4c4b8f6193edf5/scripts%2Fgen-nav.mjs"}]},"32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796":{"sha":"32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796","stats":{"additions":6529,"deletions":0,"total":6529},"files":[{"filename":"content/nav.ts","previous_filename":null,"status":"added","additions":3007,"deletions":0,"changes":3007,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796/content%2Fnav.ts"},{"filename":"content/search-index.ts","previous_filename":null,"status":"added","additions":3522,"deletions":0,"changes":3522,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796/content%2Fsearch-index.ts"}]},"cf70c0a64538af1820b32f36e093358bc4d86750":{"sha":"cf70c0a64538af1820b32f36e093358bc4d86750","stats":{"additions":447,"deletions":0,"total":447},"files":[{"filename":"scripts/dev.sh","previous_filename":null,"status":"added","additions":53,"deletions":0,"changes":53,"patch":"@@ -0,0 +1,53 @@\n+#!/bin/sh\n+# 启动 dev server,并保证用的是一个 Next.js 能接受的 Node。\n+#\n+# 【为什么需要这个包一层】\n+# 这台机器的 PATH 里同时挂着五个 nvm 版本,而 v16.20.2 排在最前面:\n+# ~/.nvm/versions/node/v16.20.2/bin ← which node 命中这个\n+# ~/.nvm/versions/node/v18.20.7/bin\n+# ~/.nvm/versions/node/v20.18.3/bin\n+# ~/.nvm/versions/node/v22.14.0/bin\n+# ~/.nvm/versions/node/v22.21.1/bin\n+# 于是直接跑 `npm run dev` 会拿到 Node 16,Next 15 直接拒绝启动:\n+# You are us","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cf70c0a64538af1820b32f36e093358bc4d86750/scripts%2Fdev.sh"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"added","additions":121,"deletions":0,"changes":121,"patch":"@@ -0,0 +1,121 @@\n+// 临时路由:把导航所需的轻量元数据 dump 成 JSON,用完即删。\n+import {\n+ EXAMS,\n+ allArena,\n+ allCodingProblems,\n+ allDrills,\n+ lessonsOf,\n+} from \"@/content/registry\";\n+\n+export function GET() {\n+ return Response.json({\n+ exams: EXAMS.map((e) => ({\n+ id: e.id,\n+ title: e.title,\n+ shortTitle: e.shortTitle,\n+ description: e.description,\n+ category: e.category,\n+ tests: e.tests,\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cf70c0a64538af1820b32f36e093358bc4d86750/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/nav-template.txt","previous_filename":null,"status":"added","additions":191,"deletions":0,"changes":191,"patch":"@@ -0,0 +1,191 @@\n+// 导航用的轻量元数据 —— 由 content/registry 派生。\n+//\n+// 为什么要有这个文件:课程内容里带 JSX(ReactNode),只能在客户端组件里 import,\n+// 而侧栏 / 首页 / 学习路径 / 搜索这些客户端组件只需要标题和计数。\n+// 如果它们直接 import registry,全部课程的正文都会被打进客户端包(实测 784 KB)。\n+// 所以这里放一份「只有文字和数字」的镜像,客户端组件只读这个。\n+//\n+// 【怎么保持同步】不要手改。改完内容后跑:\n+// npm run gen:nav\n+// 它会重新从真实内容里 dump 一份覆盖这个文件。\n+// 另外 assertNavInSync() 会在开发模式下检查两边是否一致,不一致直接抛错。\n+\n+/**\n+ * 侧栏、首页、路线图这些客户端组件要用的最小课程信息。\n+","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cf70c0a64538af1820b32f36e093358bc4d86750/scripts%2Fnav-template.txt"},{"filename":"scripts/search-index-template.txt","previous_filename":null,"status":"added","additions":44,"deletions":0,"changes":44,"patch":"@@ -0,0 +1,44 @@\n+// 这个文件是生成物,不要手改。生成器:scripts/gen-nav.mjs(模板在 search-index-template.txt)。\n+//\n+// 【它为什么单独一个文件】\n+// 里面是搜索 haystack 需要的重字段:objectives / whyForAssessment /\n+// conceptHeadings / conceptLedes / exerciseTitles / sourcePaths / recap / transfer。\n+// 加起来 130 KB 出头,而唯一的消费者是 components/search.tsx。\n+// 搜索要按 ⌘K 才打开,所以这份数据**不该进任何页面的首屏包** ——\n+// search.tsx 用 await import() 在第一次打开搜索时才拉它。\n+//\n+// 实测:拆出去之前,装着这些字段的 na","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cf70c0a64538af1820b32f36e093358bc4d86750/scripts%2Fsearch-index-template.txt"},{"filename":"scripts/source-files-template.txt","previous_filename":null,"status":"added","additions":38,"deletions":0,"changes":38,"patch":"@@ -0,0 +1,38 @@\n+// 这个文件是生成物,不要手改。生成器:scripts/gen-source-files.mjs\n+// 重新生成:npm run gen:src\n+//\n+// 【它是什么】\n+// 课文里 sourceFiles 声明的那些真实文件的**原文快照**。页面上的\n+// 「这节课要看的真实文件」每一行都能展开看到文件内容 ——\n+// 课文说「这个 package.json 只有三个 script」,你能当场展开核对。\n+//\n+// 【它不包含什么】\n+// 「从零重写」练习里那些要你自己建的文件(package.json、src/App.tsx 这种\n+// 裸相对路径)。它们在源项目磁盘上恰好是做完的版本,收进来就是泄答案。\n+// 生成器只收「第一段是三个源项目之一」的路径。\n+//\n+// 【目录也能收】路径以 / 结尾、或者是 exam 总览页那种项目根绝对路径的,\n+// 收","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cf70c0a64538af1820b32f36e093358bc4d86750/scripts%2Fsource-files-template.txt"}]},"80e1a8194dd3538b6f60e868b575729813edb193":{"sha":"80e1a8194dd3538b6f60e868b575729813edb193","stats":{"additions":512,"deletions":0,"total":512},"files":[{"filename":"app/icon.svg","previous_filename":null,"status":"added","additions":9,"deletions":0,"changes":9,"patch":"@@ -0,0 +1,9 @@\n+<!-- 四根递减的柱子 = 四条主线,每条给的脚手架比上一条少,最后只剩空文件夹。\n+ 完整设计说明和那两个坑(注释别太长、SVG 内别写注释)见 components/app-shell.tsx 的 Mark。 -->\n+<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" width=\"32\" height=\"32\">\n+ <rect x=\"2\" y=\"3\" width=\"3\" height=\"14\" rx=\"1\" fill=\"#a9c2b3\" />\n+ <rect x=\"6.5\" y=\"7\" width=\"3\" height=\"10\" rx=\"1\" fill=\"#a9c2b3\" />\n+ <rect x=\"11\" y=\"11\" width=\"3\" height=\"6\" rx=\"1\" fill=\"#a9c2b3","blob_url":"https://github.com/renrenmimi/DrillLab/blob/80e1a8194dd3538b6f60e868b575729813edb193/app%2Ficon.svg"},{"filename":"components/app-shell.tsx","previous_filename":null,"status":"added","additions":503,"deletions":0,"changes":503,"patch":"@@ -0,0 +1,503 @@\n+\"use client\";\n+\n+// 外壳:顶栏 + 左侧课程目录 + 主内容(+ 可选右侧目录栏)。\n+//\n+// 【导航的分工 —— 这一版专门为了解决「到底看顶栏还是看侧栏」】\n+//\n+// 顶栏 = 去哪儿。全站级的五个目的地,跟当前学到哪无关。\n+// 侧栏 = 我在学什么。**只在课程页和模拟考页出现**,而且只展开当前这一门。\n+//\n+// 之前的问题:侧栏在每一页都显示全部课程的目录,于是\n+// · 在「速查」「练习场」这种页面上,侧栏是纯噪音;\n+// · 顶栏的「考试」和侧栏的课程列表、首页的课程清单三重重复;\n+// · 各门课并列排着,没有任何「先学哪个」的信号。\n+//\n+// 现在:\n+// · 非课程页不渲染侧栏,页面全宽(.shell[data-nav=\"off\"]);\n+// · 「考试」索引页并进「课程」(/path),顶栏因","blob_url":"https://github.com/renrenmimi/DrillLab/blob/80e1a8194dd3538b6f60e868b575729813edb193/components%2Fapp-shell.tsx"}]},"da75bde735af0cab5340fcf9e295ac110ac35646":{"sha":"da75bde735af0cab5340fcf9e295ac110ac35646","stats":{"additions":10854,"deletions":0,"total":10854},"files":[{"filename":"components/arena-bits.tsx","previous_filename":null,"status":"added","additions":230,"deletions":0,"changes":230,"patch":"@@ -0,0 +1,230 @@\n+// 考场的公用零件 —— 纯函数 + 不带 hook 的展示件。\n+//\n+// 【为什么单独一个文件,又故意不写 \"use client\"】\n+// 考场四页里两页是服务端组件(说明屏、进行中),两页要读 localStorage 所以是\n+// 客户端小岛(列表、交卷后自评)。这些零件两边都要用。\n+// 不带 \"use client\" 的模块会跟着 import 它的那一侧走 —— 服务端页面里它留在\n+// 服务端,客户端小岛里它才进 chunk。所以这一份代码不会把内容拖进客户端包。\n+//\n+// 这里只放「不需要 localStorage 也能算出来」的东西。凡是要读进度的都在小岛里。\n+\n+import type { ArenaAttempt } from \"@/lib/progress\";\n+import { T } from \"./t\";\n+\n+/* =========","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Farena-bits.tsx"},{"filename":"components/arena-brief.tsx","previous_filename":null,"status":"added","additions":129,"deletions":0,"changes":129,"patch":"@@ -0,0 +1,129 @@\n+// 开考前的说明屏 —— 服务端组件。\n+//\n+// 【为什么这一页也用 arenaPublicById】\n+// 它只需要标题、场景、时限和几个计数。用摘掉了 hints / solution 的\n+// ArenaPublic,就算以后有人手滑在这里加一段「提示预览」,也会先撞上编译错误。\n+// 防呆放在类型上,不放在人肉 review 上。\n+//\n+// 【这一页故意不列需求】\n+// 需求在 /arena/[id]/run,也就是计时开始之后才出现。读题本身就是考试的一部分:\n+// 真实考试不会让你先把题读完再决定什么时候开始计时。\n+\n+import Link from \"next/link\";\n+import { notFound } from \"next/navigation\";\n+import { arenaPublicById } from \"@/content/","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Farena-brief.tsx"},{"filename":"components/arena-checkoff.tsx","previous_filename":null,"status":"added","additions":306,"deletions":0,"changes":306,"patch":"@@ -0,0 +1,306 @@\n+\"use client\";\n+\n+// 交卷后的自评 + 解锁 —— 复盘页唯一的客户端小岛。\n+//\n+// ============================================================\n+// 【交卷 → 自评 → 落记录,这个顺序怎么处理的】\n+//\n+// 问题:finishArena(id, outcome, checks) 需要 checks,但 checks 是在这一页勾的,\n+// 而「交卷」这个动作发生在上一页(/run)。\n+//\n+// 方案(两种里选了后一种):\n+// A. 交卷时先写一条 outcome 待定的记录,自评完再回填 checks。\n+// 要给 progress 加一个「改最后一条记录」的方法,而且中途关页面会留下\n+// 一条永远待定的记录 —— 记录里有半成品,比没有更糟。\n+/","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Farena-checkoff.tsx"},{"filename":"components/arena-clock.tsx","previous_filename":null,"status":"added","additions":205,"deletions":0,"changes":205,"patch":"@@ -0,0 +1,205 @@\n+\"use client\";\n+\n+// 考场计时器 + 交卷 / 放弃 —— 进行中那一页唯一的客户端小岛。\n+//\n+// ============================================================\n+// 【计时怎么算】\n+// 存的只有一个数:开考那一刻的时间戳(startArena 写进 localStorage 的\n+// arenaLive.startedAt)。显示的时候现算 now - startedAt。\n+//\n+// setInterval 只做一件事:每秒把 now 换成新的 Date.now(),触发一次重渲染。\n+// 它**不参与计数** —— 没有 elapsed += 1 这种东西。\n+// 理由:标签页切到后台时定时器会被节流到一分钟一次甚至完全停掉,累加法会漂,\n+// 现算法不会。这个站已经因为 rAF 在","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Farena-clock.tsx"},{"filename":"components/arena-list.tsx","previous_filename":null,"status":"added","additions":211,"deletions":0,"changes":211,"patch":"@@ -0,0 +1,211 @@\n+\"use client\";\n+\n+// 考场列表 —— 客户端组件,因为它要读 localStorage 里的尝试记录。\n+//\n+// 它只 import content/nav(生成物,纯数据),不 import content/arena。\n+// 理由见 CLAUDE.md:客户端组件一旦 import 内容,全部课程正文会被打进同一个\n+// chunk(踩过一次,784 KB)。题面、需求、答案全部留在服务端那三页。\n+//\n+// 这一页最重要的一件事:**让「没试过的题」显眼**。\n+// 试过的题会自己长出记录,没试过的题什么都不会长出来 —— 所以要主动标出来。\n+\n+import Link from \"next/link\";\n+import { ARENA, arenaPath, navExam } from \"@/content/nav\";\n+import { use","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Farena-list.tsx"},{"filename":"components/arena-review.tsx","previous_filename":null,"status":"added","additions":134,"deletions":0,"changes":134,"patch":"@@ -0,0 +1,134 @@\n+// 交卷后的复盘页 —— 服务端组件。\n+//\n+// 这是四段路由里唯一允许出现提示和答案的一页,所以它才用 arenaById()(完整数据)。\n+// 隔离靠的是路由:/run 那一页调的是 arenaPublicById(),类型上就没有这两个字段。\n+//\n+// 页面顺序是刻意的:\n+// ① 逐条勾验收命令的真实结果(自评)→ 写进这次尝试的 checks\n+// ② 勾完才解锁四级提示和参考答案\n+// 先看答案再勾,勾出来的东西就没意义了。所以①没做完,②的内容不进 DOM ——\n+// 提示和答案是作为 children 交给 ArenaCheckoff 的,它在自评完成前根本不渲染这个插槽。\n+\n+import Link from \"next/link\";\n+import { notFound } from \"next/navigation\";\n+import","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Farena-review.tsx"},{"filename":"components/arena-run.tsx","previous_filename":null,"status":"added","additions":121,"deletions":0,"changes":121,"patch":"@@ -0,0 +1,121 @@\n+// 进行中的考场 —— 服务端组件。\n+//\n+// 【这一页最硬的一条规矩】\n+// HTML 里不许出现提示和答案。做法不是折叠、不是 display: none,是\n+// **服务端根本拿不到它们**:这一页只调 arenaPublicById(),返回的\n+// ArenaPublic = Omit<ArenaChallenge, \"hints\" | \"solution\">。\n+// 所以写 a.hints 或 a.solution 会直接编译不过 —— 防线在类型上,不在自觉上。\n+//\n+// 【这一页故意没有回课程的链接】\n+// 计时期间不该有一键点回讲解的入口。要看提示只有一条路:交卷。\n+//\n+// 交互只有一块:顶上的计时器和交卷/放弃按钮(ArenaClock,客户端小岛)。\n+\n+import { notFound } from \"next/navigatio","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Farena-run.tsx"},{"filename":"components/arena-start.tsx","previous_filename":null,"status":"added","additions":172,"deletions":0,"changes":172,"patch":"@@ -0,0 +1,172 @@\n+\"use client\";\n+\n+// 说明屏上的那一块交互:历史尝试记录 + 开考按钮。\n+//\n+// 只有这一块是客户端的,因为只有它要读写 localStorage。题面留在服务端那两页。\n+//\n+// 【为什么开考按钮在挂载前是禁用的】\n+// arenaLive 只有客户端知道。服务端渲染时 ready === false,首次客户端渲染也是\n+// false —— 两边输出一样,不会有 hydration 警告。挂载后 ready 翻 true,\n+// 按钮才活过来。这是全站统一的路子(见 lib/theme.tsx / lib/progress.tsx)。\n+\n+import Link from \"next/link\";\n+import { useRouter } from \"next/navigation\";\n+import { useState } from \"re","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Farena-start.tsx"},{"filename":"components/code.tsx","previous_filename":null,"status":"added","additions":400,"deletions":0,"changes":400,"patch":"@@ -0,0 +1,400 @@\n+\"use client\";\n+\n+// 代码展示原语。\n+// CodeBlock —— 只读代码窗:文件名 + 语言 + 来源标注 + 行号 + 行高亮 + 复制\n+// TerminalCommand —— 终端命令(带 $ 提示符、可复制、可附真实输出)\n+// EditableCode —— 可编辑代码区(Reset / 复制 / 外部传入的动作按钮)\n+// DiffView —— 行级差异对照(用户代码 vs 参考答案)\n+\n+import { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\n+import { highlight, LANG_LABEL, type Lang } from \"@/lib/highlight\";\n+import ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fcode.tsx"},{"filename":"components/coding-detail.tsx","previous_filename":null,"status":"added","additions":343,"deletions":0,"changes":343,"patch":"@@ -0,0 +1,343 @@\n+// 一道 coding 题的详情页 —— 服务端组件。\n+//\n+// 一页四段,不跳来跳去:\n+// §01 题面 默认只有这一段\n+// §02 工作区 沙箱(或「本机跑」卡片)\n+// §03 展开讲解 折叠,展开后是 explainLessonId 那一节的原文\n+// §04 参考答案 四级提示 → 答案门\n+//\n+// §02 之后的开合归 CodingWorkspace(客户端)管,但 §03 / §04 的正文\n+// 仍然在这里服务端渲染,然后当 props 传进去 —— 内容一个字节都不进客户端包。\n+\n+import Link from \"next/link\";\n+import { notFound } from \"next/navigation\";\n+import {\n+ allCodingProblems,\n+ allEx","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fcoding-detail.tsx"},{"filename":"components/coding-list.tsx","previous_filename":null,"status":"added","additions":274,"deletions":0,"changes":274,"patch":"@@ -0,0 +1,274 @@\n+// Coding 题列表 —— 服务端组件。\n+//\n+// 和 /practice 一个路子:筛选条件放 URL,按钮就是普通链接。\n+// 这样筛选状态能分享、能后退,而且这一页不用把全部题面塞进客户端包。\n+//\n+// 「可运行」这一维必须从 content/coding 现算,不能读 nav 的 hasSandbox ——\n+// nav.ts 是生成物,改完 sandbox 之后不跑 gen:nav 它就是旧的。\n+\n+import Link from \"next/link\";\n+import { allCodingProblems, CODING_TRACK_LABEL, DIFFICULTY_LABEL } from \"@/content/coding\";\n+import { codingPath } from \"@/content/nav\";\n+import type {","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fcoding-list.tsx"},{"filename":"components/coding-progress.tsx","previous_filename":null,"status":"added","additions":80,"deletions":0,"changes":80,"patch":"@@ -0,0 +1,80 @@\n+\"use client\";\n+\n+// coding 题的进度小岛。三个都很小,只读 lib/progress,不碰内容。\n+\n+import { useProgress } from \"@/lib/progress\";\n+import { T } from \"./t\";\n+\n+/** 列表里那一枚「已完成 / 未开始」 */\n+export function CodingDoneBadge({ id }: { id: string }) {\n+ const { ready, codingDone } = useProgress();\n+ if (!ready) return <span className=\"cd-badge\" data-on=\"pending\" aria-hidden />;\n+\n+ const done = codingDone(id);\n+ return (\n+","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fcoding-progress.tsx"},{"filename":"components/coding-sandpack.tsx","previous_filename":null,"status":"added","additions":135,"deletions":0,"changes":135,"patch":"@@ -0,0 +1,135 @@\n+\"use client\";\n+\n+// 真正把 Sandpack 拉进来的那一块 —— 阶段 D 唯一的第三方 UI 依赖。\n+//\n+// 【为什么单独一个文件】\n+// @codesandbox/sandpack-react 压缩后有几百 KB。它只能出现在\n+// coding 题详情页和模拟考页,而且要等用户点了「打开工作区」才下载。\n+// 所以这个文件被 components/sandbox.tsx 用 next/dynamic({ ssr: false }) 引,\n+// 打包器会把它切成独立的 async chunk —— 别的页面首屏一个字节都不会多。\n+// **不要在任何地方静态 import 这个文件。**\n+//\n+// 【为什么要联网】\n+// Sandpack 的打包器跑在 codesandbox 的远程 iframe 里(默认\n+// https://2-19","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fcoding-sandpack.tsx"},{"filename":"components/coding-workspace.tsx","previous_filename":null,"status":"added","additions":71,"deletions":0,"changes":71,"patch":"@@ -0,0 +1,71 @@\n+\"use client\";\n+\n+// 工作区 + 讲解 + 答案的开合器。\n+//\n+// 【为什么这个壳是客户端组件,里面的正文却是服务端渲染的】\n+// 「切到空白重来 → 讲解和答案一并收起」这条规则要一个 state,所以壳必须在客户端。\n+// 但讲解那一节的正文带 JSX、体积很大,绝不能让客户端 import 内容。\n+// 办法是把服务端渲染好的节点当 props 传进来(explain / solution)——\n+// React 会在服务端把它们渲染成 RSC payload,客户端只负责决定挂不挂。\n+\n+import { useState, type ReactNode } from \"react\";\n+import type { SandboxSpec } from \"@/content/types\";\n+import { CodingDoneToggle } f","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fcoding-workspace.tsx"},{"filename":"components/data-flow.tsx","previous_filename":null,"status":"added","additions":159,"deletions":0,"changes":159,"patch":"@@ -0,0 +1,159 @@\n+\"use client\";\n+\n+// 数据流 / 请求流的逐帧图。\n+//\n+// 每一帧是一张完整快照:哪些节点已经走过、哪个节点正在发生、\n+// 节点上显示什么具体数据。旁白一句话说清「这一步到底发生了什么」。\n+// 用来讲两条主线:\n+// React: 用户操作 → 事件处理 → setState → 重渲染 → 新 UI\n+// GraphQL:客户端查询 → Router → subgraph A → entity reference → subgraph B → 合并响应\n+\n+import { useEffect, useRef, useState, type ReactNode } from \"react\";\n+import { useT } from \"@/lib/locale\";\n+import { T } from \"./t\";\n+\n+export i","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdata-flow.tsx"},{"filename":"components/drill-answer.tsx","previous_filename":null,"status":"added","additions":37,"deletions":0,"changes":37,"patch":"@@ -0,0 +1,37 @@\n+// 一道八股题的答案正文 —— 服务端组件,列表页 / 单题页 / 抽认卡三处共用。\n+//\n+// 同一道题在哪儿看都长一样,是因为三处渲染答案的只有这一个组件。\n+//\n+// 【为什么必须留在服务端】\n+// answer 是 ReactNode(正文带 JSX),只有服务端组件能 import content/drills。\n+// 客户端小岛拿到的永远是「已经渲染好的 children」,正文走 RSC payload,\n+// 不进 JS 包。\n+//\n+// 有 answerEn 的题直接复用课程页那个 AnswerTabs(纯 CSS 的中文 / English\n+// 两个 tab,零 JS)—— 不另写一套。\n+\n+import type { DrillQuestion } from \"@/content/types\";\n+import { CodeBlock } fro","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdrill-answer.tsx"},{"filename":"components/drill-card.tsx","previous_filename":null,"status":"added","additions":244,"deletions":0,"changes":244,"patch":"@@ -0,0 +1,244 @@\n+\"use client\";\n+\n+// 列表页上的三个客户端小岛:一张题卡的外壳、「筛出多少道」、搜索框。\n+//\n+// 【正文怎么进来的】\n+// 题卡的答案是**服务端渲染好的 children**(见 components/drill-list.tsx)。\n+// 这个文件只读 content/nav 里的题面元数据 —— 一行内容正文都不 import。\n+// 反过来做(客户端 import content/drills)会把 99 道题的正文打进 JS 包,\n+// 那就是 README 里记的 784 KB 那个坑。\n+//\n+// 【掌握状态这一维为什么只能在客户端筛】\n+// 「会 / 模糊 / 不会」存在 localStorage 里,服务端渲染时根本读不到。\n+// 所以分工是:\n+// · track(方向)+ q(关键词)→ 服务端真筛,还分页,链接可以分享;","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdrill-card.tsx"},{"filename":"components/drill-detail.tsx","previous_filename":null,"status":"added","additions":117,"deletions":0,"changes":117,"patch":"@@ -0,0 +1,117 @@\n+// 单题页 —— 服务端组件。99 道全部预渲染(见 app/drill/[id]/page.tsx)。\n+//\n+// 这一页的定位:一道题一个可以分享、可以收藏、可以直接搜到的地址。\n+// 所以正文一次给全(不折叠),上下题能连着翻,出处能一键回到那节课。\n+\n+import Link from \"next/link\";\n+import { drillById, drillNeighbours, TRACK_LABEL } from \"@/content/drills\";\n+// lessonPath 从 nav 取而不是 registry:registry 还 re-export 了 coding / arena,\n+// 这一页用不上,没必要把它们拖进这条 import 链。\n+import { lessonPath } from \"@/content/nav\";\n+import","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdrill-detail.tsx"},{"filename":"components/drill-list.tsx","previous_filename":null,"status":"added","additions":262,"deletions":0,"changes":262,"patch":"@@ -0,0 +1,262 @@\n+// 八股题库列表 —— 服务端组件。\n+//\n+// 【筛选的分工】(详见 components/drill-card.tsx 顶部那段)\n+// track + q → 这里真筛,还分页,URL 可以分享、可以后退;\n+// mark → 存在 localStorage 里,服务端读不到,只能由客户端小岛\n+// 把不符合的整张卡摘掉。mark 生效时这里不分页。\n+//\n+// 【为什么是服务端】答案正文带 JSX。做成 \"use client\" 就会把 99 道题的正文\n+// 打进客户端包(784 KB 那个坑)。所以正文在这里渲染,作为 children 交给\n+// 客户端小岛 <DrillCard>,交互只包一层壳。\n+\n+import Link from \"next/link\";\n+import { allDrills, ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdrill-list.tsx"},{"filename":"components/drill-marks.tsx","previous_filename":null,"status":"added","additions":148,"deletions":0,"changes":148,"patch":"@@ -0,0 +1,148 @@\n+\"use client\";\n+\n+// 掌握状态的两个客户端小岛:三个自评按钮 + 顶部那条进度。\n+//\n+// 【为什么是小岛】题目正文是服务端渲染的,这里只碰 localStorage 里的\n+// 「会 / 模糊 / 不会」。它们不 import 任何内容文件 —— 要题面就读 content/nav。\n+\n+import { useProgress, type DrillMark } from \"@/lib/progress\";\n+import { T } from \"./t\";\n+\n+/** 三档自评。key 是抽认卡上的键盘快捷键,列表页也顺手显示出来 */\n+export const MARK_META: {\n+ id: DrillMark;\n+ zh: string;\n+ en: string;\n+ key: string;\n+ tone: \"ok\" | \"war","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdrill-marks.tsx"},{"filename":"components/drill-query.tsx","previous_filename":null,"status":"added","additions":58,"deletions":0,"changes":58,"patch":"@@ -0,0 +1,58 @@\n+// 题库列表页的 URL 参数模型 —— 服务端和客户端都要用的一小段纯函数。\n+//\n+// 【为什么单独一个文件】\n+// `/drill` 的列表是服务端组件渲染的(正文带 JSX,只能在服务端 import\n+// content/drills),但搜索框和「筛出多少道」是客户端小岛。两边都要拼同一种\n+// 链接。这个文件**没有 \"use client\" 也不 import 任何内容**,所以两边都能\n+// import:服务端直接调用,客户端把这几十字节打进包里。\n+//\n+// 如果把它写在 \"use client\" 文件里,服务端 import 到的会是一个客户端引用\n+// 代理,调用时直接报错 —— 这是 RSC 的规则,不是风格问题。\n+\n+/** 列表页认的四个参数 */\n+export interface DrillQuery {\n+ /** 方向:html ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdrill-query.tsx"},{"filename":"components/drill-session-page.tsx","previous_filename":null,"status":"added","additions":50,"deletions":0,"changes":50,"patch":"@@ -0,0 +1,50 @@\n+// 抽认卡的服务端半边:只做一件事 —— 把 URL 上点到的那几道题的答案渲染好,\n+// 交给客户端组件。\n+//\n+// 为什么答案要在这里渲染:客户端组件不许 import content/drills(正文带 JSX,\n+// 一 import 就把 99 道题打进 JS 包)。这些 body 是 ReactNode,走 RSC payload\n+// 下来,客户端只负责显示哪一张、什么时候显示。\n+//\n+// 为什么按 ids 而不是一次全发:见 components/drill-session.tsx 顶部那段。\n+// 一句话 —— 一轮只看十几张,没必要把 99 道题的正文都塞进这一页的 HTML。\n+\n+import { drillById } from \"@/content/drills\";\n+import type { DrillQuestion } from \"@","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdrill-session-page.tsx"},{"filename":"components/drill-session.tsx","previous_filename":null,"status":"added","additions":763,"deletions":0,"changes":763,"patch":"@@ -0,0 +1,763 @@\n+\"use client\";\n+\n+// 抽认卡 —— 客户端组件(要键盘、要计数、要写 localStorage)。\n+//\n+// ============================================================\n+// 【正文是怎么进来的】—— 这是这一页最要紧的一件事\n+// ============================================================\n+// 答案正文带 JSX,客户端组件不许 import content/drills。\n+// 所以 app/drill/session/page.tsx 是**服务端**页面:它按 URL 上的 ids\n+// 把那几道题的答案渲染好,作为 cards={[{ id, body: ReactNode }]} 传进来。\n+// 这些 body 走 RS","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fdrill-session.tsx"},{"filename":"components/exam-overview.tsx","previous_filename":null,"status":"added","additions":328,"deletions":0,"changes":328,"patch":"@@ -0,0 +1,328 @@\n+\"use client\";\n+\n+// 一门考试的首页:考什么 → 源项目在哪 → 任务覆盖清单 → 模块与课程 → 模拟考。\n+// 所有考试共用这一个版式,新增考试不用写页面。\n+\n+import Link from \"next/link\";\n+import { navExam, lessonPath, mockPath } from \"@/content/nav\";\n+import { useProgress } from \"@/lib/progress\";\n+import { T } from \"./t\";\n+import { FileExplorer } from \"./lesson-kit\";\n+\n+export function ExamOverview({ examId }: { examId: string }) {\n+ const exam = navExam(exam","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fexam-overview.tsx"},{"filename":"components/exercise.tsx","previous_filename":null,"status":"added","additions":955,"deletions":0,"changes":955,"patch":"@@ -0,0 +1,955 @@\n+\"use client\";\n+\n+// 六种练习的实现 + 一个分发器 <ExerciseView />。\n+//\n+// Level 1 recognition / ordering —— 认得出概念、排得对顺序\n+// Level 2 fill-blank —— 挖空(空位只挖真正的知识点)\n+// Level 2/3 debug —— Debug Lab:读报错 → 判类型 → 定位 → 改 → 验证\n+// Level 3 code-completion —— 只给签名和要求,自己写整块;文本级 Check\n+// Level 4 from-scratch —— 从空文件开始,四级提示,最后才给答案\n+//\n+// 做对了会写进进度(useProgress.markExercise),不","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fexercise.tsx"},{"filename":"components/guide.tsx","previous_filename":null,"status":"added","additions":461,"deletions":0,"changes":461,"patch":"@@ -0,0 +1,461 @@\n+// 使用说明 —— 一页说清「这个 App 怎么用」。\n+//\n+// 【为什么需要它】\n+// 之前每一页都有「怎么用这一页」(/practice 的右栏就是),但没有一页回答\n+// 「整体怎么用 / 每天怎么安排 / 考前一周怎么冲」。用户第一次打开时\n+// 得靠自己摸索这四条主线的关系,那是浪费。\n+//\n+// 【为什么是服务端组件】\n+// 纯展示,没有 hook。数量一律从 NAV / DRILLS / CODING / ARENA 派生,\n+// 不写死 —— 加一门课这一页自己就对了。\n+\n+import Link from \"next/link\";\n+import { ARENA, CODING, DRILLS, NAV } from \"@/content/nav\";\n+import { Ladder } from \"./ladder\";\n+import { T } ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fguide.tsx"},{"filename":"components/hint-panel.tsx","previous_filename":null,"status":"added","additions":123,"deletions":0,"changes":123,"patch":"@@ -0,0 +1,123 @@\n+\"use client\";\n+\n+// 分级提示 —— 一次只放一级,不点一下就把答案全倒出来。\n+//\n+// 四级递进(与 FromScratchExercise.hints 的顺序一致):\n+// 1 只说思考方向\n+// 2 说该改哪个文件 / 用什么概念\n+// 3 给伪代码\n+// 4 给局部代码\n+// 答案是第五步,由调用方单独用 SolutionGate 控制。\n+//\n+// 【双语】note / label 收的是 LocalizedString(见 components/t.tsx)——\n+// 调用方用 L(\"中文\", \"English\") 传,两种语言同时进 HTML,CSS 藏一份。\n+// 提示正文(hints 数组)来自课程内容,那一层的翻译是另一件事,这里不管。\n+\n+import { useState, type ReactNode } ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fhint-panel.tsx"},{"filename":"components/home.tsx","previous_filename":null,"status":"added","additions":418,"deletions":0,"changes":418,"patch":"@@ -0,0 +1,418 @@\n+\"use client\";\n+\n+// 首页。不做企业 Dashboard,也不做 SaaS Hero。\n+//\n+// 【这一版的重点:第一屏必须回答「我该点哪」】\n+// 之前的问题是编辑式引言压在最上面,「该从哪开始」在一屏半以下 ——\n+// 新用户进来先读到三段散文,然后看到 4 门课并列,不知道从哪下手。\n+// 现在第一屏就是一个「开始」条:没进度就是「从第一节开始」,\n+// 有进度就是「继续上次」。只有一个主按钮。\n+//\n+// 另一个修正:以前文案写死成「两个真实的 综合项目」。\n+// 现在不止两个 —— 八股问答、小 coding、三个真题项目的大题、两套模拟考,\n+// 而且以后还会加。所以数量一律从 NAV 里算,不写死。\n+\n+import Link from \"next/link\";\n+import { ARENA, CODING, DRILLS, NAV,","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fhome.tsx"},{"filename":"components/ladder.tsx","previous_filename":null,"status":"added","additions":162,"deletions":0,"changes":162,"patch":"@@ -0,0 +1,162 @@\n+// 四级阶梯 —— 放在 /practice、/drill、/code、/arena 四页的最上面。\n+//\n+// 【为什么需要它】\n+// 使用者的原话:「首页 八股 Coding 考场 模拟考 课程 速查,我还是不清楚这几个有什么区别。」\n+//\n+// 上一版的做法是在**首页**加一张解释表 —— 没用,因为那张表在第一屏以下,\n+// 而困惑发生在顶栏。所以这一版把答案放在困惑发生的地方:\n+// 不管你落在哪一条主线上,页面顶部第一件事就是整个阶梯,当前那一格高亮。\n+//\n+// 四条线的区别不是「题的类型」,是**给你多少东西** —— 越往右给得越少:\n+// 八股 给你问题,你用嘴答 → 说得出\n+// 课内练习 挖好了空等你填 → 认得出\n+// Coding 文件、依赖、测试都给好了","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fladder.tsx"},{"filename":"components/learning-path.tsx","previous_filename":null,"status":"added","additions":193,"deletions":0,"changes":193,"patch":"@@ -0,0 +1,193 @@\n+\"use client\";\n+\n+// 路线图 —— 回答「我在哪、下一步去哪」。\n+//\n+// 【为什么不是列表】\n+// 之前这一页是一串扁平的模块清单,看不出顺序也看不出自己在哪。\n+// 现在是带连线的节点(road):读完的打勾变灰,当前那一段强高亮并标「你在这」,\n+// 后面的弱化 —— 视觉上引导按顺序走,但链接不锁死,有基础的人要能跳。\n+//\n+// 分组和序号都从 module.stage 推导(形如「React · 第 3 部分」),不是硬编码。\n+\n+import Link from \"next/link\";\n+import { NAV, lessonPath, navStages } from \"@/content/nav\";\n+import { useProgress } from \"@/lib/progress\";\n+import { T } from \".","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Flearning-path.tsx"},{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"added","additions":262,"deletions":0,"changes":262,"patch":"@@ -0,0 +1,262 @@\n+// 一节课的完整渲染 —— 服务端组件。\n+//\n+// 所有课都走这一条路径,所以教学节奏是统一的:\n+// 课头 → 学完你会 / 考什么 → 涉及的真实文件 → 编号讲解段(含真实代码)\n+// → 提示框 → 练习 → 常见错误 → 迁移模式 → 要点 → 学完打勾 → 上下节\n+//\n+// 【为什么是服务端组件】课程内容里带 JSX。以前这个文件是 \"use client\",\n+// 结果 42 节课的全文全被打进客户端包(实测单个 chunk 784 KB,每页都下载)。\n+// 现在正文在服务端渲染,只有真正需要交互的部分是客户端小岛:\n+// LessonVisit / LessonDoneBar / LessonToc(本文件下方引用)\n+// CodeBlock / ExerciseView / DataFlowDiagram(各自文件里标了 \"use","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Flesson-body.tsx"},{"filename":"components/lesson-islands.tsx","previous_filename":null,"status":"added","additions":91,"deletions":0,"changes":91,"patch":"@@ -0,0 +1,91 @@\n+\"use client\";\n+\n+// 课程页里需要交互的三小块。其余(讲解正文、代码、错例、迁移表)都在服务端渲染,\n+// 这样 42 节课的全文不会被打进客户端包。\n+\n+import { useEffect } from \"react\";\n+import { useProgress } from \"@/lib/progress\";\n+import { useActiveHeading } from \"@/lib/use-active-heading\";\n+import { T } from \"./t\";\n+\n+/** 记录「最近学到哪」,首页的「继续上次」靠它 */\n+export function LessonVisit({\n+ examId,\n+ lessonId,\n+ title,\n+}: {\n+ examId: string;\n+ lessonId: string;\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Flesson-islands.tsx"},{"filename":"components/lesson-kit.tsx","previous_filename":null,"status":"added","additions":520,"deletions":0,"changes":520,"patch":"@@ -0,0 +1,520 @@\n+// 课程页面原语:课头 / 学习目标 / 编号段 / 提示框 / 文件树 /\n+// 「先想再写」/ 常见错误 / 迁移模式 / 要点 / 测试结果 / 页脚。\n+//\n+// 这些都是纯展示件,层级靠留白与细线,不套多层圆角卡片。\n+\n+import Link from \"next/link\";\n+import { Loc, T, type LocalizedString } from \"./t\";\n+import type { ReactNode } from \"react\";\n+import type {\n+ Callout as CalloutData,\n+ CodeExample,\n+ ConceptTone,\n+} from \"@/content/types\";\n+import { CodeBlock } from \"./code\";\n+import { snapshot","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Flesson-kit.tsx"},{"filename":"components/local-setup.tsx","previous_filename":null,"status":"added","additions":293,"deletions":0,"changes":293,"patch":"@@ -0,0 +1,293 @@\n+// 「这套题去哪跑、怎么跑起来」——模拟考页和考场 run 页共用。\n+//\n+// 【为什么这一块存在】\n+// 使用者问过一句关键的话:「模拟考页接沙箱是不是根本就无法实现?」\n+// 不是。React 模拟考就是一个组件加五个 RTL 测试,和 /code 里那些跑绿的\n+// 沙箱形状一样,接上去一定能跑。\n+//\n+// 不接是**设计决定**:模拟考和考场是「空手做」那一档(模拟考本来就是考场\n+// 考场那几道里的 2 道)。给它配一个连好依赖和测试的编辑器,就降成了「写得对」;\n+// 改成页面上填空,就降成了「认得出」。这两档在 /code 和 /practice 里都已经有一堆题了。\n+// 真实考试是在本机的编辑器里、自己 npm install、自己看报错 ——\n+// 「换壳验收」要换的就是这个壳。\n+//\n+// 但代价是:说明必须够硬。以前这两个页面只给一串命","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Flocal-setup.tsx"},{"filename":"components/mock-detail.tsx","previous_filename":null,"status":"added","additions":222,"deletions":0,"changes":222,"patch":"@@ -0,0 +1,222 @@\n+// 模拟考详情页 —— 服务端组件。\n+//\n+// 为什么是服务端:它要 import 完整的模拟考内容(starter、测试、讲解、答案,\n+// 里面全是 JSX)。如果这个组件是 \"use client\",那些内容会被打进客户端包。\n+// 放服务端之后,内容只以渲染结果的形式出现在这一页的 payload 里,不进 JS。\n+//\n+// 需要交互的三处用客户端小岛:SolutionGate(两道答案门)、MockScore(自评分)。\n+\n+import Link from \"next/link\";\n+import { notFound } from \"next/navigation\";\n+import { examById } from \"@/content/registry\";\n+import { examPath } from \"@/content/nav\";\n+impo","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fmock-detail.tsx"},{"filename":"components/mock-score.tsx","previous_filename":null,"status":"added","additions":79,"deletions":0,"changes":79,"patch":"@@ -0,0 +1,79 @@\n+\"use client\";\n+\n+// 模拟考的自评分小岛。服务端的详情页只把 examId / mockId / 满分传进来,\n+// 进度读写留在客户端。\n+\n+import { useState } from \"react\";\n+import { useT } from \"@/lib/locale\";\n+import { useProgress } from \"@/lib/progress\";\n+import { T } from \"./t\";\n+\n+export function MockScore({\n+ examId,\n+ mockId,\n+ outOf,\n+}: {\n+ examId: string;\n+ mockId: string;\n+ outOf: number;\n+}) {\n+ const { mockRecord, markMock, ready } = ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fmock-score.tsx"},{"filename":"components/mock-workspace.tsx","previous_filename":null,"status":"added","additions":189,"deletions":0,"changes":189,"patch":"@@ -0,0 +1,189 @@\n+\"use client\";\n+\n+// 模拟考列表页(客户端:要读进度)。\n+// 详情页在 mock-detail.tsx —— 那是服务端组件,因为它要 import 全文内容。\n+\n+import Link from \"next/link\";\n+import { NAV } from \"@/content/nav\";\n+import { useProgress } from \"@/lib/progress\";\n+import { T } from \"./t\";\n+\n+/* ---------- 列表页 ---------- */\n+\n+export function MockList() {\n+ const { mockRecord, ready } = useProgress();\n+ const all = NAV.flatMap((exam) => exam.mockExam","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fmock-workspace.tsx"},{"filename":"components/practice-focus.tsx","previous_filename":null,"status":"added","additions":59,"deletions":0,"changes":59,"patch":"@@ -0,0 +1,59 @@\n+\"use client\";\n+\n+// 「有进度的人别看全站平铺」—— 练习页的一个极小客户端小岛。\n+//\n+// 【为什么需要它,为什么它只能是客户端】\n+// /practice 是服务端组件(要遍历 123 个练习的正文,不能进客户端 chunk),\n+// 而「这个人正在学哪一门」只有 localStorage 知道。所以服务端渲染时\n+// 只能给出「全部 123 个平铺」,这对刚有一点进度的人是压力不是信息。\n+//\n+// 这个岛不接收任何内容,只做一件事:**首次进入没带 ?exam= 时,\n+// 如果有进度,就把 URL 换成你正在学的那门课。** 换 URL 之后服务端重新\n+// 渲染,筛选就对齐了 —— 内容仍然全在服务端。\n+//\n+// 【为什么用 replace 而不是 push】\n+// 不能让「后退」退回到未筛选的同一页 —— 那会变成一个退不出去的循环。","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fpractice-focus.tsx"},{"filename":"components/practice-page.tsx","previous_filename":null,"status":"added","additions":328,"deletions":0,"changes":328,"patch":"@@ -0,0 +1,328 @@\n+// 练习场 —— 服务端组件。\n+//\n+// 【为什么改成服务端 + URL 参数筛选】\n+// 这一页要遍历全站 103 个练习。以前它是 \"use client\" 并 import registry,\n+// 于是全部课程内容都被打进客户端包。改成服务端之后:\n+// · 筛选条件放在 URL 里(?exam=&kind=&level=&page=),按钮就是普通链接\n+// · 只渲染当前这一批(12 个),payload 小\n+// · 顺带得到「筛选状态可以分享、可以后退」这个好处\n+// 代价是点筛选会走一次导航,不是纯前端瞬时切换 —— 对这个页面是可以接受的。\n+\n+import Link from \"next/link\";\n+import { NAV } from \"@/content/nav\";\n+import { T } from \"./t\";\n+imp","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fpractice-page.tsx"},{"filename":"components/practice-progress.tsx","previous_filename":null,"status":"added","additions":53,"deletions":0,"changes":53,"patch":"@@ -0,0 +1,53 @@\n+\"use client\";\n+\n+// 练习场右栏的「你做对过」—— 只有这一小块需要读进度。\n+// 它按 nav 里的 id 清单去数,不需要 import 全文内容。\n+\n+import { NAV } from \"@/content/nav\";\n+import { useProgress } from \"@/lib/progress\";\n+import { T } from \"./t\";\n+\n+export function PracticeProgress({ total }: { total: number }) {\n+ const { data, ready } = useProgress();\n+\n+ // 进度里存的键是 `${examId}/${exerciseId}`,直接数属于本站考试的那些\n+ const ids = new Set(NAV.map((e) =>","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fpractice-progress.tsx"},{"filename":"components/reference.tsx","previous_filename":null,"status":"added","additions":821,"deletions":0,"changes":821,"patch":"@@ -0,0 +1,821 @@\n+\"use client\";\n+\n+// 速查。考场上真正会翻的东西:命令、字段、Hook、SDL、directive、状态码、\n+// 报错对照表、Debug 清单。全部来自两个真实项目,不放通用手册里抄来的东西。\n+\n+import { CodeBlock, TerminalCommand } from \"./code\";\n+import { real } from \"@/content/helpers\";\n+import { useActiveHeading } from \"@/lib/use-active-heading\";\n+import { T } from \"./t\";\n+\n+// 【这一页的双语边界】\n+// 界面壳(页头、小节标题、表头、侧栏)双语;**速查条目本身只有中文**。\n+// 原因:那些条目是内容不是界面 —— 报错原文、命令、字段说明加起来 300 多段,\n+/","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Freference.tsx"},{"filename":"components/sandbox.tsx","previous_filename":null,"status":"added","additions":192,"deletions":0,"changes":192,"patch":"@@ -0,0 +1,192 @@\n+\"use client\";\n+\n+// 沙箱的外壳:一道门 + 两档 + 「跑不了就别装能跑」的降级卡片。\n+//\n+// 门在这里的理由和答案门一样 —— 一进页面就把编辑器摊开,人会直接开始改代码,\n+// 跳过「先读题、先想」。所以默认只有一个按钮。\n+// 顺带一个实打实的好处:Sandpack 那几百 KB 只在点开之后才下载。\n+\n+import dynamic from \"next/dynamic\";\n+import { useMemo, useState } from \"react\";\n+import type { SandboxSpec } from \"@/content/types\";\n+import { useTheme } from \"@/lib/theme\";\n+import { TerminalCommand } from \"./code\";\n+import { ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fsandbox.tsx"},{"filename":"components/search.tsx","previous_filename":null,"status":"added","additions":386,"deletions":0,"changes":386,"patch":"@@ -0,0 +1,386 @@\n+\"use client\";\n+\n+// ⌘K 搜索。课、练习、八股、coding、考场题全部进索引(计数都从 nav 派生),\n+// 光靠侧栏翻太慢。\n+//\n+// 索引对象:平台页面、考试、课程(标题 + 一行钩子 + 考点)、模拟考,\n+// 加上刷题三层(八股 / coding / 考场)。\n+//\n+// 【双语】展示字段是 LocalizedString,但**匹配用 allText 拼中英两版** ——\n+// 搜索是找东西,不该被当前界面语言挡住。八股的英文原题尤其重要:\n+// 面试官念的是英文,用户会用英文搜。\n+// 匹配规则:把查询按空格切成词,每个词都要在标题或正文里出现(AND),\n+// 命中标题的排前面。纯前端、零依赖,数据来自轻量的 content/nav(不是全文内容)。\n+\n+import Link from \"next/link\";\n+import ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Fsearch.tsx"},{"filename":"components/t.tsx","previous_filename":null,"status":"added","additions":70,"deletions":0,"changes":70,"patch":"@@ -0,0 +1,70 @@\n+// 双语渲染的两个原语。**故意不加 \"use client\"** —— 课程正文是服务端组件,\n+// 它也要能用这两个。\n+//\n+// 做法:两种语言都渲染进 HTML,各包一层 data-lang,由 CSS 隐掉另一边\n+// (见 styles/base.css 末尾那几行,以及 lib/locale.tsx 顶部的说明)。\n+//\n+// 为什么分成两个:\n+// <T> 行内用,包 <span>\n+// <TBlock> 段落/列表/表格这类块级内容用,包 <div>\n+// 混用会出问题 —— <span> 里放 <p>,浏览器解析时会把 <p> 提出去,布局直接坏。\n+//\n+// 两个包装层都是 display: contents,所以不影响 flex / grid / 行内布局。\n+\n+import type { ReactNode } from \"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/da75bde735af0cab5340fcf9e295ac110ac35646/components%2Ft.tsx"}]},"cc1f80eaa9ee810849d8f2e1087d24804214119a":{"sha":"cc1f80eaa9ee810849d8f2e1087d24804214119a","stats":{"additions":386,"deletions":0,"total":386},"files":[{"filename":"app/arena/[id]/page.tsx","previous_filename":null,"status":"added","additions":22,"deletions":0,"changes":22,"patch":"@@ -0,0 +1,22 @@\n+// 开考前的说明屏。薄壳:解析 params、预生成六道题、给 metadata,剩下交给组件。\n+//\n+// metadata 也只用 arenaPublicById —— 这一页从头到尾拿不到 hints / solution。\n+\n+import { ArenaBrief } from \"@/components/arena-brief\";\n+import { allArena, arenaPublicById } from \"@/content/arena\";\n+\n+export function generateStaticParams() {\n+ return allArena().map((a) => ({ id: a.id }));\n+}\n+\n+export async function generateMetadata({ params }: { params: Pro","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Farena%2F%5Bid%5D%2Fpage.tsx"},{"filename":"app/arena/[id]/review/page.tsx","previous_filename":null,"status":"added","additions":22,"deletions":0,"changes":22,"patch":"@@ -0,0 +1,22 @@\n+// 交卷后的复盘页。薄壳。\n+//\n+// 四段路由里只有这一页允许出现提示和答案,所以只有它用完整的 arenaById()。\n+\n+import { ArenaReview } from \"@/components/arena-review\";\n+import { allArena, arenaById } from \"@/content/arena\";\n+\n+export function generateStaticParams() {\n+ return allArena().map((a) => ({ id: a.id }));\n+}\n+\n+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {\n+ const { id } = await params;","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Farena%2F%5Bid%5D%2Freview%2Fpage.tsx"},{"filename":"app/arena/[id]/run/page.tsx","previous_filename":null,"status":"added","additions":24,"deletions":0,"changes":24,"patch":"@@ -0,0 +1,24 @@\n+// 进行中的考场。薄壳。\n+//\n+// 这一页(含 metadata)只碰 arenaPublicById()。ArenaPublic 在类型上就没有\n+// hints 和 solution,所以「不小心把答案渲染进 run 页」是编译错误,不是 review 时\n+// 靠眼睛看出来的问题。\n+\n+import { ArenaRun } from \"@/components/arena-run\";\n+import { allArena, arenaPublicById } from \"@/content/arena\";\n+\n+export function generateStaticParams() {\n+ return allArena().map((a) => ({ id: a.id }));\n+}\n+\n+export async function generateMetada","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Farena%2F%5Bid%5D%2Frun%2Fpage.tsx"},{"filename":"app/arena/page.tsx","previous_filename":null,"status":"added","additions":10,"deletions":0,"changes":10,"patch":"@@ -0,0 +1,10 @@\n+import { ArenaList } from \"@/components/arena-list\";\n+\n+export const metadata = {\n+ title: \"考场 / Arena\",\n+ description: \"计时、无提示、答案锁到交卷之后的从零重写。沙箱跑绿不等于能在空文件夹里做出来。\",\n+};\n+\n+export default function Page() {\n+ return <ArenaList />;\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Farena%2Fpage.tsx"},{"filename":"app/code/[id]/page.tsx","previous_filename":null,"status":"added","additions":23,"deletions":0,"changes":23,"patch":"@@ -0,0 +1,23 @@\n+import { CodingDetail } from \"@/components/coding-detail\";\n+import { allCodingProblems, codingProblemById } from \"@/content/registry\";\n+\n+export function generateStaticParams() {\n+ return allCodingProblems().map((p) => ({ id: p.id }));\n+}\n+\n+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {\n+ const { id } = await params;\n+ const problem = codingProblemById(","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fcode%2F%5Bid%5D%2Fpage.tsx"},{"filename":"app/code/page.tsx","previous_filename":null,"status":"added","additions":16,"deletions":0,"changes":16,"patch":"@@ -0,0 +1,16 @@\n+import { CodingList, type CodingQuery } from \"@/components/coding-list\";\n+\n+export const metadata = {\n+ title: \"Coding 题 / Coding problems\",\n+ description:\n+ \"全站成型的 coding 题,可按方向、难度、能不能在浏览器里跑筛选。能跑的题带一个真沙箱:左边写、右边预览、一个按钮跑测试。\",\n+};\n+\n+export default async function Page({\n+ searchParams,\n+}: {\n+ searchParams: Promise<CodingQuery>;\n+}) {\n+ const query = await searchParams;\n+ return <CodingList q","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fcode%2Fpage.tsx"},{"filename":"app/drill/[id]/page.tsx","previous_filename":null,"status":"added","additions":25,"deletions":0,"changes":25,"patch":"@@ -0,0 +1,25 @@\n+import { notFound } from \"next/navigation\";\n+import { DrillDetail } from \"@/components/drill-detail\";\n+import { allDrills, drillById } from \"@/content/drills\";\n+\n+/** 99 道全部预渲染 —— 单题页要能被搜到、能被收藏 */\n+export function generateStaticParams() {\n+ return allDrills().map((q) => ({ id: q.id }));\n+}\n+\n+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {\n+ const { id } =","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fdrill%2F%5Bid%5D%2Fpage.tsx"},{"filename":"app/drill/page.tsx","previous_filename":null,"status":"added","additions":17,"deletions":0,"changes":17,"patch":"@@ -0,0 +1,17 @@\n+import { DrillList } from \"@/components/drill-list\";\n+import type { DrillQuery } from \"@/components/drill-query\";\n+\n+export const metadata = {\n+ title: \"八股题库 / Interview drills\",\n+ description:\n+ \"99 道面试问答题,一道一卡。默认只显示问题,答案折叠;按方向、掌握状态、题库编号筛。\",\n+};\n+\n+export default async function Page({\n+ searchParams,\n+}: {\n+ searchParams: Promise<DrillQuery>;\n+}) {\n+ const query = await searchParams;\n+ ret","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fdrill%2Fpage.tsx"},{"filename":"app/drill/session/page.tsx","previous_filename":null,"status":"added","additions":24,"deletions":0,"changes":24,"patch":"@@ -0,0 +1,24 @@\n+import { DrillSessionPage } from \"@/components/drill-session-page\";\n+\n+export const metadata = {\n+ title: \"抽认卡 / Flashcards\",\n+ description:\n+ \"八股题库的抽认卡:先只给问题,翻面看答案,然后自评「会 / 模糊 / 不会」。键盘全程可用。\",\n+};\n+\n+interface Query {\n+ /** 这一轮要过哪些题,逗号分隔。没有它就是「选范围」那一屏 */\n+ ids?: string;\n+ scope?: string;\n+ random?: string;\n+}\n+\n+export default async function Page({ searchParams }: { searchParams: Promise<Qu","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fdrill%2Fsession%2Fpage.tsx"},{"filename":"app/exams/[examId]/[lessonId]/page.tsx","previous_filename":null,"status":"added","additions":30,"deletions":0,"changes":30,"patch":"@@ -0,0 +1,30 @@\n+import { notFound } from \"next/navigation\";\n+import { LessonBody } from \"@/components/lesson-body\";\n+import { EXAMS, findLesson, lessonsOf } from \"@/content/registry\";\n+\n+export function generateStaticParams() {\n+ return EXAMS.flatMap((exam) =>\n+ lessonsOf(exam).map((ref) => ({ examId: exam.id, lessonId: ref.lesson.id })),\n+ );\n+}\n+\n+export async function generateMetadata({\n+ params,\n+}: {\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fexams%2F%5BexamId%5D%2F%5BlessonId%5D%2Fpage.tsx"},{"filename":"app/exams/[examId]/page.tsx","previous_filename":null,"status":"added","additions":28,"deletions":0,"changes":28,"patch":"@@ -0,0 +1,28 @@\n+import { notFound } from \"next/navigation\";\n+import { ExamOverview } from \"@/components/exam-overview\";\n+import { EXAMS, examById } from \"@/content/registry\";\n+\n+export function generateStaticParams() {\n+ return EXAMS.map((e) => ({ examId: e.id }));\n+}\n+\n+export async function generateMetadata({\n+ params,\n+}: {\n+ params: Promise<{ examId: string }>;\n+}) {\n+ const { examId } = await params;\n+ co","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fexams%2F%5BexamId%5D%2Fpage.tsx"},{"filename":"app/globals.css","previous_filename":null,"status":"added","additions":10,"deletions":0,"changes":10,"patch":"@@ -0,0 +1,10 @@\n+@import \"../styles/tokens.css\";\n+@import \"../styles/base.css\";\n+@import \"../styles/shell.css\";\n+@import \"../styles/lesson.css\";\n+@import \"../styles/code.css\";\n+@import \"../styles/exercise.css\";\n+@import \"../styles/viz.css\";\n+@import \"../styles/drill.css\";\n+@import \"../styles/arena.css\";\n+@import \"../styles/coding.css\";","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fglobals.css"},{"filename":"app/guide/page.tsx","previous_filename":null,"status":"added","additions":11,"deletions":0,"changes":11,"patch":"@@ -0,0 +1,11 @@\n+import { Guide } from \"@/components/guide\";\n+\n+export const metadata = {\n+ title: \"使用说明 / How to use this\",\n+ description:\n+ \"一页说清:有哪些东西、按什么顺序走、每天怎么用、考前一周怎么冲,以及几条要先知道的边界。\",\n+};\n+\n+export default function Page() {\n+ return <Guide />;\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fguide%2Fpage.tsx"},{"filename":"app/layout.tsx","previous_filename":null,"status":"added","additions":46,"deletions":0,"changes":46,"patch":"@@ -0,0 +1,46 @@\n+import type { Metadata, Viewport } from \"next\";\n+import type { ReactNode } from \"react\";\n+import { AppShell } from \"@/components/app-shell\";\n+import { ProgressProvider } from \"@/lib/progress\";\n+import { LOCALE_BOOTSTRAP, LocaleProvider } from \"@/lib/locale\";\n+import { THEME_BOOTSTRAP, ThemeProvider } from \"@/lib/theme\";\n+import \"./globals.css\";\n+\n+export const metadata: Metadata = {\n+ // 【为什么标题是中英并","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Flayout.tsx"},{"filename":"app/mock/[examId]/[mockId]/page.tsx","previous_filename":null,"status":"added","additions":28,"deletions":0,"changes":28,"patch":"@@ -0,0 +1,28 @@\n+import { MockDetail } from \"@/components/mock-detail\";\n+import { EXAMS, examById } from \"@/content/registry\";\n+\n+export function generateStaticParams() {\n+ return EXAMS.flatMap((exam) =>\n+ exam.mockExams.map((mock) => ({ examId: exam.id, mockId: mock.id })),\n+ );\n+}\n+\n+export async function generateMetadata({\n+ params,\n+}: {\n+ params: Promise<{ examId: string; mockId: string }>;\n+}) {\n+ cons","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fmock%2F%5BexamId%5D%2F%5BmockId%5D%2Fpage.tsx"},{"filename":"app/mock/page.tsx","previous_filename":null,"status":"added","additions":10,"deletions":0,"changes":10,"patch":"@@ -0,0 +1,10 @@\n+import { MockList } from \"@/components/mock-workspace\";\n+\n+export const metadata = {\n+ title: \"模拟考 / Mock exams\",\n+ description: \"换了业务场景、考点不变的模拟 assessment。\",\n+};\n+\n+export default function Page() {\n+ return <MockList />;\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fmock%2Fpage.tsx"},{"filename":"app/page.tsx","previous_filename":null,"status":"added","additions":5,"deletions":0,"changes":5,"patch":"@@ -0,0 +1,5 @@\n+import { Home } from \"@/components/home\";\n+\n+export default function Page() {\n+ return <Home />;\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fpage.tsx"},{"filename":"app/path/page.tsx","previous_filename":null,"status":"added","additions":10,"deletions":0,"changes":10,"patch":"@@ -0,0 +1,10 @@\n+import { LearningPath } from \"@/components/learning-path\";\n+\n+export const metadata = {\n+ title: \"课程路线图 / Roadmap\",\n+ description: \"从零基础到独立完成两个综合项目 的八个阶段。\",\n+};\n+\n+export default function Page() {\n+ return <LearningPath />;\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fpath%2Fpage.tsx"},{"filename":"app/practice/page.tsx","previous_filename":null,"status":"added","additions":15,"deletions":0,"changes":15,"patch":"@@ -0,0 +1,15 @@\n+import { PracticePage, type PracticeQuery } from \"@/components/practice-page\";\n+\n+export const metadata = {\n+ title: \"练习 / Practice\",\n+ description: \"全站练习,可按考试、难度、类型筛选。\",\n+};\n+\n+export default async function Page({\n+ searchParams,\n+}: {\n+ searchParams: Promise<PracticeQuery>;\n+}) {\n+ const query = await searchParams;\n+ return <PracticePage query={query} />;\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Fpractice%2Fpage.tsx"},{"filename":"app/reference/page.tsx","previous_filename":null,"status":"added","additions":10,"deletions":0,"changes":10,"patch":"@@ -0,0 +1,10 @@\n+import { Reference } from \"@/components/reference\";\n+\n+export const metadata = {\n+ title: \"速查 / Reference\",\n+ description: \"命令、package.json、React Hooks、GraphQL SDL、Federation directive、HTTP 状态码、报错对照表、Debug 清单。\",\n+};\n+\n+export default function Page() {\n+ return <Reference />;\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cc1f80eaa9ee810849d8f2e1087d24804214119a/app%2Freference%2Fpage.tsx"}]},"8b45be32dffef6c8bca0fda0b9d46352d4b2e316":{"sha":"8b45be32dffef6c8bca0fda0b9d46352d4b2e316","stats":{"additions":92,"deletions":0,"total":92},"files":[{"filename":"docs/sandpack-evaluation.md","previous_filename":null,"status":"added","additions":92,"deletions":0,"changes":92,"patch":"@@ -0,0 +1,92 @@\n+# Sandpack 评估:能不能自托管 / 离线跑\n+\n+这是 `CLAUDE.md` 那条「不要引入 UI 库」禁令的唯一破例,\n+所以先把「它到底依赖什么」查清楚,再决定接不接受。\n+\n+**结论先说:可以自托管打包器,但做不到真正离线,所以本站接受远程依赖,\n+并在 UI 上明说「需要联网」。**\n+\n+## 装的版本\n+\n+```\n+@codesandbox/sandpack-react 2.20.0\n+@codesandbox/sandpack-client 2.19.8\n+```\n+\n+## 它到底往外连什么\n+\n+以下不是从文档抄的,是从 `node_modules` 里的产物直接查出来的。\n+\n+**① 打包器(bundler)跑在一个远程 iframe 里。**\n+默认地址由 client 的版本号推出来 ——\n+`node_modules/@codesandbo","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8b45be32dffef6c8bca0fda0b9d46352d4b2e316/docs%2Fsandpack-evaluation.md"}]},"68c35de54571fa9d3732144ab7bb060bd4145cac":{"sha":"68c35de54571fa9d3732144ab7bb060bd4145cac","stats":{"additions":243,"deletions":0,"total":243},"files":[{"filename":"docs/ia-audit-round3.md","previous_filename":null,"status":"added","additions":243,"deletions":0,"changes":243,"patch":"@@ -0,0 +1,243 @@\n+# IA 审计 · 第三轮\n+\n+用户反馈原话:**「打开看了一下觉得有点乱,无从下手,不知道该怎么开始。」**\n+\n+前两轮改过首页 hero 和顶栏(记录在 `components/home.tsx` 与\n+`components/app-shell.tsx` 的头部注释里),用户仍然觉得乱 —— 所以这一轮不碰那两处,\n+往下一层找。\n+\n+审计方式:清空 `localStorage`,以全新用户身份实走\n+「首页 → 开始第一课 → 练习 → 考场」以及「首页 → 四档任一入口」,\n+用 DOM 断言数每一页的**可见**术语数、可见去处数、可见文字量。\n+(不用抓 HTML 数 —— 第一次那样做时把 RSC payload 里的 `$L1b` 误当成了「L1」。)\n+\n+---\n+\n+## 一、实测数据\n+\n+| 页面 | 可见分类术语 | 可见去处 | 可见文字 | 筛选 ch","blob_url":"https://github.com/renrenmimi/DrillLab/blob/68c35de54571fa9d3732144ab7bb060bd4145cac/docs%2Fia-audit-round3.md"}]},"fb93c1c0dc2dcfb125592d7bfa156317c1b7c8f2":{"sha":"fb93c1c0dc2dcfb125592d7bfa156317c1b7c8f2","stats":{"additions":11,"deletions":0,"total":11},"files":[{"filename":".claude/launch.json","previous_filename":null,"status":"added","additions":11,"deletions":0,"changes":11,"patch":"@@ -0,0 +1,11 @@\n+{\n+ \"version\": \"0.0.1\",\n+ \"configurations\": [\n+ {\n+ \"name\": \"drill-lab\",\n+ \"runtimeExecutable\": \"npm\",\n+ \"runtimeArgs\": [\"run\", \"dev\", \"--\", \"-p\", \"3480\"],\n+ \"port\": 3480\n+ }\n+ ]\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/fb93c1c0dc2dcfb125592d7bfa156317c1b7c8f2/.claude%2Flaunch.json"},{"filename":"docs/drill.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/fb93c1c0dc2dcfb125592d7bfa156317c1b7c8f2/docs%2Fdrill.jpg"},{"filename":"docs/home.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/fb93c1c0dc2dcfb125592d7bfa156317c1b7c8f2/docs%2Fhome.jpg"}]},"6c01250eb82c32b60650234e12b7ed076678f18f":{"sha":"6c01250eb82c32b60650234e12b7ed076678f18f","stats":{"additions":523,"deletions":0,"total":523},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"added","additions":523,"deletions":0,"changes":523,"patch":"@@ -0,0 +1,523 @@\n+# CLAUDE.md — DrillLab\n+\n+新会话先读完这份文件再动手。产品说明见 [README.md](README.md)。\n+\n+## 这是什么\n+\n+一个**刷题 App**,四条主线:\n+\n+```\n+/drill 八股题库 105 道问答,题库模式 + 抽认卡\n+/code Coding 题 25 道,题面 + 讲解 + 门后答案;21 道能在浏览器里跑测试\n+/arena 考场 空文件夹、计时、无提示、答案锁死到交卷;**故意不给运行环境**\n+/mock 模拟考 2 套,换壳验收\n+/path 课程归档 75 节课文 —— 「题目背后的讲解」,不是主线\n+```\n+\n+**课文一行不删。** 它是资产(`content/exams/**` 约 3.8 万行,全部本机实测过","blob_url":"https://github.com/renrenmimi/DrillLab/blob/6c01250eb82c32b60650234e12b7ed076678f18f/CLAUDE.md"}]},"7e45aca2d0b7b6584f26393c9821342b848b840e":{"sha":"7e45aca2d0b7b6584f26393c9821342b848b840e","stats":{"additions":110,"deletions":0,"total":110},"files":[{"filename":"README.md","previous_filename":null,"status":"added","additions":110,"deletions":0,"changes":110,"patch":"@@ -0,0 +1,110 @@\n+# DrillLab — Practising Until You Can Do It Alone\n+\n+A study app I built for myself. The goal is not \"I understood the solution\" — it is\n+**writing it in an empty folder, with no answer in front of you**.\n+\n+## Where the material comes from\n+\n+While learning React, GraphQL and Spring Boot, I kept running into problems and concepts\n+that were more interesting than the tutorials around them — some fr","blob_url":"https://github.com/renrenmimi/DrillLab/blob/7e45aca2d0b7b6584f26393c9821342b848b840e/README.md"}]},"7eb5240d8d4a174fbb9d2500c9bdbae0a26bf25b":{"sha":"7eb5240d8d4a174fbb9d2500c9bdbae0a26bf25b","stats":{"additions":2,"deletions":2,"total":4},"files":[{"filename":"app/layout.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -12,11 +12,11 @@ export const metadata: Metadata = {\n // (见 lib/locale.tsx:两份文字都进 HTML,CSS 藏一份)。所以标题切不了语言。\n // 并排写比只写一种好:两种语言的用户在标签页和搜索结果里都认得出。\n title: {\n- default: \"DrillLab · 把 Online Assessment 练到能独立完成 / Train for your online assessment\",\n+ default: \"DrillLab · 练到能独立写出来 / Practising until you can do it alone\",\n template: \"%s · DrillLab\",\n },\n description:\n- \"一个多考试的前端训练平台:105 道面试八股、25 道 co","blob_url":"https://github.com/renrenmimi/DrillLab/blob/7eb5240d8d4a174fbb9d2500c9bdbae0a26bf25b/app%2Flayout.tsx"}]},"2854ce1a45f8b7788979211b33a906a55ab41019":{"sha":"2854ce1a45f8b7788979211b33a906a55ab41019","stats":{"additions":841,"deletions":218,"total":1059},"files":[{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":393,"deletions":165,"changes":558,"patch":"@@ -1,70 +1,83 @@\n \"use client\";\n \n-// 外壳:顶栏 + 左侧课程目录 + 主内容(+ 可选右侧目录栏)。\n+// 外壳:顶栏 + 左侧学习路径 + 主内容(+ 可选右侧目录栏)。\n //\n-// 【导航的分工 —— 这一版专门为了解决「到底看顶栏还是看侧栏」】\n+// 【为什么又改了导航 —— 上一版仍然是错的】\n //\n-// 顶栏 = 去哪儿。全站级的五个目的地,跟当前学到哪无关。\n-// 侧栏 = 我在学什么。**只在课程页和模拟考页出现**,而且只展开当前这一门。\n+// 上一版的分工是「顶栏 = 去哪儿,侧栏 = 我在学什么,侧栏只在课程页出现」。\n+// 使用者的反馈是:那七个已上线的 app「你点左边那栏,一步一步做,就不会错过\n+// 任何信息」,而这个不行 ——「你不知道该点左边还是该点 header 那些链接」。\n //\n-// 之前的问题:侧栏在每一页都显示全部课程的目","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/components%2Fapp-shell.tsx"},{"filename":"components/drill-list.tsx","previous_filename":null,"status":"modified","additions":38,"deletions":4,"changes":42,"patch":"@@ -15,6 +15,8 @@ import type { DrillTrack } from \"@/content/types\";\n import { DrillAnswer } from \"./drill-answer\";\n import { DrillCard, DrillEmptyIfNone, DrillListStatus, DrillSearchBox, type MarkFilter } from \"./drill-card\";\n import { DrillProgressStrip } from \"./drill-marks\";\n+import { lessonRef } from \"@/content/path\";\n+import { lessonPath } from \"@/content/nav\";\n import { DRILL_PAGE, drillListHref, drillMatchesK","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/components%2Fdrill-list.tsx"},{"filename":"components/drill-query.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":1,"changes":12,"patch":"@@ -9,14 +9,23 @@\n // 如果把它写在 \"use client\" 文件里,服务端 import 到的会是一个客户端引用\n // 代理,调用时直接报错 —— 这是 RSC 的规则,不是风格问题。\n \n-/** 列表页认的四个参数 */\n+/** 列表页认的五个参数 */\n export interface DrillQuery {\n /** 方向:html / css / js / react / node / db / web,或 all */\n track?: string;\n /** 掌握状态:known / fuzzy / unknown / none(未做),或 all */\n mark?: string;\n /** 关键词或题库编号(`#279` 和 `279` 都认) */\n q?: string;\n+ /**\n+ * 只看某一节课的题。侧栏用它。\n+ *\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/components%2Fdrill-query.tsx"},{"filename":"components/home.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":1,"changes":5,"patch":"@@ -14,6 +14,7 @@\n \n import Link from \"next/link\";\n import { ARENA, CODING, DRILLS, NAV, examPath, lessonPath, navLessonsOf } from \"@/content/nav\";\n+import { pathGroups } from \"@/content/path\";\n import { useProgress } from \"@/lib/progress\";\n import { LayerMap } from \"./lesson-kit\";\n import { T } from \"./t\";\n@@ -338,7 +339,9 @@ export function Home() {\n <T zh=\"你的进度\" en=\"Your progress\" />\n </h","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/components%2Fhome.tsx"},{"filename":"components/learning-path.tsx","previous_filename":null,"status":"modified","additions":63,"deletions":22,"changes":85,"patch":"@@ -11,6 +11,7 @@\n \n import Link from \"next/link\";\n import { NAV, lessonPath, navStages } from \"@/content/nav\";\n+import { pathGroups } from \"@/content/path\";\n import { useProgress } from \"@/lib/progress\";\n import { T } from \"./t\";\n \n@@ -25,13 +26,18 @@ export function LearningPath() {\n // 根子上的原因是:八股本来就是并行轨道,不是「走完 8 个阶段之后」。\n // 现在改成「按考试分组 + 组内序号」,stage 字段的值形如「React · 第 3 部分」。\n //\n- // 分组键用 examId,顺序直接用 NAV 的数组顺","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/components%2Flearning-path.tsx"},{"filename":"content/coding.ts","previous_filename":null,"status":"modified","additions":19,"deletions":2,"changes":21,"patch":"@@ -989,7 +989,7 @@ const SPECS: Spec[] = [\n difficulty: 3,\n minutes: 90,\n from: \"g-rebuild-subgraph\",\n- explain: \"g-task1-loader\",\n+ explain: \"g-read-task1\",\n runnable: false,\n commands: [\n { cmd: \"npm test\", expect: \"10 passed\" },\n@@ -1003,7 +1003,7 @@ const SPECS: Spec[] = [\n difficulty: 3,\n minutes: 75,\n from: \"g-rebuild-controller\",\n- explain: \"g-task2-endpoints\",\n+","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/content%2Fcoding.ts"},{"filename":"content/nav.ts","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -2845,7 +2845,7 @@ const PAYLOAD: NavPayload = {\n \"minutes\": 90,\n \"runnable\": false,\n \"hasSandbox\": false,\n- \"explainLessonId\": \"g-task1-loader\",\n+ \"explainLessonId\": \"g-read-task1\",\n \"requirementCount\": 13\n },\n {\n@@ -2856,7 +2856,7 @@ const PAYLOAD: NavPayload = {\n \"minutes\": 75,\n \"runnable\": false,\n \"hasSandbox\": false,\n- \"explainLessonId\": \"g-task","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/content%2Fnav.ts"},{"filename":"content/path.ts","previous_filename":null,"status":"added","additions":104,"deletions":0,"changes":104,"patch":"@@ -0,0 +1,104 @@\n+// 学习路径的派生视图 —— 手写文件。\n+// **不要**把这些放进 content/nav.ts:那份是 gen:nav 生成的,会被整体覆盖。\n+//\n+// 【为什么需要这一层】\n+// nav.ts 给的是两套互不相通的数据:\n+// 一套是「五门课 → 模块 → 课文」,只能从侧栏进;\n+// 一套是「105 道八股 / 25 道 coding / 7 道考场题」三张全量表,只能从顶栏进。\n+// 同一批材料被切成两种形状,于是学的人得自己猜「现在该点左边还是点上边」。\n+//\n+// 这一层把第二套挂回第一套:给定一门课(或一节课),它有哪些题。\n+// 归属方式三条主线各不相同,所以反查逻辑只应该有一份:\n+// drill 自带 examId 和 lessonId → 能精确到课文\n+// arena 自带 examId ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/content%2Fpath.ts"},{"filename":"styles/drill.css","previous_filename":null,"status":"modified","additions":47,"deletions":0,"changes":47,"patch":"@@ -777,11 +777,58 @@\n color: var(--ink-3);\n }\n \n+/* ---------- 「只看这一节」的范围提示 ----------\n+ 侧栏每节课那行的「八股 N」链到 /drill?lesson=…。落地时必须一眼看出\n+ 两件事:现在只看这一节(否则会以为题库只有 12 道),以及怎么回到全库。 */\n+\n+.lesson-scope {\n+ display: flex;\n+ align-items: baseline;\n+ flex-wrap: wrap;\n+ gap: 4px 10px;\n+ padding: 9px 13px;\n+ margin-bottom: 20px;\n+ font-size: 13.5px;\n+ background: var(--accent-wash);\n+ border: 1px solid var(--accent-","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/styles%2Fdrill.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":160,"deletions":21,"changes":181,"patch":"@@ -10,18 +10,6 @@\n min-height: 100vh;\n }\n \n-/* 首页 / 学习路径 / 练习场 / 模拟考索引 / 速查 —— 这些页面没有课程目录,\n- 整个第一列收掉,内容全宽。见 app-shell.tsx 顶部的分工说明。 */\n-.shell[data-nav=\"off\"] {\n- grid-template-columns: minmax(0, 1fr);\n-}\n-\n-/* 桌面上没有课程目录就整块不渲染视觉;窄屏下面的媒体查询会把它放回来,\n- 因为窄屏的抽屉里还装着主导航(顶栏在窄屏是隐藏的)。 */\n-.shell[data-nav=\"off\"] .sidebar {\n- display: none;\n-}\n-\n /* 抽屉里的主导航 —— 只在窄屏出现。\n 之前窄屏下 .topbar-nav 被 display:none,而抽屉里只有课程目录,\n 于","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2854ce1a45f8b7788979211b33a906a55ab41019/styles%2Fshell.css"}]},"b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd":{"sha":"b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd","stats":{"additions":661,"deletions":53,"total":714},"files":[{"filename":"app/code/[id]/page.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":1,"changes":3,"patch":"@@ -1,5 +1,6 @@\n import { CodingDetail } from \"@/components/coding-detail\";\n import { allCodingProblems, codingProblemById } from \"@/content/registry\";\n+import { slashTitle } from \"@/content/path\";\n \n export function generateStaticParams() {\n return allCodingProblems().map((p) => ({ id: p.id }));\n@@ -10,7 +11,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin\n const problem = co","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/app%2Fcode%2F%5Bid%5D%2Fpage.tsx"},{"filename":"app/exams/[examId]/[lessonId]/page.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":1,"changes":9,"patch":"@@ -1,6 +1,7 @@\n import { notFound } from \"next/navigation\";\n import { LessonBody } from \"@/components/lesson-body\";\n import { EXAMS, findLesson, lessonsOf } from \"@/content/registry\";\n+import { slashTitle } from \"@/content/path\";\n \n export function generateStaticParams() {\n return EXAMS.flatMap((exam) =>\n@@ -16,7 +17,13 @@ export async function generateMetadata({\n const { examId, lessonId } = await params;\n co","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/app%2Fexams%2F%5BexamId%5D%2F%5BlessonId%5D%2Fpage.tsx"},{"filename":"app/exams/[examId]/page.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":1,"changes":6,"patch":"@@ -1,6 +1,7 @@\n import { notFound } from \"next/navigation\";\n import { ExamOverview } from \"@/components/exam-overview\";\n import { EXAMS, examById } from \"@/content/registry\";\n+import { slashTitle } from \"@/content/path\";\n \n export function generateStaticParams() {\n return EXAMS.map((e) => ({ examId: e.id }));\n@@ -14,7 +15,10 @@ export async function generateMetadata({\n const { examId } = await params;\n const e","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/app%2Fexams%2F%5BexamId%5D%2Fpage.tsx"},{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":16,"deletions":5,"changes":21,"patch":"@@ -50,6 +50,7 @@ import {\n drillsOfExam,\n drillsOfLesson,\n pathGroups,\n+ stageEn,\n } from \"@/content/path\";\n import { useLocale } from \"@/lib/locale\";\n import { useProgress } from \"@/lib/progress\";\n@@ -162,7 +163,7 @@ function ExamNode({\n <span className=\"side-exam-idx tabular\">\n {num === undefined ? \"—\" : String(num).padStart(2, \"0\")}\n </span>\n- {exam.shortTitle}\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fapp-shell.tsx"},{"filename":"components/coding-detail.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":1,"changes":5,"patch":"@@ -110,7 +110,9 @@ export function CodingDetail({ id }: { id: string }) {\n </nav>\n \n <div className=\"page-head\">\n- <h1 className=\"page-title serif\">{problem.title}</h1>\n+ <h1 className=\"page-title serif\">\n+ <T zh={problem.title} en={problem.titleEn} />\n+ </h1>\n <div className=\"lesson-meta\" style={{ marginTop: 14 }}>\n <span className=\"tag\">{C","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fcoding-detail.tsx"},{"filename":"components/coding-list.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":1,"changes":4,"patch":"@@ -205,7 +205,9 @@ export function CodingList({ query }: { query: CodingQuery }) {\n {shown.map((p) => (\n <li className=\"cd-row\" key={p.id}>\n <Link className=\"cd-row-main\" href={codingPath(p.id)}>\n- <span className=\"cd-row-title\">{p.title}</span>\n+ <span className=\"cd-row-title\">\n+ <T zh={p.title} en={p.titleEn} />\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fcoding-list.tsx"},{"filename":"components/coding-progress.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":2,"changes":13,"patch":"@@ -19,7 +19,16 @@ export function CodingDoneBadge({ id }: { id: string }) {\n }\n \n /** 详情页底部的打勾 */\n-export function CodingDoneToggle({ id, title }: { id: string; title: string }) {\n+export function CodingDoneToggle({\n+ id,\n+ title,\n+ titleEn,\n+}: {\n+ id: string;\n+ title: string;\n+ /** 英文标题 —— 下面那句话把标题嵌进句子里,英文那半不能嵌中文标题 */\n+ titleEn?: string;\n+}) {\n const { ready, codingDone, toggleCoding } = useProgress();\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fcoding-progress.tsx"},{"filename":"components/coding-workspace.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":1,"changes":4,"patch":"@@ -18,6 +18,7 @@ import type { LocalizedString } from \"./t\";\n export function CodingWorkspace({\n id,\n title,\n+ titleEn,\n spec,\n commands,\n localWhy,\n@@ -26,6 +27,7 @@ export function CodingWorkspace({\n }: {\n id: string;\n title: string;\n+ titleEn?: string;\n spec?: SandboxSpec;\n commands?: { cmd: string; expect: string }[];\n localWhy: LocalizedString;\n@@ -50,7 +52,7 @@ export function CodingWorks","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fcoding-workspace.tsx"},{"filename":"components/exam-overview.tsx","previous_filename":null,"status":"modified","additions":22,"deletions":7,"changes":29,"patch":"@@ -5,6 +5,7 @@\n \n import Link from \"next/link\";\n import { navExam, lessonPath, mockPath } from \"@/content/nav\";\n+import { stageEn } from \"@/content/path\";\n import { useProgress } from \"@/lib/progress\";\n import { T } from \"./t\";\n import { FileExplorer } from \"./lesson-kit\";\n@@ -44,8 +45,12 @@ export function ExamOverview({ examId }: { examId: string }) {\n <div className=\"eyebrow\">\n {exam.categor","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fexam-overview.tsx"},{"filename":"components/home.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":2,"changes":8,"patch":"@@ -212,7 +212,9 @@ export function Home() {\n <span className=\"hero-cta-main\">\n <T zh=\"开始第一课\" en=\"Start the first lesson\" />\n </span>\n- <span className=\"hero-cta-sub\">{firstLesson.lesson.title}</span>\n+ <span className=\"hero-cta-sub\">\n+ <T zh={firstLesson.lesson.title} en={firstLesson.lesson.titleEn} />\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fhome.tsx"},{"filename":"components/learning-path.tsx","previous_filename":null,"status":"modified","additions":15,"deletions":7,"changes":22,"patch":"@@ -11,7 +11,7 @@\n \n import Link from \"next/link\";\n import { NAV, lessonPath, navStages } from \"@/content/nav\";\n-import { pathGroups } from \"@/content/path\";\n+import { pathGroups, stageEn } from \"@/content/path\";\n import { useProgress } from \"@/lib/progress\";\n import { T } from \"./t\";\n \n@@ -135,7 +135,10 @@ export function LearningPath() {\n {String(ei + 1).padStart(2, \"0\")}\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Flearning-path.tsx"},{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"modified","additions":21,"deletions":7,"changes":28,"patch":"@@ -12,6 +12,7 @@\n \n import Link from \"next/link\";\n import { examPath, findLesson, lessonPath, prevNextLesson } from \"@/content/registry\";\n+import { stageEn } from \"@/content/path\";\n import { CodeBlock } from \"./code\";\n import { ExerciseView } from \"./exercise\";\n import { LessonDoneBar, LessonToc, LessonVisit } from \"./lesson-islands\";\n@@ -69,12 +70,15 @@ export function LessonBody({ examId, lessonId }: { examId: str","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Flesson-body.tsx"},{"filename":"components/lesson-kit.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":5,"changes":14,"patch":"@@ -25,11 +25,14 @@ export function LessonHeader({\n minutes,\n tags,\n }: {\n- crumbs: { label: string; href?: string }[];\n+ // label / title / blurb 收 ReactNode,是为了能直接塞 <T zh en />。\n+ // 课程和课文标题现在是双语的(content/types.ts 的 titleEn),\n+ // 而 <T> 渲染的是两个 span,不是字符串。\n+ crumbs: { label: ReactNode; href?: string }[];\n index: number;\n total: number;\n- title: string;\n- blurb: string;\n+ title: ReactNode;\n+ blurb: R","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Flesson-kit.tsx"},{"filename":"components/practice-focus.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":2,"changes":11,"patch":"@@ -50,9 +50,16 @@ export function PracticeFocus({ activeExam }: { activeExam: string }) {\n \n return (\n <p className=\"practice-focus\">\n+ {/* 课程名本身也是双语的,所以这句话不能用模板字符串拼 ——\n+ 英文那半必须用 shortTitleEn,否则一句英文里嵌一个中文课程名。 */}\n <T\n- en={`Filtered to ${exam.shortTitle} — the course you are on. Use “All” above to see everything.`}\n- zh={`已筛到你正在学的《${exam.shortTitle}》。想看全部就点上面的「全部」。`}\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fpractice-focus.tsx"},{"filename":"components/practice-page.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":3,"changes":8,"patch":"@@ -150,7 +150,7 @@ export function PracticePage({ query }: { query: PracticeQuery }) {\n data-on={exam === e.id}\n href={hrefWith(query, { exam: e.id, page: \"1\" })}\n >\n- {e.shortTitle}\n+ <T zh={e.shortTitle} en={e.shortTitleEn} />\n </Link>\n ))}\n </div>\n@@ -256,9 +256,11 @@ export function PracticePage({ query }: { query: Pr","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fpractice-page.tsx"},{"filename":"components/search.tsx","previous_filename":null,"status":"modified","additions":31,"deletions":7,"changes":38,"patch":"@@ -16,6 +16,7 @@ import Link from \"next/link\";\n import { usePathname, useRouter } from \"next/navigation\";\n import { useEffect, useMemo, useRef, useState } from \"react\";\n import { ARENA, CODING, DRILLS, NAV, arenaPath, codingPath, drillPath, examPath, lessonPath, mockPath, navLessonsOf } from \"@/content/nav\";\n+import { stageEn } from \"@/content/path\";\n import { useT } from \"@/lib/locale\";\n import { allText, L, Loc, T","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/components%2Fsearch.tsx"},{"filename":"content/coding.ts","previous_filename":null,"status":"modified","additions":26,"deletions":0,"changes":26,"patch":"@@ -26,6 +26,7 @@ import { tested } from \"./helpers\";\n interface Spec {\n id: string;\n title: string;\n+ titleEn?: string;\n track: CodingProblem[\"track\"];\n difficulty: 1 | 2 | 3;\n minutes: number;\n@@ -595,6 +596,7 @@ const SPECS: Spec[] = [\n {\n id: \"timer\",\n title: \"计时器(useEffect 清理函数)\",\n+ titleEn: \"Timer (useEffect cleanup)\",\n track: \"react\",\n difficulty: 2,\n minutes: 25,\n@@ -615,6 ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fcoding.ts"},{"filename":"content/exams/cab-booking.tsx","previous_filename":null,"status":"modified","additions":23,"deletions":0,"changes":23,"patch":"@@ -385,12 +385,18 @@ describe(\"React: Cab Booking\", () => {\n const cabBooking: Exam = {\n id: \"cab-booking\",\n title: \"React Cab Booking(Context 版)\",\n+ titleEn: \"React Cab Booking (Context version)\",\n shortTitle: \"Cab Booking\",\n+ shortTitleEn: \"Cab Booking\",\n description:\n \"一个用 Context 管全局状态的打车小应用。四个页面、一个 Context、四个测试。练的是「Context 在一个真实多页应用里怎么用」—— Provider 放在哪一层、一个 action 同时改两个 state、消费者散在三个组件里。\",\n+ desc","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fcab-booking.tsx"},{"filename":"content/exams/fed-part1.tsx","previous_filename":null,"status":"modified","additions":13,"deletions":0,"changes":13,"patch":"@@ -297,13 +297,17 @@ export const gqlBasics: Module = {\n id: \"gql-basics\",\n stage: \"Federation · 第 1 部分\",\n title: \"GraphQL 基础\",\n+ titleEn: \"GraphQL basics\",\n summary:\n \"schema、type、field、query、resolver、非空与列表。全部用 node-subgraph 的真实 schema.graphql 当例子 —— 这份 schema 的每个细节后面都会变成考点。\",\n+ summaryEn:\n+ \"schema, type, field, query, resolver, non-null and lists. Every example is the real schema.graphql from nod","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Ffed-part1.tsx"},{"filename":"content/exams/fed-part2.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -114,13 +114,17 @@ export const fedTask1: Module = {\n id: \"fed-task1\",\n stage: \"Federation · 第 3 部分\",\n title: \"Task 1 · subgraph resolver 逐项拆解\",\n+ titleEn: \"Task 1 · the subgraph resolvers, one at a time\",\n summary:\n \"四个 TODO 加三处埋雷。每个 TODO 都走同一套流程:读题 → 考什么 → 先想再写 → 分步实现 → 完整答案 → 为什么成立 → 常见错法 → 迁移。\",\n+ summaryEn:\n+ \"Four TODOs plus three planted bugs. Every TODO follows the same steps: read the tas","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Ffed-part2.tsx"},{"filename":"content/exams/fed-part3.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -214,13 +214,17 @@ export const fedTask2: Module = {\n id: \"fed-task2\",\n stage: \"Federation · 第 4 部分\",\n title: \"Task 2 · Spring Boot REST 控制器\",\n+ titleEn: \"Task 2 · the Spring Boot REST controller\",\n summary:\n \"六个端点,业务逻辑全都给好了。真正考的是「HTTP 状态码选对了吗」和「异常该谁处理」—— 而这两点恰好是那五个测试只抓住一半的地方。\",\n+ summaryEn:\n+ \"Six endpoints, with all the business logic already written for you. What is really tested is whether you","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Ffed-part3.tsx"},{"filename":"content/exams/fed-part4.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":0,"changes":5,"patch":"@@ -241,13 +241,17 @@ export const fedMastery: Module = {\n id: \"fed-mastery\",\n stage: \"Federation · 第 6 部分\",\n title: \"综合 Debug 与从零重写\",\n+ titleEn: \"Mixed debugging, and rewriting from an empty directory\",\n summary:\n \"把 GraphQL 和 Spring 两边的典型故障集中练一遍,然后在没有答案的情况下从空目录重建整个 subgraph 和整个控制器。\",\n+ summaryEn:\n+ \"Practice the common failures on both the GraphQL side and the Spring side in one place, then rebuild","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Ffed-part4.tsx"},{"filename":"content/exams/foundations.tsx","previous_filename":null,"status":"modified","additions":24,"deletions":0,"changes":24,"patch":"@@ -69,12 +69,18 @@ const SUBGRAPH_PKG = `{\n const foundations: Exam = {\n id: \"foundations\",\n title: \"地基 · 项目怎么跑起来,JS/TS 到底要会哪些\",\n+ titleEn: \"Foundations · how a project runs, and the JS/TS you actually need\",\n shortTitle: \"地基 · 项目与语言\",\n+ shortTitleEn: \"Foundations · project and language\",\n description:\n \"在动 React 和 GraphQL 之前,先把「一个 JavaScript 项目是怎么运行的」搞清楚:Node、npm、package.json、scripts、目录结构、怎么跑测试、报错该从哪看","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Ffoundations.tsx"},{"filename":"content/exams/graphql-federation.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -1287,12 +1287,18 @@ function createReviewerLoader(reviewerDataSource) {\n const fedExam: Exam = {\n id: \"graphql-federation\",\n title: \"GraphQL Federation Capstone\",\n+ titleEn: \"GraphQL Federation Capstone\",\n shortTitle: \"Federation 考试\",\n+ shortTitleEn: \"Federation exam\",\n description:\n \"对应 graphql-federation-practice 这个真实项目:一个 Apollo Federation subgraph(Node.js)加一个 Spring Boot REST 微服务,再加两道书面题。从「Graph","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fgraphql-federation.tsx"},{"filename":"content/exams/interview.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -32,12 +32,18 @@ import { ivTs } from \"./iv-ts\";\n const interview: Exam = {\n id: \"interview\",\n title: \"前端面试八股 · 99 问 + 16 道 coding\",\n+ titleEn: \"Frontend interview preparation · 99 questions + 16 coding problems\",\n shortTitle: \"面试八股\",\n+ shortTitleEn: \"Interview questions\",\n description:\n \"一份作者做过的前端面试题整理,按 HTML / CSS / JavaScript / React / Node / 数据库 / 网络分好组,每道题给「一句话答案 + 展开 + 会被追问什么」。最后一节把 16 道 coding","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Finterview.tsx"},{"filename":"content/exams/iv-backend.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -9,15 +9,19 @@ export const ivBackend: Module = {\n id: \"iv-backend\",\n stage: \"面试 · 第 6 部分\",\n title: \"Node、数据库与网络\",\n+ titleEn: \"Node, databases and networking\",\n summary:\n \"12 道题。全栈岗和前端岗都会问到这一层:Node 的事件循环、请求响应流程、REST 的 CRUD 映射、关系型 vs 文档型、以及 HTTPS / JWT / CORS / session 这四道安全常客。CORS 那道几乎人人都遇到过,但很多人说不清它到底是谁在拦。\",\n+ summaryEn:\n+ \"12 questions. Both full-stack and frontend roles ask about this layer: th","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-backend.tsx"},{"filename":"content/exams/iv-basics.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":0,"changes":5,"patch":"@@ -15,15 +15,19 @@ export const ivBasics: Module = {\n id: \"iv-basics\",\n stage: \"面试 · 第 1 部分\",\n title: \"HTML 与 CSS\",\n+ titleEn: \"HTML and CSS\",\n summary:\n \"13 道题。这两块最容易被轻视 —— 前端面试第一轮几乎必问,而且问的都是「你平时到底有没有想过为什么」。盒模型、Flex 与 Grid 的分工、事件冒泡与捕获、语义化标签、无障碍。\",\n+ summaryEn:\n+ \"13 questions. These two areas are the easiest to underestimate. The first round of a frontend interview almost always covers them, and th","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-basics.tsx"},{"filename":"content/exams/iv-coding.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -926,15 +926,19 @@ export const ivCoding: Module = {\n id: \"iv-coding\",\n stage: \"面试 · 第 7 部分\",\n title: \"Coding 题:对照与补缺\",\n+ titleEn: \"Coding problems: what is covered and what is missing\",\n summary:\n \"16 道 coding 题逐题对照本站已有的练习:9 道已经被 Q1、Q2、五道变式题和两套模拟考覆盖,7 道是真缺口。缺口题的参考解法都在本机跑过测试(24 / 24 加 8 / 8)。\",\n+ summaryEn:\n+ \"The 16 coding problems compared one by one against the exercises already on this site: 9","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-coding.tsx"},{"filename":"content/exams/iv-hand.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -168,8 +168,11 @@ const REF_LRU = `export class LRUCache<K, V> {\n export const ivHand: Module = {\n id: \"iv-hand\",\n title: \"手写题\",\n+ titleEn: \"Implement it yourself\",\n summary:\n \"Phone screen 的主菜:现场手写 debounce、Promise.all、EventEmitter 这类工具函数。8 道题全部带浏览器沙箱,参考解法 40 / 40 实测。DrillLab 自出,不在原题库里。\",\n+ summaryEn:\n+ \"The main item in a phone screen: writing utility functions such as debounce, Promise.all and Ev","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-hand.tsx"},{"filename":"content/exams/iv-js1.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":0,"changes":5,"patch":"@@ -9,15 +9,19 @@ export const ivJsCore: Module = {\n id: \"iv-js-core\",\n stage: \"面试 · 第 2 部分\",\n title: \"JavaScript · 引擎、类型与函数\",\n+ titleEn: \"JavaScript · the engine, types and functions\",\n summary:\n \"22 道题。这一组是 JS 面试的地基:值怎么存、类型怎么转、变量怎么提升、闭包到底是什么。闭包、hoisting、== vs === 这三道几乎每场都问。\",\n+ summaryEn:\n+ \"22 questions. This group is the base of every JavaScript interview: how values are stored, how types convert","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-js1.tsx"},{"filename":"content/exams/iv-js2.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -9,15 +9,19 @@ export const ivJsAsync: Module = {\n id: \"iv-js-async\",\n stage: \"面试 · 第 3 部分\",\n title: \"JavaScript · this、异步与工具链\",\n+ titleEn: \"JavaScript · this, async and tooling\",\n summary:\n \"16 道题。事件循环那道是 JS 面试最能分层次的题 —— 说得出宏任务微任务的执行顺序就上一个档。this 与 call/apply/bind 是老题但仍必问,DOM 事件委托会连到 React 的合成事件。\",\n+ summaryEn:\n+ \"16 questions. The event loop question separates candidates more than any other JavaS","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-js2.tsx"},{"filename":"content/exams/iv-react1.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":0,"changes":5,"patch":"@@ -10,15 +10,19 @@ export const ivReactBasics: Module = {\n id: \"iv-react-basics\",\n stage: \"面试 · 第 4 部分\",\n title: \"React · 基础与组件\",\n+ titleEn: \"React · basics and components\",\n summary:\n \"18 道题。虚拟 DOM 与 diff、reconciliation 这几道是「你到底懂不懂 React 在干什么」的分水岭;props vs state、受控 vs 非受控、状态提升三道直接对应 Q1 那道真题里写过的代码。\",\n+ summaryEn:\n+ \"18 questions. The questions on the virtual DOM, diffing and reconciliation are the d","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-react1.tsx"},{"filename":"content/exams/iv-react2.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -9,15 +9,19 @@ export const ivReactHooks: Module = {\n id: \"iv-react-hooks\",\n stage: \"面试 · 第 5 部分\",\n title: \"React · Hooks、性能与生态\",\n+ titleEn: \"React · Hooks, performance and the ecosystem\",\n summary:\n \"18 道题。useMemo / useCallback / React.memo 三兄弟的区别几乎每场必问,而且要答得出「什么时候不该用」;Redux 那四道是问 Redux 项目经验的固定套路。\",\n+ summaryEn:\n+ \"18 questions. The difference between useMemo, useCallback and React.memo is asked ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-react2.tsx"},{"filename":"content/exams/iv-ts.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":0,"changes":5,"patch":"@@ -16,15 +16,19 @@ export const ivTs: Module = {\n id: \"iv-ts\",\n stage: \"面试 · 第 9 部分\",\n title: \"TypeScript 深度\",\n+ titleEn: \"TypeScript in depth\",\n summary:\n \"6 道题。senior 面试的 TS 深水区:utility types 不止会用还要会手写,再加泛型约束、判别联合与 unknown / any / never。这 6 道全部是 DrillLab 自出,不在原题库里。\",\n+ summaryEn:\n+ \"6 questions. The harder TypeScript topics that senior interviews go into: you have to write utility types yourself, ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Fiv-ts.tsx"},{"filename":"content/exams/react-part1.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -77,13 +77,17 @@ export const reactMentalModel: Module = {\n id: \"react-mental-model\",\n stage: \"React · 第 1 部分\",\n title: \"React 心智模型\",\n+ titleEn: \"The React mental model\",\n summary:\n \"组件、props、state、事件、渲染。每一条都用 react-notes-app 里真实的组件当例子,不用虚构的 Counter。\",\n+ summaryEn:\n+ \"Components, props, state, events, rendering. Every example is a real component from react-notes-app, never an invented Counter.\",\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Freact-part1.tsx"},{"filename":"content/exams/react-part2.tsx","previous_filename":null,"status":"modified","additions":7,"deletions":0,"changes":7,"patch":"@@ -86,13 +86,17 @@ export const reactHooks: Module = {\n id: \"react-hooks\",\n stage: \"React · 第 2 部分\",\n title: \"受控输入、列表渲染与 useEffect\",\n+ titleEn: \"Controlled inputs, rendering lists, and useEffect\",\n summary:\n \"把 NoteForm 从头到尾读懂 —— 它一个文件里就用到了受控输入、useEffect 同步、派生数据和表单提交四件事。\",\n+ summaryEn:\n+ \"Read NoteForm from top to bottom. That one file already does four things: controlled inputs, syncing with useEff","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Freact-part2.tsx"},{"filename":"content/exams/react-part3.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -138,13 +138,17 @@ export const reactQ1: Module = {\n id: \"react-q1\",\n stage: \"React · 第 3 部分\",\n title: \"Q1 Notes Manager · 逐题拆解\",\n+ titleEn: \"Q1 Notes Manager · one task at a time\",\n summary:\n \"严格对应 react-notes-app README 的三个 Task。每一题都走同一套流程:读懂题 → 考什么 → 先想再写 → 分步实现 → 完整答案 → 为什么成立 → 常见错法 → 迁移。\",\n+ summaryEn:\n+ \"Follows the three tasks in the react-notes-app README exactly. Every task takes the same","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Freact-part3.tsx"},{"filename":"content/exams/react-part4.tsx","previous_filename":null,"status":"modified","additions":10,"deletions":0,"changes":10,"patch":"@@ -109,13 +109,17 @@ export const reactQ2: Module = {\n id: \"react-q2\",\n stage: \"React · 第 4 部分\",\n title: \"Q2 · 带并发上限的异步任务调度器\",\n+ titleEn: \"Q2 · an async task runner that limits how many tasks run at the same time\",\n summary:\n \"react-notes-app 的第二道题,和 React 完全无关。纯 TypeScript + 异步。考的是「你能不能自己实现一个 Promise.allSettled 加节流」。\",\n+ summaryEn:\n+ \"The second question in react-notes-app, with no React in it at a","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Freact-part4.tsx"},{"filename":"content/exams/react-part5.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -895,15 +895,19 @@ export const reactVariants: Module = {\n id: \"react-variants\",\n stage: \"React · 第 5 部分\",\n title: \"五道高频变式题\",\n+ titleEn: \"Five variations that come up often\",\n summary:\n \"TodoList、计时器、fetch 取数、递归评论树、主题切换(Context)。第一道是 Q1 的变式,后四道补的是源项目里没有但同类考试常考的东西:useEffect 清理函数、异步三态与竞态、递归组件与树形不可变更新、Context 与 value 记忆化。五道题的参考答案与测试都在本机跑过(36 / 36)。\",\n+ summaryEn:\n+ \"A TodoList, a timer, fetching data","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Freact-part5.tsx"},{"filename":"content/exams/react.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -820,12 +820,18 @@ export default TicketList;`,\n const reactExam: Exam = {\n id: \"react\",\n title: \"React Capstone\",\n+ titleEn: \"React Capstone\",\n shortTitle: \"React 考试\",\n+ shortTitleEn: \"React exam\",\n description:\n \"对应 react-notes-app 这个真实项目:Q1 是一个 Notes Manager 的增删改(CRUD),Q2 是一个带并发上限的异步任务调度器。从「组件是什么」讲到能在空文件夹里重建整个项目。\",\n+ descriptionEn:\n+ \"Built on the real react-notes-app project: Q1 is a Notes Ma","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fexams%2Freact.tsx"},{"filename":"content/nav.ts","previous_filename":null,"status":"modified","additions":187,"deletions":0,"changes":187,"patch":"@@ -27,6 +27,8 @@\n export interface NavLesson {\n id: string;\n title: string;\n+ /** 英文标题。没补就是 undefined,<T> 会回落中文 —— 见 content/types.ts 的 Lesson.titleEn */\n+ titleEn?: string;\n blurb: string;\n minutes: number;\n exerciseCount: number;\n@@ -35,7 +37,9 @@ export interface NavLesson {\n export interface NavModule {\n id: string;\n title: string;\n+ titleEn?: string;\n summary: string;\n+ summaryEn?: string;\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fnav.ts"},{"filename":"content/path.ts","previous_filename":null,"status":"modified","additions":48,"deletions":0,"changes":48,"patch":"@@ -102,3 +102,51 @@ export function pathGroups(): PathGroup[] {\n if (parallel.length) out.push({ kind: \"parallel\", exams: parallel });\n return out;\n }\n+\n+/* ============================================================\n+ stage 的英文形式\n+ ============================================================ */\n+\n+/**\n+ * stage 里那两个中文课程名。别的三个(React / Federation / Cab Booking)\n+ * 本来就是英文,原样带过去。\n+ */\n+const STAGE_COURSE_EN: ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Fpath.ts"},{"filename":"content/types.ts","previous_filename":null,"status":"modified","additions":22,"deletions":0,"changes":22,"patch":"@@ -219,6 +219,18 @@ export type Exercise =\n export interface Lesson {\n id: string;\n title: string;\n+ /**\n+ * 英文标题。\n+ *\n+ * 【为什么是旁挂字段而不是把 title 放宽成 string | Bilingual】\n+ * 全站有 150 处直接读 .title,其中不少要的是纯字符串 ——\n+ * generateMetadata 的 title、aria-label、排序键、搜索索引。\n+ * 放宽联合类型等于在这 150 处都得插一次 pick(),改动面大、回归风险高。\n+ * 旁挂之后:那 150 处一行不用动(继续拿中文),只有真正显示标题的地方\n+ * 改成 <T zh={l.title} en={l.titleEn} />,而 <T> 缺 en 时自动回","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/content%2Ftypes.ts"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -12,10 +12,14 @@ export function GET() {\n exams: EXAMS.map((e) => ({\n id: e.id,\n title: e.title,\n+ titleEn: e.titleEn,\n shortTitle: e.shortTitle,\n+ shortTitleEn: e.shortTitleEn,\n+ descriptionEn: e.descriptionEn,\n description: e.description,\n category: e.category,\n tests: e.tests,\n+ testsEn: e.testsEn,\n stack: e.stack,\n status: e.status,\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/nav-template.txt","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -27,6 +27,8 @@\n export interface NavLesson {\n id: string;\n title: string;\n+ /** 英文标题。没补就是 undefined,<T> 会回落中文 —— 见 content/types.ts 的 Lesson.titleEn */\n+ titleEn?: string;\n blurb: string;\n minutes: number;\n exerciseCount: number;\n@@ -35,7 +37,9 @@ export interface NavLesson {\n export interface NavModule {\n id: string;\n title: string;\n+ titleEn?: string;\n summary: string;\n+ summaryEn?: string;\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b27bb8a9a18577f3ee0f23e5d7539ee516fd35bd/scripts%2Fnav-template.txt"}]},"1a60d44fdd67b268eeb4bd9a9023706a87a93987":{"sha":"1a60d44fdd67b268eeb4bd9a9023706a87a93987","stats":{"additions":4835,"deletions":447,"total":5282},"files":[{"filename":"app/arena/[id]/page.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":1,"changes":6,"patch":"@@ -4,6 +4,7 @@\n \n import { ArenaBrief } from \"@/components/arena-brief\";\n import { allArena, arenaPublicById } from \"@/content/arena\";\n+import { slashTitle } from \"@/content/path\";\n \n export function generateStaticParams() {\n return allArena().map((a) => ({ id: a.id }));\n@@ -13,7 +14,10 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin\n const { id } = await params;\n const a = ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/app%2Farena%2F%5Bid%5D%2Fpage.tsx"},{"filename":"app/mock/[examId]/[mockId]/page.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":1,"changes":6,"patch":"@@ -1,5 +1,6 @@\n import { MockDetail } from \"@/components/mock-detail\";\n import { EXAMS, examById } from \"@/content/registry\";\n+import { slashTitle } from \"@/content/path\";\n \n export function generateStaticParams() {\n return EXAMS.flatMap((exam) =>\n@@ -15,7 +16,10 @@ export async function generateMetadata({\n const { examId, mockId } = await params;\n const mock = examById(examId)?.mockExams.find((m) => m.id === ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/app%2Fmock%2F%5BexamId%5D%2F%5BmockId%5D%2Fpage.tsx"},{"filename":"components/arena-brief.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":3,"changes":12,"patch":"@@ -44,8 +44,12 @@ export function ArenaBrief({ id }: { id: string }) {\n <div className=\"eyebrow\">\n <T zh=\"开考前\" en=\"Before you start\" />\n </div>\n- <h1 className=\"page-title serif\">{a.title}</h1>\n- <p className=\"page-lede\">{a.scenario}</p>\n+ <h1 className=\"page-title serif\">\n+ <T zh={a.title} en={a.titleEn} />\n+ </h1>\n+ <p classNam","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Farena-brief.tsx"},{"filename":"components/arena-list.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":2,"changes":6,"patch":"@@ -134,9 +134,11 @@ export function ArenaList() {\n <span className=\"exam-idx\">{String(i + 1).padStart(2, \"0\")}</span>\n <div>\n <Link className=\"exam-name\" href={arenaPath(r.nav.id)}>\n- {r.nav.title}\n+ <T zh={r.nav.title} en={r.nav.titleEn} />\n </Link>\n- <p className=\"exam-desc\">{r.nav.scenario}</","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Farena-list.tsx"},{"filename":"components/arena-review.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":2,"changes":8,"patch":"@@ -34,7 +34,9 @@ export function ArenaReview({ id }: { id: string }) {\n <span className=\"crumb-sep\" aria-hidden>\n /\n </span>\n- <Link href={arenaPath(a.id)}>{a.title}</Link>\n+ <Link href={arenaPath(a.id)}>\n+ <T zh={a.title} en={a.titleEn} />\n+ </Link>\n </nav>\n \n <div className=\"page-head\">\n@@ -122,7 +124,9 @@ export function ArenaRe","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Farena-review.tsx"},{"filename":"components/arena-run.tsx","previous_filename":null,"status":"modified","additions":12,"deletions":2,"changes":14,"patch":"@@ -51,7 +51,9 @@ export function ArenaRun({ id }: { id: string }) {\n <div className=\"eyebrow\">\n <T zh=\"考场 · 进行中\" en=\"Arena · in progress\" />\n </div>\n- <h1 className=\"page-title serif\">{a.title}</h1>\n+ <h1 className=\"page-title serif\">\n+ <T zh={a.title} en={a.titleEn} />\n+ </h1>\n <p className=\"page-lede\">{a.scenario}</p>\n <div c","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Farena-run.tsx"},{"filename":"components/coding-detail.tsx","previous_filename":null,"status":"modified","additions":7,"deletions":2,"changes":9,"patch":"@@ -134,9 +134,14 @@ export function CodingDetail({ id }: { id: string }) {\n {/* ---------- §01 题面 ---------- */}\n <Section\n id=\"brief\"\n- lede=\"先把要求读完,再动手。 / Read every requirement before you start.\"\n+ lede={\n+ <T\n+ zh=\"先把要求读完,再动手。\"\n+ en=\"Read every requirement before you start.\"\n+ />\n+ }\n n=\"01\"\n- ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Fcoding-detail.tsx"},{"filename":"components/exam-overview.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -82,7 +82,7 @@ export function ExamOverview({ examId }: { examId: string }) {\n 不是任何一道题的答案。 */}\n <FileExplorer\n showContent\n- title=\"源项目 / Source project\"\n+ title={<T zh=\"源项目\" en=\"Source project\" />}\n files={exam.sourceProjects.map((p) => ({ path: p.path, role: p.role }))}\n />\n </>","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Fexam-overview.tsx"},{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"modified","additions":32,"deletions":10,"changes":42,"patch":"@@ -55,11 +55,24 @@ export function LessonBody({ examId, lessonId }: { examId: string; lessonId: str\n const tocItems = [\n ...lesson.concepts.map((c, i) => ({\n id: c.id,\n- label: `${String(i + 1).padStart(2, \"0\")} ${c.heading}`,\n+ label: (\n+ <>\n+ {String(i + 1).padStart(2, \"0\")} <T zh={c.heading} en={c.headingEn} />\n+ </>\n+ ),\n })),\n- ...(lesson.exercises?.lengt","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Flesson-body.tsx"},{"filename":"components/lesson-islands.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":1,"changes":5,"patch":"@@ -3,6 +3,7 @@\n // 课程页里需要交互的三小块。其余(讲解正文、代码、错例、迁移表)都在服务端渲染,\n // 这样 42 节课的全文不会被打进客户端包。\n \n+import type { ReactNode } from \"react\";\n import { useEffect } from \"react\";\n import { useProgress } from \"@/lib/progress\";\n import { useActiveHeading } from \"@/lib/use-active-heading\";\n@@ -73,7 +74,9 @@ export function LessonDoneBar({\n export function LessonToc({\n items,\n }: {\n- items: { id: string; label: string }[];\n+ // la","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Flesson-islands.tsx"},{"filename":"components/lesson-kit.tsx","previous_filename":null,"status":"modified","additions":50,"deletions":15,"changes":65,"patch":"@@ -72,14 +72,37 @@ export function LessonHeader({\n );\n }\n \n+/**\n+ * 一对平行的中英文数组,逐项渲染成双语列表项。\n+ *\n+ * 【为什么长度不等就整段回落中文】\n+ * 对齐靠的是下标,不是内容。少一条英文,从那一条起后面全部错位 ——\n+ * 第 3 条中文配第 4 条的英文,看起来还挺像那么回事,但说的是别的事。\n+ * 这种错比缺英文难发现得多,所以宁可全中文。\n+ */\n+export function BilingualList({ zh, en }: { zh: string[]; en?: string[] }) {\n+ const paired = en && en.length === zh.length ? en : undefined;\n+ return (\n+ <>\n+ {zh.map((item, i) =>","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Flesson-kit.tsx"},{"filename":"components/mock-detail.tsx","previous_filename":null,"status":"modified","additions":16,"deletions":10,"changes":26,"patch":"@@ -13,7 +13,7 @@ import { examPath } from \"@/content/nav\";\n import { CodeBlock } from \"./code\";\n import { SolutionGate } from \"./hint-panel\";\n import { L, T } from \"./t\";\n-import { AnswerTabs, Section } from \"./lesson-kit\";\n+import { AnswerTabs, BilingualList, Section } from \"./lesson-kit\";\n import { LocalSetup } from \"./local-setup\";\n import { MockScore } from \"./mock-score\";\n \n@@ -39,8 +39,12 @@ export function Mo","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Fmock-detail.tsx"},{"filename":"components/search.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -151,8 +151,8 @@ function buildIndex(deepText: (examId: string, lessonId: string) => string): Hit\n }`,\n },\n title: loc(l.title, l.titleEn),\n- sub: l.blurb,\n- haystack: [l.title, l.titleEn, l.blurb, deepText(exam.id, l.id)]\n+ sub: loc(l.blurb, l.blurbEn),\n+ haystack: [l.title, l.titleEn, l.blurb, l.blurbEn, deepText(exam.id, l.id)]\n .filter(Boolean)\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/components%2Fsearch.tsx"},{"filename":"content/arena.ts","previous_filename":null,"status":"modified","additions":18,"deletions":1,"changes":19,"patch":"@@ -21,41 +21,55 @@ import type { ArenaChallenge, CodeExample, FromScratchExercise, MockExam } from\n import { EXAMS } from \"./exam-list\";\n \n /** 每道考场题的时限与场景 —— 时限按源题的实际规模诚实估,不往短了写 */\n-const META: Record<string, { minutes: number; scenario: string }> = {\n+const META: Record<string, { minutes: number; scenario: string; scenarioEn: string }> = {\n \"r-rebuild-q1\": {\n minutes: 75,\n scenario:\n \"空文件夹。自己起一个 Vi","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Farena.ts"},{"filename":"content/exams/cab-booking.tsx","previous_filename":null,"status":"modified","additions":581,"deletions":37,"changes":618,"patch":"@@ -438,15 +438,25 @@ const cabBooking: Exam = {\n title: \"先读四个测试:它们到底要什么\",\n titleEn: \"Read the four tests first: what exactly they ask for\",\n blurb: \"四个测试全靠 data-testid 找元素。先抄一张 testid 表出来,再动手。\",\n+ blurbEn:\n+ \"All four tests find elements by data-testid. Copy out the list of testids first, then start writing.\",\n minutes: 14,\n objectives: [\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fcab-booking.tsx"},{"filename":"content/exams/fed-part1.tsx","previous_filename":null,"status":"modified","additions":192,"deletions":31,"changes":223,"patch":"@@ -309,15 +309,24 @@ export const gqlBasics: Module = {\n title: \"GraphQL 是什么:一份 schema 加一堆 resolver\",\n titleEn: \"What GraphQL is: one schema plus a set of resolvers\",\n blurb: \"读真实的 schema.graphql,把 type / field / Query / Mutation 一次讲清。\",\n+ blurbEn: \"Read the real schema.graphql and cover type / field / Query / Mutation in one pass.\",\n minutes: 15,\n objectives: [\n \"说清 schema","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Ffed-part1.tsx"},{"filename":"content/exams/fed-part2.tsx","previous_filename":null,"status":"modified","additions":223,"deletions":23,"changes":246,"patch":"@@ -126,15 +126,24 @@ export const fedTask1: Module = {\n title: \"先读题:四个 TODO、三处埋雷、十个测试\",\n titleEn: \"Read the task first: four TODOs, three planted bugs, ten tests\",\n blurb: \"在写第一行 resolver 之前,把要改什么、别人给了什么、判卷标准是什么全摸清。\",\n+ blurbEn: \"Before writing the first line of a resolver, find out what to change, what is already given, and how it will be graded.\",\n minutes: 15,\n objectives: [\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Ffed-part2.tsx"},{"filename":"content/exams/fed-part3.tsx","previous_filename":null,"status":"modified","additions":143,"deletions":17,"changes":160,"patch":"@@ -226,15 +226,24 @@ export const fedTask2: Module = {\n title: \"先看懂给你的东西:Spring 的几个注解和一条请求链路\",\n titleEn: \"Understand what you are given: a few Spring annotations and the path one request takes\",\n blurb: \"没写过 Java 也能看懂 —— 这一节只讲这道题真正需要的那几个概念。\",\n+ blurbEn: \"You do not need Java experience. This lesson covers only the few ideas this task actually needs.\",\n minutes: 16,\n objectives: [\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Ffed-part3.tsx"},{"filename":"content/exams/fed-part4.tsx","previous_filename":null,"status":"modified","additions":107,"deletions":11,"changes":118,"patch":"@@ -253,15 +253,25 @@ export const fedMastery: Module = {\n title: \"Debug Lab · Federation 十种典型故障\",\n titleEn: \"Debug Lab · ten common Federation failures\",\n blurb: \"从「resolver 写了但返回 null」到「composition 失败」,每一种都给真实报错。\",\n+ blurbEn:\n+ \"From a resolver that runs but returns null, to a composition failure. Every case comes with the real error text.\",\n minutes: 22,\n objectives: [\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Ffed-part4.tsx"},{"filename":"content/exams/foundations.tsx","previous_filename":null,"status":"modified","additions":450,"deletions":35,"changes":485,"patch":"@@ -111,15 +111,25 @@ const foundations: Exam = {\n titleEn: \"Node.js, npm, node_modules and the lockfile\",\n blurb:\n \"为什么装个 React 项目会多出几万个文件,以及为什么那个 lock 文件不能随便删。\",\n+ blurbEn:\n+ \"Why installing a React project adds tens of thousands of files, and why you must not delete that lock file.\",\n minutes: 12,\n objectives: [\n \"说清 Node.js 和浏览器里的","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Ffoundations.tsx"},{"filename":"content/exams/graphql-federation.tsx","previous_filename":null,"status":"modified","additions":13,"deletions":1,"changes":14,"patch":"@@ -461,10 +461,15 @@ export { createReviewerLoader, createReviewLoader };`;\n const reviewsMock: MockExam = {\n id: \"book-reviews\",\n title: \"模拟考 B · Book Reviews Subgraph\",\n+ titleEn: \"Mock exam B · Book Reviews Subgraph\",\n mirrors:\n \"与真实 Task 1 相同的考点:entity 与 @key、__resolveReference、字段 resolver 的 parent、schema 可空性决定的兜底策略、DataLoader 防 N+1 及其长度/顺序契约、结构化错误与 correlation id、以及「catch 不要吞掉已结构化错误」。新增三个考点:复合 @key、可","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fgraphql-federation.tsx"},{"filename":"content/exams/iv-backend.tsx","previous_filename":null,"status":"modified","additions":165,"deletions":18,"changes":183,"patch":"@@ -23,19 +23,30 @@ export const ivBackend: Module = {\n title: \"Node 与 Express 四问\",\n titleEn: \"4 questions on Node and Express\",\n blurb: \"Node 的事件循环、请求响应周期、查询参数 vs 路径参数、CRUD。\",\n+ blurbEn:\n+ \"The Node.js event loop, the request and response cycle, query parameters vs path parameters, CRUD.\",\n minutes: 18,\n objectives: [\n \"说出 Node 事件循环的几个阶段以及 nextTick 的特殊位置\",\n \"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-backend.tsx"},{"filename":"content/exams/iv-basics.tsx","previous_filename":null,"status":"modified","additions":119,"deletions":12,"changes":131,"patch":"@@ -29,19 +29,30 @@ export const ivBasics: Module = {\n title: \"HTML 五问\",\n titleEn: \"5 questions on HTML\",\n blurb: \"块级与行内、事件冒泡与捕获、meta、语义化、无障碍。\",\n+ blurbEn:\n+ \"Block and inline elements, event bubbling and capturing, meta tags, semantic elements, accessibility.\",\n minutes: 14,\n objectives: [\n \"说清块级和行内元素的三处实际差别\",\n \"画出事件从 window 到目标再回到 window 的完整路径\",\n \"说明","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-basics.tsx"},{"filename":"content/exams/iv-coding.tsx","previous_filename":null,"status":"modified","additions":344,"deletions":23,"changes":367,"patch":"@@ -940,19 +940,30 @@ export const ivCoding: Module = {\n title: \"16 道题逐题对照\",\n titleEn: \"The 16 problems, compared one by one\",\n blurb: \"哪些已经写过、哪些是缺口、缺的那道到底在考什么。\",\n+ blurbEn:\n+ \"Which ones you have already written, which ones are gaps, and what the missing ones actually test.\",\n minutes: 12,\n objectives: [\n \"知道这 16 道题分别对应本站哪一节课\",\n \"识别出「换了个业务壳但考点相同」的题\",\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-coding.tsx"},{"filename":"content/exams/iv-hand.tsx","previous_filename":null,"status":"modified","additions":161,"deletions":11,"changes":172,"patch":"@@ -183,19 +183,30 @@ export const ivHand: Module = {\n title: \"计时两兄弟:debounce 与 throttle\",\n titleEn: \"Two timing helpers: debounce and throttle\",\n blurb: \"先分清「等你停手」和「匀速放行」,再各写一个。\",\n+ blurbEn:\n+ \"First tell the two apart — wait until the calls stop, versus let one through at a steady rate — then write each one.\",\n minutes: 30,\n objectives: [\n \"一句话说清 debounce 和 throttle","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-hand.tsx"},{"filename":"content/exams/iv-js1.tsx","previous_filename":null,"status":"modified","additions":153,"deletions":17,"changes":170,"patch":"@@ -23,19 +23,30 @@ export const ivJsCore: Module = {\n title: \"引擎与类型十问\",\n titleEn: \"10 questions on the engine and types\",\n blurb: \"引擎、REPL、原始值 vs 引用值、类型转换、== vs ===、短路、var/let/const、传值传引用、Set、Map。\",\n+ blurbEn:\n+ \"The engine, REPL, primitive vs reference values, type conversion, == vs ===, short-circuiting, var/let/const, passing values and references, Set, Map.\",\n minutes: 22,\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-js1.tsx"},{"filename":"content/exams/iv-js2.tsx","previous_filename":null,"status":"modified","additions":175,"deletions":18,"changes":193,"patch":"@@ -23,18 +23,27 @@ export const ivJsAsync: Module = {\n title: \"this 与面向对象三问\",\n titleEn: \"3 questions on this and object-oriented programming\",\n blurb: \"OOP、this 指向的四条规则、call/apply/bind。\",\n+ blurbEn: \"OOP, the four rules for what this points to, call/apply/bind.\",\n minutes: 16,\n objectives: [\n \"按优先级说出 this 指向的四条判定规则\",\n \"分清 call、apply、bind 三者的差别并手写一个 bind\",\n \"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-js2.tsx"},{"filename":"content/exams/iv-react1.tsx","previous_filename":null,"status":"modified","additions":136,"deletions":15,"changes":151,"patch":"@@ -24,19 +24,30 @@ export const ivReactBasics: Module = {\n title: \"React 是什么 · 七问\",\n titleEn: \"7 questions on what React is\",\n blurb: \"React vs Angular、优势、SPA、JSX、虚拟 DOM 与 diff、reconciliation、babel 与 webpack。\",\n+ blurbEn:\n+ \"React vs Angular, the selling points, SPA, JSX, the virtual DOM and diffing, reconciliation, babel and webpack.\",\n minutes: 22,\n objectives: [\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-react1.tsx"},{"filename":"content/exams/iv-react2.tsx","previous_filename":null,"status":"modified","additions":186,"deletions":21,"changes":207,"patch":"@@ -23,19 +23,30 @@ export const ivReactHooks: Module = {\n title: \"Hooks 四问\",\n titleEn: \"4 questions on Hooks\",\n blurb: \"hooks 是什么与为什么、useMemo vs useCallback、React.memo vs useMemo、自定义 hook。\",\n+ blurbEn:\n+ \"What hooks are and why they exist, useMemo vs useCallback, React.memo vs useMemo, custom hooks.\",\n minutes: 20,\n objectives: [\n \"说出 hooks 解决的三个类组件痛点\",\n \"分清 ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-react2.tsx"},{"filename":"content/exams/iv-ts.tsx","previous_filename":null,"status":"modified","additions":61,"deletions":1,"changes":62,"patch":"@@ -31,19 +31,30 @@ export const ivTs: Module = {\n titleEn: \"Utility types: use them, and write them yourself\",\n blurb:\n \"Partial / Pick / Omit / Record 怎么选,mapped type 手写 MyPick 与 MyPartial,conditional type 配 infer 手写 MyReturnType。\",\n+ blurbEn:\n+ \"How to choose between Partial / Pick / Omit / Record, how to write MyPick and MyPartial as mapped types, and how to write MyReturnType with","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Fiv-ts.tsx"},{"filename":"content/exams/react-part1.tsx","previous_filename":null,"status":"modified","additions":176,"deletions":12,"changes":188,"patch":"@@ -89,15 +89,24 @@ export const reactMentalModel: Module = {\n title: \"组件就是一个返回界面的函数\",\n titleEn: \"A component is a function that returns what you see on screen\",\n blurb: \"从这个项目最短的两个文件开始:App.tsx 只有 5 行。\",\n+ blurbEn: \"Start with the two shortest files in this project: App.tsx is only 5 lines.\",\n minutes: 11,\n objectives: [\n \"说清「组件」在 React 里到底是什么\",\n \"看懂 JSX 里的标签、花括号、cla","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Freact-part1.tsx"},{"filename":"content/exams/react-part2.tsx","previous_filename":null,"status":"modified","additions":221,"deletions":16,"changes":237,"patch":"@@ -98,15 +98,24 @@ export const reactHooks: Module = {\n title: \"受控输入:value + onChange 的闭环\",\n titleEn: \"Controlled inputs: the loop between value and onChange\",\n blurb: \"输入框里的字,其实存在 React 的 state 里,不在 DOM 里。\",\n+ blurbEn: \"The text you type sits in React state, not in the DOM.\",\n minutes: 13,\n objectives: [\n \"说清「受控」到底控的是什么\",\n \"写出 value + onChange 的完整闭环\",\n \"知道只","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Freact-part2.tsx"},{"filename":"content/exams/react-part3.tsx","previous_filename":null,"status":"modified","additions":304,"deletions":23,"changes":327,"patch":"@@ -150,15 +150,24 @@ export const reactQ1: Module = {\n title: \"先读题:三个任务、一条硬约束、四个测试\",\n titleEn: \"Read the question first: three tasks, one rule you must not break, four tests\",\n blurb: \"在写第一行代码之前,把题目、约束和判卷标准全部摸清。\",\n+ blurbEn: \"Before writing a single line of code, get clear on the task, the constraints, and how it is graded.\",\n minutes: 13,\n objectives: [\n \"用自己的话复述三个 Task 的验","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Freact-part3.tsx"},{"filename":"content/exams/react-part4.tsx","previous_filename":null,"status":"modified","additions":146,"deletions":20,"changes":166,"patch":"@@ -121,15 +121,24 @@ export const reactQ2: Module = {\n title: \"读题:三条要求,每一条都在指定一种写法\",\n titleEn: \"Reading the question: three requirements, and each one decides how you write it\",\n blurb: \"题面就写在 taskRunner.ts 的文件头注释里。逐条翻译。\",\n+ blurbEn: \"The question is written in the header comment of taskRunner.ts. Take it one requirement at a time.\",\n minutes: 12,\n objectives: [\n \"复述三条要求,并说","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Freact-part4.tsx"},{"filename":"content/exams/react-part5.tsx","previous_filename":null,"status":"modified","additions":459,"deletions":30,"changes":489,"patch":"@@ -909,20 +909,32 @@ export const reactVariants: Module = {\n title: \"变式一 · Todo List\",\n titleEn: \"Variation 1 · Todo List\",\n blurb: \"和 Notes Manager 同一套骨架,多了一个布尔字段、一个筛选、两个批量操作。\",\n+ blurbEn:\n+ \"The same skeleton as the Notes Manager, plus one boolean field, one filter, and two bulk actions.\",\n minutes: 14,\n objectives: [\n \"用 map + 对象展开就地翻转一条数据的布尔字段\",\n \"把「剩余几项」","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Freact-part5.tsx"},{"filename":"content/exams/react.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":0,"changes":11,"patch":"@@ -136,10 +136,15 @@ interface TicketFormProps {\n const ticketMock: MockExam = {\n id: \"support-tickets\",\n title: \"模拟考 A · Support Ticket Board\",\n+ titleEn: \"Mock exam A · Support Ticket Board\",\n mirrors:\n \"与真实 Q1 完全相同的考点:受控输入、列表渲染与 key、useState 的三种不可变更新(增 / 删 / 就地替换)、useEffect 同步外部 prop、派生数据、状态提升、双模式按钮与 id 复用。额外增加一个「筛选」考点 —— 这是这类题最常见的变式方向。\",\n+ mirrorsEn:\n+ \"Exactly the same points as the real Q1: cont","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fexams%2Freact.tsx"},{"filename":"content/nav.ts","previous_filename":null,"status":"modified","additions":92,"deletions":0,"changes":92,"patch":"@@ -30,6 +30,7 @@ export interface NavLesson {\n /** 英文标题。没补就是 undefined,<T> 会回落中文 —— 见 content/types.ts 的 Lesson.titleEn */\n titleEn?: string;\n blurb: string;\n+ blurbEn?: string;\n minutes: number;\n exerciseCount: number;\n }\n@@ -116,7 +117,9 @@ export interface NavCoding {\n export interface NavArena {\n id: string;\n title: string;\n+ titleEn?: string;\n scenario: string;\n+ scenarioEn?: string;\n minut","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Fnav.ts"},{"filename":"content/types.ts","previous_filename":null,"status":"modified","additions":40,"deletions":2,"changes":42,"patch":"@@ -63,8 +63,10 @@ export interface ConceptSection {\n /** 段内锚点 id,用于右侧目录 */\n id: string;\n heading: string;\n+ headingEn?: string;\n /** 一句话副标,写「这一段到底在解决什么」 */\n lede?: string;\n+ ledeEn?: string;\n body: ReactNode;\n /**\n * 英文版正文。给了它,这一段就会长出「中文 / English」两个 tab。\n@@ -233,23 +235,42 @@ export interface Lesson {\n titleEn?: string;\n /** 侧栏用的一行钩子 */\n blurb: string;\n+ blurbEn?: string;\n /** 阅读时长(分钟,诚实","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/content%2Ftypes.ts"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -46,6 +46,7 @@ export function GET() {\n title: l.title,\n titleEn: l.titleEn,\n blurb: l.blurb,\n+ blurbEn: l.blurbEn,\n minutes: l.minutes,\n exerciseCount: l.exercises?.length ?? 0,\n })),\n@@ -92,6 +93,8 @@ export function GET() {\n arena: allArena().map((a) => ({\n id: a.id,\n title: a.title,\n+ titleEn: a.titleEn,\n+ scenarioEn: ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/nav-template.txt","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -30,6 +30,7 @@ export interface NavLesson {\n /** 英文标题。没补就是 undefined,<T> 会回落中文 —— 见 content/types.ts 的 Lesson.titleEn */\n titleEn?: string;\n blurb: string;\n+ blurbEn?: string;\n minutes: number;\n exerciseCount: number;\n }\n@@ -116,7 +117,9 @@ export interface NavCoding {\n export interface NavArena {\n id: string;\n title: string;\n+ titleEn?: string;\n scenario: string;\n+ scenarioEn?: string;\n minut","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1a60d44fdd67b268eeb4bd9a9023706a87a93987/scripts%2Fnav-template.txt"}]},"970a4212fe009039623ca8879566c5eb95fb3ea4":{"sha":"970a4212fe009039623ca8879566c5eb95fb3ea4","stats":{"additions":16595,"deletions":1209,"total":17804},"files":[{"filename":"app/layout.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -26,7 +26,7 @@ export const viewport: Viewport = {\n \n export default function RootLayout({ children }: { children: ReactNode }) {\n return (\n- <html lang=\"zh-CN\" data-theme=\"light\" suppressHydrationWarning>\n+ <html lang=\"en\" data-theme=\"light\" suppressHydrationWarning>\n <head>\n {/* 在 React 接管前定好主题和语言,避免首屏闪一下 */}\n <script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP }} />","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/app%2Flayout.tsx"},{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":2,"changes":8,"patch":"@@ -345,7 +345,9 @@ function ExamNode({\n onClick={onNavigate}\n >\n <span className=\"side-lesson-n\">·</span>\n- <span>{a.title}</span>\n+ <span>\n+ <T zh={a.title} en={a.titleEn} />\n+ </span>\n </Link>\n </li>\n );\n@@ -371,7 +373,9 @@ function ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fapp-shell.tsx"},{"filename":"components/arena-checkoff.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":3,"changes":6,"patch":"@@ -55,7 +55,7 @@ export function ArenaCheckoff({\n id: string;\n title: string;\n minutes: number;\n- commands: { cmd: string; expect: string }[];\n+ commands: { cmd: string; expect: string; expectEn?: string }[];\n /** 提示 + 参考答案 + 讲解链接。自评完成前**不渲染**,不是隐藏。 */\n children: ReactNode;\n }) {\n@@ -129,7 +129,7 @@ export function ArenaCheckoff({\n <span className=\"arena-check-cmd mono\">{c.cmd}</span>\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Farena-checkoff.tsx"},{"filename":"components/arena-run.tsx","previous_filename":null,"status":"modified","additions":35,"deletions":12,"changes":47,"patch":"@@ -40,7 +40,26 @@ export function ArenaRun({ id }: { id: string }) {\n if (!a) notFound();\n \n const exam = navExam(a.sourceExamId);\n+ // 【为什么整份数组按语言选,而不是逐条配对】\n+ // 分组是从「【任务标题】」这种标记行解析出来的,中英两份的分组边界可能\n+ // 落在不同位置。逐条配对会让标题和它下面的条目错开,所以两份各自分组,\n+ // 由 <T> 整块切换。长度不等时英文那份就不给 —— 宁可全中文,不要错位。\n const groups = groupRequirements(a.requirements);\n+ const groupsEn =\n+ a.requirementsEn && a.requirementsEn.length === a.r","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Farena-run.tsx"},{"filename":"components/code.tsx","previous_filename":null,"status":"modified","additions":62,"deletions":14,"changes":76,"patch":"@@ -9,7 +9,7 @@\n import { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\n import { highlight, LANG_LABEL, type Lang } from \"@/lib/highlight\";\n import { useT } from \"@/lib/locale\";\n-import { Loc, T, type LocalizedString } from \"./t\";\n+import { Loc, T, pick, type LocalizedString } from \"./t\";\n import type { CodeExample } from \"@/content/types\";\n \n /* ---------- 复制按钮 ---------- */\n@@ -141,13 +141,2","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fcode.tsx"},{"filename":"components/coding-detail.tsx","previous_filename":null,"status":"modified","additions":47,"deletions":19,"changes":66,"patch":"@@ -23,7 +23,7 @@ import type { CodeExample, CodingProblem, Lesson } from \"@/content/types\";\n import { CodeBlock } from \"./code\";\n import { CodingWorkspace } from \"./coding-workspace\";\n import { HintPanel, SolutionGate } from \"./hint-panel\";\n-import { AnswerTabs, Section } from \"./lesson-kit\";\n+import { AnswerTabs, Section, BilingualList } from \"./lesson-kit\";\n import { L, T, type LocalizedString } from \"./t\";\n \n /* ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fcoding-detail.tsx"},{"filename":"components/coding-list.tsx","previous_filename":null,"status":"modified","additions":18,"deletions":3,"changes":21,"patch":"@@ -265,9 +265,24 @@ export function CodingList({ query }: { query: CodingQuery }) {\n <T zh=\"为什么有的跑不了\" en=\"Why some can't run here\" />\n </div>\n <p className=\"dimmer\" style={{ fontSize: 12.5, lineHeight: 1.6, margin: 0 }}>\n- GraphQL subgraph 要真起一个服务,Spring 那道要 JVM 和 Maven ——\n- 浏览器里都办不到。这两道给的是本机命令和期望输出,\n- <strong>不给假编辑器</strong>。\n+ <T\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fcoding-list.tsx"},{"filename":"components/drill-detail.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":2,"changes":8,"patch":"@@ -95,7 +95,9 @@ export function DrillDetail({ id }: { id: string }) {\n <span className=\"foot-dir\">\n <T zh=\"← 上一题\" en=\"← Previous\" />\n </span>\n- <span className=\"foot-title\">{prev.zh}</span>\n+ <span className=\"foot-title\">\n+ <T zh={prev.zh} en={prev.en} />\n+ </span>\n </Link>\n ) : (\n <di","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fdrill-detail.tsx"},{"filename":"components/exam-overview.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":1,"changes":6,"patch":"@@ -83,7 +83,11 @@ export function ExamOverview({ examId }: { examId: string }) {\n <FileExplorer\n showContent\n title={<T zh=\"源项目\" en=\"Source project\" />}\n- files={exam.sourceProjects.map((p) => ({ path: p.path, role: p.role }))}\n+ // FileExplorer 收 LocalizedString;补了 roleEn 的映射成双语\n+ files={exam.sourceProjects.map((p) => ({\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fexam-overview.tsx"},{"filename":"components/exercise.tsx","previous_filename":null,"status":"modified","additions":67,"deletions":27,"changes":94,"patch":"@@ -21,6 +21,7 @@ import type {\n RecognitionExercise,\n } from \"@/content/types\";\n import { useProgress } from \"@/lib/progress\";\n+import { BilingualList } from \"./lesson-kit\";\n import { L, T } from \"./t\";\n import { CodeBlock, CodeFragment, DiffView, EditableCode, TerminalCommand } from \"./code\";\n import { HintPanel, SolutionGate } from \"./hint-panel\";\n@@ -62,7 +63,9 @@ function ExShell({\n <span className=\"ex","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fexercise.tsx"},{"filename":"components/hint-panel.tsx","previous_filename":null,"status":"modified","additions":10,"deletions":2,"changes":12,"patch":"@@ -23,9 +23,17 @@ const STEP_LABEL: LocalizedString[] = [\n L(\"提示 4 · 局部代码\", \"Hint 4 · partial code\"),\n ];\n \n-export function HintPanel({ hints }: { hints: string[] }) {\n+export function HintPanel({\n+ hints,\n+ hintsEn,\n+}: {\n+ hints: string[];\n+ /** 英文版。长度必须和 hints 一致,否则整组回落中文 —— 见 BilingualList */\n+ hintsEn?: string[];\n+}) {\n const [shown, setShown] = useState(0);\n const left = hints.length - shown;\n+ co","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fhint-panel.tsx"},{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":1,"changes":7,"patch":"@@ -122,7 +122,12 @@ export function LessonBody({ examId, lessonId }: { examId: string; lessonId: str\n \n {lesson.sourceFiles && lesson.sourceFiles.length > 0 && (\n <FileExplorer\n- files={lesson.sourceFiles}\n+ // role 在内容里是普通字符串,FileExplorer 收的是 LocalizedString ——\n+ // 补了 roleEn 的映射成双语,没补的原样传(<Loc> 会当纯中文渲染)\n+ files={lesson.sourceFiles.map((f) => ({\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Flesson-body.tsx"},{"filename":"components/lesson-kit.tsx","previous_filename":null,"status":"modified","additions":13,"deletions":3,"changes":16,"patch":"@@ -226,13 +226,23 @@ const TONE_LABEL: Record<ConceptTone, { zh: string; en: string }> = {\n transfer: { zh: \"换个题也能用\", en: \"Transfers to other problems\" },\n };\n \n-export function Callout({ tone, title, body }: CalloutData) {\n+export function Callout({ tone, title, titleEn, body, bodyEn }: CalloutData) {\n return (\n <aside className=\"callout\" data-tone={tone}>\n <strong className=\"callout-title\">\n- ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Flesson-kit.tsx"},{"filename":"components/local-setup.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":1,"changes":10,"patch":"@@ -99,6 +99,9 @@ export function NoRunnerNote({\n yourself, read the errors yourself. If you want a wired-up\n editor, <Link href=\"/code\">the coding problems</Link> have 11\n that run in the browser.\n+ {stackblitz === \"node\"\n+ ? \" This one is a Node project, so StackBlitz can run it — it compiles Node itself into the browser.\"\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Flocal-setup.tsx"},{"filename":"components/mock-detail.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":1,"changes":4,"patch":"@@ -97,7 +97,9 @@ export function MockDetail({ examId, mockId }: { examId: string; mockId: string\n <span className=\"rubric-pt\">\n <T en={`${r.points} pts`} zh={`${r.points} 分`} />\n </span>\n- <span>{r.label}</span>\n+ <span>\n+ <T zh={r.label} en={r.labelEn} />\n+ </span","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fmock-detail.tsx"},{"filename":"components/reference.tsx","previous_filename":null,"status":"modified","additions":1159,"deletions":161,"changes":1320,"patch":"@@ -6,13 +6,21 @@\n import { CodeBlock, TerminalCommand } from \"./code\";\n import { real } from \"@/content/helpers\";\n import { useActiveHeading } from \"@/lib/use-active-heading\";\n-import { T } from \"./t\";\n+import { L, T } from \"./t\";\n \n-// 【这一页的双语边界】\n-// 界面壳(页头、小节标题、表头、侧栏)双语;**速查条目本身只有中文**。\n-// 原因:那些条目是内容不是界面 —— 报错原文、命令、字段说明加起来 300 多段,\n-// 属于「课程正文英文版」那件事,不在界面 i18n 的范围里。\n-// 所以下面给英文读者加了一句明说,别让人默默撞墙。\n+// 【这一页的双语范围】\n+// 界","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Freference.tsx"},{"filename":"components/sandbox.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":4,"changes":12,"patch":"@@ -32,13 +32,17 @@ const CodingSandpack = dynamic(() => import(\"./coding-sandpack\"), {\n 可运行:真沙箱\n ============================================================ */\n \n+// 编辑器里的文字没法按语言切 —— 它是一个字符串,进了 Sandpack 就由那边渲染,\n+// CSS 的 data-lang 开关管不到。所以照 lib/locale.tsx 里那条约定:两种语言拼在\n+// 同一行(和 aria-label=\"复制 / Copy\" 一个路子)。\n+// 【行数必须保持不变】沙箱代码的行数会影响编辑器显示,翻译只准往行尾加。\n const BLANK_NOTE =\n- \"// 空白重来:这个文件是空的,全部由你写。\\n// 测试文件就是判卷器 ——","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/components%2Fsandbox.tsx"},{"filename":"content/arena.ts","previous_filename":null,"status":"modified","additions":18,"deletions":0,"changes":18,"patch":"@@ -105,10 +105,13 @@ function build(): ArenaChallenge[] {\n out.push({\n id: fs.id,\n title: fs.title,\n+ titleEn: fs.titleEn,\n scenario: meta.scenario,\n scenarioEn: meta.scenarioEn,\n minutes: meta.minutes,\n requirements: fs.requirements,\n+ // 来源练习这三样都已经有英文,只是原来没派生出来\n+ requirementsEn: fs.requirementsEn,\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Farena.ts"},{"filename":"content/coding.ts","previous_filename":null,"status":"modified","additions":143,"deletions":0,"changes":143,"patch":"@@ -42,6 +42,7 @@ interface Spec {\n * 题面推不出来,所以在这里直接写一句。\n */\n brief?: string;\n+ briefEn?: string;\n /**\n * 显式覆盖验收标准。\n *\n@@ -53,6 +54,8 @@ interface Spec {\n * 有这个字段就在这里把整道题的要求写全,不去改那个练习。\n */\n requirements?: string[];\n+ /** 英文版验收标准。长度必须和 requirements 完全一致,否则渲染端整段回落中文。 */\n+ requirementsEn?: string[];\n /**\n * 显式覆盖参考答案。\n *\n@@ -592,6 +595,16 @@ const SPECS: Spec[] = [\n \"Check all / U","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fcoding.ts"},{"filename":"content/exams/cab-booking.tsx","previous_filename":null,"status":"modified","additions":1023,"deletions":59,"changes":1082,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fcab-booking.tsx"},{"filename":"content/exams/fed-part1.tsx","previous_filename":null,"status":"modified","additions":943,"deletions":51,"changes":994,"patch":"@@ -331,6 +331,7 @@ export const gqlBasics: Module = {\n {\n path: \"graphql-federation-practice/node-subgraph/src/schema.graphql\",\n role: \"整个 subgraph 的契约\",\n+ roleEn: \"The contract for the whole subgraph\",\n },\n ],\n concepts: [\n@@ -417,6 +418,7 @@ export const gqlBasics: Module = {\n code: [\n real(\"graphql\", SCHEMA_FULL, {\n filen","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Ffed-part1.tsx"},{"filename":"content/exams/fed-part2.tsx","previous_filename":null,"status":"modified","additions":1136,"deletions":62,"changes":1198,"patch":"@@ -90,6 +90,95 @@ export const resolvers = {\n \n export { createShippingInfoLoader, createOrderLoader };`;\n \n+// English side of STARTER_RESOLVERS. The line count must match it exactly,\n+// because highlight is a line number. audit:code only pairs inline template\n+// literals, so it cannot see a pair passed by constant like this one.\n+const STARTER_RESOLVERS_EN = `import DataLoader from 'dataloader';\n+import { GraphQ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Ffed-part2.tsx"},{"filename":"content/exams/fed-part3.tsx","previous_filename":null,"status":"modified","additions":929,"deletions":48,"changes":977,"patch":"@@ -156,6 +156,105 @@ public class OrderController {\n }\n }`;\n \n+// English side of CONTROLLER_SOLUTION. The line count must match it exactly,\n+// because highlight is a line number. audit:code only pairs inline template\n+// literals, so it cannot see a codeEn passed by constant like this one.\n+const CONTROLLER_SOLUTION_EN = `@RestController\n+public class OrderController {\n+ private static final Logger logger =","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Ffed-part3.tsx"},{"filename":"content/exams/fed-part4.tsx","previous_filename":null,"status":"modified","additions":953,"deletions":49,"changes":1002,"patch":"@@ -237,6 +237,244 @@ log('Mutation.createOrder:', JSON.stringify(q5.data), '| errors:', JSON.stringif\n const q6 = await run('mutation { createOrder(userId:\"789\", items:[]) { id } }');\n log('createOrder empty items code:', q6.errors?.map(e => e.extensions.code));`;\n \n+// English side of FULL_RESOLVERS. The line count must match it exactly, because\n+// highlight is a line number. audit:code only pairs inline template ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Ffed-part4.tsx"},{"filename":"content/exams/foundations.tsx","previous_filename":null,"status":"modified","additions":782,"deletions":60,"changes":842,"patch":"@@ -82,8 +82,16 @@ const foundations: Exam = {\n testsEn:\n \"This course is not an exam itself. It is the base the other two stand on. What usually stops a beginner in an exam is not React syntax. It is questions like how to run the tests, whether an error is your own mistake or a defect that was already in the project, and what the difference is between dependencies and devDependencies.\",\n sourceProjects: [\n- ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Ffoundations.tsx"},{"filename":"content/exams/graphql-federation.tsx","previous_filename":null,"status":"modified","additions":219,"deletions":26,"changes":245,"patch":"@@ -530,127 +530,267 @@ const reviewsMock: MockExam = {\n {\n id: \"t1\",\n title: \"Task 1 · Author 上的两个字段\",\n+ titleEn: \"Task 1 · The two fields on Author\",\n requirement: [\n \"Author.reviews:按 author.id 取全部评论。schema 是 [Review!]!,绝不返回 null\",\n \"Author.averageRating:用 ratingDataSource.computeAverage 计算。schema 是 Float(可空),没有评论时返回 null\",\n \"两个都要 try/catch + 结构化错误 + correlationId","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fgraphql-federation.tsx"},{"filename":"content/exams/iv-backend.tsx","previous_filename":null,"status":"modified","additions":66,"deletions":4,"changes":70,"patch":"@@ -218,7 +218,20 @@ console.log(\"6 同步\");\n // -> 2 timers 和 3 check(这两个的相对顺序在主模块里不保证)\n //\n // 记住:nextTick 比 Promise 更优先,这是 Node 独有的`,\n- { filename: \"Node 的执行顺序\" },\n+ {\n+ codeEn: `console.log(\"1 sync\");\n+\n+setTimeout(() => console.log(\"2 timers\"), 0);\n+setImmediate(() => console.log(\"3 check\"));\n+Promise.resolve().then(() => console.log(\"4 promise microtask\"));\n+process.nextTick(() =","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-backend.tsx"},{"filename":"content/exams/iv-basics.tsx","previous_filename":null,"status":"modified","additions":74,"deletions":5,"changes":79,"patch":"@@ -252,9 +252,22 @@ outer.addEventListener(\"click\", () => console.log(\"outer 冒泡\"));\n // 点击 btn 的输出顺序:\n // outer 捕获 -> inner 捕获 -> btn 目标 -> inner 冒泡 -> outer 冒泡`,\n {\n+ codeEn: `// <div id=\"outer\"><div id=\"inner\"><button id=\"btn\">Click me</button></div></div>\n+\n+outer.addEventListener(\"click\", () => console.log(\"outer capture\"), true);\n+inner.addEventListener(\"click\", () => console.log(\"inner capture","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-basics.tsx"},{"filename":"content/exams/iv-coding.tsx","previous_filename":null,"status":"modified","additions":1536,"deletions":94,"changes":1630,"patch":"@@ -91,6 +91,82 @@ const Dropdown: React.FC<Props> = ({ options, onSelect }) => {\n \n export default Dropdown;`;\n \n+const C_DROPDOWN_EN = `import React, { useEffect, useRef, useState } from \"react\";\n+\n+export interface Option { id: string; label: string }\n+\n+interface Props {\n+ options: Option[];\n+ onSelect?: (id: string) => void;\n+}\n+\n+const Dropdown: React.FC<Props> = ({ options, onSelect }) => {\n+ const [open, s","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-coding.tsx"},{"filename":"content/exams/iv-hand.tsx","previous_filename":null,"status":"modified","additions":528,"deletions":33,"changes":561,"patch":"@@ -46,6 +46,28 @@ const REF_DEBOUNCE = `export function debounce<T extends (...args: never[]) => v\n return debounced;\n }`;\n \n+const REF_DEBOUNCE_EN = `export function debounce<T extends (...args: never[]) => void>(\n+ fn: T,\n+ delay: number,\n+): ((...args: Parameters<T>) => void) & { cancel: () => void } {\n+ let timer: ReturnType<typeof setTimeout> | null = null;\n+\n+ const debounced = (...args: Parameters<T>) =","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-hand.tsx"},{"filename":"content/exams/iv-js1.tsx","previous_filename":null,"status":"modified","additions":339,"deletions":15,"changes":354,"patch":"@@ -300,7 +300,27 @@ console.log({} === {}); // false(两个不同的地址)\n console.log(\"a\" === \"a\"); // true (比的是值)\n console.log(typeof null); // \"object\" ← 历史 bug\n console.log(Array.isArray([])); // true ← 判断数组要用这个`,\n- { filename: \"值和地址\" },\n+ {\n+ filename: \"值和地址\",\n+ filenameEn: \"Values and addresses\",\n+ codeEn: `// Primitive value: ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-js1.tsx"},{"filename":"content/exams/iv-js2.tsx","previous_filename":null,"status":"modified","additions":249,"deletions":9,"changes":258,"patch":"@@ -173,7 +173,25 @@ new Dog(\"旺财\").speak(); // \"旺财 汪汪\"\n // class 只是语法糖,底下是原型链\n Object.getPrototypeOf(Dog.prototype) === Animal.prototype; // true\n new Dog(\"x\") instanceof Animal; // true`,\n- { filename: \"四个特征与原型链\" },\n+ {\n+ filename: \"四个特征与原型链\",\n+ filenameEn: \"The four traits and the prototype chain\",\n+ codeE","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-js2.tsx"},{"filename":"content/exams/iv-react1.tsx","previous_filename":null,"status":"modified","additions":90,"deletions":6,"changes":96,"patch":"@@ -528,7 +528,21 @@ const el = React.createElement(\n {if (ok) <A />} // ✗ 语法错误\n {ok ? <A /> : null} // ✓\n {ok && <A />} // ✓(注意 0 会被渲染出来,见 #281)`,\n- { filename: \"JSX 编译成什么\" },\n+ {\n+ codeEn: `// What you write\n+const el = <button className=\"btn\" onClick={handle}>Click me</button>;\n+\n+// After Babel compiles it (before React 17)\n+const el = React.createElement(\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-react1.tsx"},{"filename":"content/exams/iv-react2.tsx","previous_filename":null,"status":"modified","additions":162,"deletions":9,"changes":171,"patch":"@@ -201,7 +201,24 @@ function Good({ show }) {\n // ...\n }, [show]);\n }`,\n- { filename: \"为什么不能写在条件里\" },\n+ {\n+ codeEn: `// ✗ the order changes, and every hook after it shifts\n+function Bad({ show }) {\n+ if (show) {\n+ const [a] = useState(1); // called sometimes, skipped other times\n+ }\n+ const [b] = useState(2); // b may end up in a's slot\n+}\n+\n+// ✓ hooks at the top l","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-react2.tsx"},{"filename":"content/exams/iv-ts.tsx","previous_filename":null,"status":"modified","additions":286,"deletions":18,"changes":304,"patch":"@@ -205,7 +205,28 @@ type UserCardProps = Pick<User, \"name\" | \"role\">;\n \n // Omit<T, K>:去掉不该外泄的字段\n type PublicUser = Omit<User, \"email\">;`,\n- { filename: \"三个场景各拿哪个\" },\n+ {\n+ codeEn: `interface User {\n+ id: number;\n+ name: string;\n+ email: string;\n+ role: \"admin\" | \"member\";\n+}\n+\n+// Partial<T>: every field in patch may be left out, but if given it must be right\n+function updateUser(us","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Fiv-ts.tsx"},{"filename":"content/exams/react-part1.tsx","previous_filename":null,"status":"modified","additions":320,"deletions":23,"changes":343,"patch":"@@ -108,8 +108,16 @@ export const reactMentalModel: Module = {\n whyForAssessmentEn:\n \"In Q1 the four components are already written for you, and you add the logic inside them. Before you add anything, you have to see which component renders which, and where the data comes from. Otherwise you put the code in the wrong component.\",\n sourceFiles: [\n- { path: \"react-notes-app/src/App.tsx\", role","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Freact-part1.tsx"},{"filename":"content/exams/react-part2.tsx","previous_filename":null,"status":"modified","additions":393,"deletions":39,"changes":432,"patch":"@@ -120,6 +120,7 @@ export const reactHooks: Module = {\n {\n path: \"react-notes-app/src/components/NoteForm/index.tsx\",\n role: \"两个受控输入 + 表单提交的完整实现\",\n+ roleEn: \"The complete implementation of the two controlled inputs and the form submit\",\n },\n ],\n concepts: [\n@@ -212,10 +213,23 @@ export const reactHooks: Module = {\n />`,\n {\n filena","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Freact-part2.tsx"},{"filename":"content/exams/react-part3.tsx","previous_filename":null,"status":"modified","additions":895,"deletions":76,"changes":971,"patch":"@@ -169,8 +169,16 @@ export const reactQ1: Module = {\n whyForAssessmentEn:\n \"This lesson is itself part of the exam. The most expensive mistake is not bad code, it is starting to write before you have read the task properly: deleting by title instead of by id, or updating an item by removing it and adding it again.\",\n sourceFiles: [\n- { path: \"react-notes-app/README.md\", role: \"三个 Task 的原文与","blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Freact-part3.tsx"},{"filename":"content/exams/react-part4.tsx","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Freact-part4.tsx"},{"filename":"content/exams/react-part5.tsx","previous_filename":null,"status":"modified","additions":2455,"deletions":129,"changes":2584,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Freact-part5.tsx"},{"filename":"content/exams/react.tsx","previous_filename":null,"status":"modified","additions":294,"deletions":23,"changes":317,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fexams%2Freact.tsx"},{"filename":"content/helpers.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fhelpers.tsx"},{"filename":"content/nav.ts","previous_filename":null,"status":"modified","additions":19,"deletions":6,"changes":25,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Fnav.ts"},{"filename":"content/types.ts","previous_filename":null,"status":"modified","additions":79,"deletions":14,"changes":93,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/content%2Ftypes.ts"},{"filename":"lib/locale.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":6,"changes":17,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/lib%2Flocale.tsx"},{"filename":"package.json","previous_filename":null,"status":"modified","additions":1,"deletions":0,"changes":1,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/package.json"},{"filename":"scripts/audit-code-lines.mjs","previous_filename":null,"status":"added","additions":372,"deletions":0,"changes":372,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/scripts%2Faudit-code-lines.mjs"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":1,"deletions":0,"changes":1,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/nav-template.txt","previous_filename":null,"status":"modified","additions":2,"deletions":1,"changes":3,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/970a4212fe009039623ca8879566c5eb95fb3ea4/scripts%2Fnav-template.txt"}]},"54c4df0b86dd9adafda177907aee222de00c17ae":{"sha":"54c4df0b86dd9adafda177907aee222de00c17ae","stats":{"additions":4882,"deletions":2074,"total":6956},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":129,"deletions":12,"changes":141,"patch":"@@ -4,16 +4,24 @@\n \n ## 这是什么\n \n-一个**刷题 App**,四条主线:\n+一个**刷题 App**。材料分成四条主线,**导航按「你想做哪一类事」分成四个模式**\n+(见 [lib/modes.ts](lib/modes.ts)):\n \n ```\n-/drill 八股题库 105 道问答,题库模式 + 抽认卡\n-/code Coding 题 25 道,题面 + 讲解 + 门后答案;21 道能在浏览器里跑测试\n-/arena 考场 空文件夹、计时、无提示、答案锁死到交卷;**故意不给运行环境**\n-/mock 模拟考 2 套,换壳验收\n-/path 课程归档 75 节课文 —— 「题目背后的讲解」,不是主线\n+模式(顶栏) 路由 材料\n+学课程 Le","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/CLAUDE.md"},{"filename":"README.md","previous_filename":null,"status":"modified","additions":30,"deletions":5,"changes":35,"patch":"@@ -17,13 +17,34 @@ problem was inspired by a format I had seen, I wrote a new scenario for it rathe\n reproducing anything. It is a study project, and it is public in case it is useful to\n someone learning the same things.\n \n-![The home — four tracks, each giving you less than the last](docs/home.jpg)\n+![The home — continue where you left off, then four things you might want to do](docs/ia-nav/home-after.jpg)\n \n-*The","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/README.md"},{"filename":"app/globals.css","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -1,6 +1,9 @@\n @import \"../styles/tokens.css\";\n @import \"../styles/base.css\";\n @import \"../styles/shell.css\";\n+/* nav.css 必须排在 shell.css 之后 —— 它重写了 .topbar 的布局,\n+ 也在 media query 里改了 --topbar-h。同优先级下靠源码顺序决定胜负。 */\n+@import \"../styles/nav.css\";\n @import \"../styles/lesson.css\";\n @import \"../styles/code.css\";\n @import \"../styles/exercise.css\";","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/app%2Fglobals.css"},{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":176,"deletions":503,"changes":679,"patch":"@@ -1,87 +1,52 @@\n \"use client\";\n \n-// 外壳:顶栏 + 左侧学习路径 + 主内容(+ 可选右侧目录栏)。\n+// 外壳:顶栏 + 随模式而变的侧栏 + 主内容(+ 可选右侧目录栏)。\n //\n-// 【为什么又改了导航 —— 上一版仍然是错的】\n+// 【这一版换掉了什么,以及为什么】\n //\n-// 上一版的分工是「顶栏 = 去哪儿,侧栏 = 我在学什么,侧栏只在课程页出现」。\n-// 使用者的反馈是:那七个已上线的 app「你点左边那栏,一步一步做,就不会错过\n-// 任何信息」,而这个不行 ——「你不知道该点左边还是该点 header 那些链接」。\n+// 上一版是「一个常驻侧栏装下全部」:完整课程树 + 平行支线 + 四张全量表 +\n+// 速查 + 清空进度,全部同时露出。它解决了上上一版「不知道该点左边还是点上边」\n+// 的问题,但换来一个更根本的毛病 ——\n //\n-// 病","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fapp-shell.tsx"},{"filename":"components/arena-brief.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":0,"changes":11,"patch":"@@ -15,6 +15,7 @@ import { arenaPublicById } from \"@/content/arena\";\n import { examPath, navExam } from \"@/content/nav\";\n import { ArenaRules } from \"./arena-bits\";\n import { ArenaStartPanel } from \"./arena-start\";\n+import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n \n export function ArenaBrief({ id }: { id: string }) {\n@@ -25,6 +26,16 @@ export function ArenaBrief({ id }: { id: string }) {\n \n return","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Farena-brief.tsx"},{"filename":"components/arena-list.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -14,6 +14,7 @@ import { ARENA, arenaPath, navExam } from \"@/content/nav\";\n import { useProgress } from \"@/lib/progress\";\n import { T } from \"./t\";\n import { ArenaWhy, AttemptTags, attemptMs, bestPass, fmtClock } from \"./arena-bits\";\n+import { NoteRecent } from \"./recent\";\n import { Ladder } from \"./ladder\";\n \n export function ArenaList() {\n@@ -39,6 +40,14 @@ export function ArenaList() {\n \n return (\n <main c","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Farena-list.tsx"},{"filename":"components/arena-run.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":0,"changes":11,"patch":"@@ -18,6 +18,7 @@ import { ArenaClock } from \"./arena-clock\";\n import { TerminalCommand } from \"./code\";\n import { FileExplorer } from \"./lesson-kit\";\n import { NoRunnerNote } from \"./local-setup\";\n+import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n \n /** 模拟考派生的题会把「【任务标题】」平铺进 requirements,这里拆回分组 */\n@@ -63,6 +64,16 @@ export function ArenaRun({ id }: { id: string }) {\n \n return (\n <main className=","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Farena-run.tsx"},{"filename":"components/coding-detail.tsx","previous_filename":null,"status":"modified","additions":10,"deletions":0,"changes":10,"patch":"@@ -19,11 +19,13 @@ import {\n lessonPath,\n } from \"@/content/registry\";\n import { CODING_TRACK_LABEL, DIFFICULTY_LABEL } from \"@/content/coding\";\n+import { codingPath } from \"@/content/nav\";\n import type { CodeExample, CodingProblem, Lesson } from \"@/content/types\";\n import { CodeBlock } from \"./code\";\n import { CodingWorkspace } from \"./coding-workspace\";\n import { HintPanel, SolutionGate } from \"./hint-panel\";\n i","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fcoding-detail.tsx"},{"filename":"components/coding-list.tsx","previous_filename":null,"status":"modified","additions":96,"deletions":77,"changes":173,"patch":"@@ -10,16 +10,15 @@ import Link from \"next/link\";\n import { allCodingProblems, CODING_TRACK_LABEL, DIFFICULTY_LABEL } from \"@/content/coding\";\n import { codingPath } from \"@/content/nav\";\n import type { CodingProblem } from \"@/content/types\";\n+// 筛选链接的拼法在 lib/list-query.ts —— 理由同 practice-page.tsx\n+import { codingHref, type CodingQuery } from \"@/lib/list-query\";\n+\n+export type { CodingQuery };\n import { CodingCount, ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fcoding-list.tsx"},{"filename":"components/continue.tsx","previous_filename":null,"status":"added","additions":289,"deletions":0,"changes":289,"patch":"@@ -0,0 +1,289 @@\n+\"use client\";\n+\n+// 「继续」—— 顶栏那颗按钮和首页那张卡共用的一段逻辑。\n+//\n+// 【它回答的问题】\n+// 一个回头客打开这个站,第一个念头是「我上次到哪了」。以前这个答案只在\n+// 首页的 hero 里,而且只认课文;从八股或者 coding 半途离开的人回来看到的\n+// 仍然是「接着上次那一节课」—— 那不是他上次做的事。\n+//\n+// 现在目标从 progress.recent 里取,四个模式都算:\n+// 最近一节课 / 最近一张筛过的题单 / 最近一道 coding 题 / 最近一场考试。\n+// 一条历史都没有的人,目标是地基第一节 —— 那也是首页那句「从地基开始」。\n+//\n+// 【为什么 ready 之前要渲染「从第一节开始」而不是空】\n+// 进度在 localStorage 里,服务端不知道。服务端和首次客户端渲染必须输出同一棵","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fcontinue.tsx"},{"filename":"components/drill-card.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":2,"changes":11,"patch":"@@ -30,12 +30,19 @@ import { T } from \"./t\";\n \n const META = new Map(DRILLS.map((d) => [d.id, d]));\n \n-/** 掌握状态筛选的取值。\"none\" = 还没做过 */\n-export type MarkFilter = \"all\" | DrillMark | \"none\";\n+/**\n+ * 掌握状态筛选的取值。\n+ * \"none\" 还没做过\n+ * \"review\" 「模糊 + 不会」并成一档 —— 这是考前一晚真正要过的那一堆,\n+ * 也是 Review 侧栏「要复习」那一项指向的值。\n+ * 拆成两次点击(先筛模糊、再筛不会)是把一件事说成两件。\n+ */\n+export type MarkFilter = \"all\" | DrillMark | \"none\" | ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fdrill-card.tsx"},{"filename":"components/drill-detail.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -10,6 +10,7 @@ import { drillById, drillNeighbours, TRACK_LABEL } from \"@/content/drills\";\n import { lessonPath } from \"@/content/nav\";\n import { DrillAnswer } from \"./drill-answer\";\n import { DrillMarks } from \"./drill-marks\";\n+import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n \n export function DrillDetail({ id }: { id: string }) {\n@@ -35,6 +36,14 @@ export function DrillDetail({ id }: { id: strin","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fdrill-detail.tsx"},{"filename":"components/drill-list.tsx","previous_filename":null,"status":"modified","additions":98,"deletions":63,"changes":161,"patch":"@@ -18,12 +18,16 @@ import { DrillProgressStrip } from \"./drill-marks\";\n import { lessonRef } from \"@/content/path\";\n import { lessonPath } from \"@/content/nav\";\n import { DRILL_PAGE, drillListHref, drillMatchesKeyword, type DrillQuery } from \"./drill-query\";\n+import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n import { Ladder } from \"./ladder\";\n \n const MARK_FILTERS: { id: MarkFilter; zh: string; en: s","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fdrill-list.tsx"},{"filename":"components/drill-session-page.tsx","previous_filename":null,"status":"modified","additions":16,"deletions":1,"changes":17,"patch":"@@ -11,6 +11,7 @@\n import { drillById } from \"@/content/drills\";\n import type { DrillQuestion } from \"@/content/types\";\n import { DrillAnswer } from \"./drill-answer\";\n+import { NoteRecent } from \"./recent\";\n import { DrillSession, type DrillCardData } from \"./drill-session\";\n \n export function DrillSessionPage({\n@@ -46,5 +47,19 @@ export function DrillSessionPage({\n if (picked.length > 0) p.set(\"ids\", picked.map((q","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fdrill-session-page.tsx"},{"filename":"components/guide.tsx","previous_filename":null,"status":"modified","additions":76,"deletions":8,"changes":84,"patch":"@@ -11,6 +11,7 @@\n \n import Link from \"next/link\";\n import { ARENA, CODING, DRILLS, NAV } from \"@/content/nav\";\n+import { MODES } from \"@/lib/modes\";\n import { Ladder } from \"./ladder\";\n import { T } from \"./t\";\n \n@@ -35,6 +36,25 @@ export function Guide() {\n en=\"One page: what is here, in what order to go through it, how to use it day to day, and how to sprint the week before an assessment.\"\n /","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fguide.tsx"},{"filename":"components/home.tsx","previous_filename":null,"status":"modified","additions":308,"deletions":331,"changes":639,"patch":"@@ -1,384 +1,331 @@\n \"use client\";\n \n-// 首页。不做企业 Dashboard,也不做 SaaS Hero。\n+// 首页 —— 一张「今天做什么」的仪表盘。\n //\n-// 【这一版的重点:第一屏必须回答「我该点哪」】\n-// 之前的问题是编辑式引言压在最上面,「该从哪开始」在一屏半以下 ——\n-// 新用户进来先读到三段散文,然后看到 4 门课并列,不知道从哪下手。\n-// 现在第一屏就是一个「开始」条:没进度就是「从第一节开始」,\n-// 有进度就是「继续上次」。只有一个主按钮。\n+// 【这一版要修的问题】\n+// 上一版的第一屏是一个主 CTA(继续上次那一节课),下面是四档练法。\n+// 它解决了「无从下手」,但只对一种人成立 —— **打算按课程往下走的人**。\n+// 一个只想复习 React 的人、一个想找一道能在浏览器里跑的题的人,\n+// 在这一页得不到答案:四档说的是","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fhome.tsx"},{"filename":"components/ladder.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":2,"changes":7,"patch":"@@ -80,9 +80,12 @@ export function Ladder({ current }: { current: LadderStep }) {\n 省下的说明高度正好被新增的那行吃掉,净收益为零(实测 212 → 217)。\n 放同一行才真的减掉一整块。 */}\n <div className=\"ladder-head\">\n+ {/* 四档不再自己充当一级导航(那是顶栏四个模式的活),所以这一行\n+ 必须把两套说法对上 —— 否则同一个地方又会有两个名字,\n+ 正是 docs/ia-audit-round3.md 记的那个毛病。 */}\n <T\n- zh=\"同一批本事的四个难度档 —— 区别是「给你多少东西」\"\n- en=\"Four fidelity ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fladder.tsx"},{"filename":"components/learning-path.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":1,"changes":7,"patch":"@@ -13,6 +13,7 @@ import Link from \"next/link\";\n import { NAV, lessonPath, navStages } from \"@/content/nav\";\n import { pathGroups, stageEn } from \"@/content/path\";\n import { useProgress } from \"@/lib/progress\";\n+import { ResumeBar } from \"./continue\";\n import { T } from \"./t\";\n \n \n@@ -82,7 +83,7 @@ export function LearningPath() {\n <div className=\"content\">\n <div className=\"page-head\">\n <div c","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Flearning-path.tsx"},{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"modified","additions":54,"deletions":21,"changes":75,"patch":"@@ -1,29 +1,33 @@\n // 一节课的完整渲染 —— 服务端组件。\n //\n // 所有课都走这一条路径,所以教学节奏是统一的:\n-// 课头 → 学完你会 / 考什么 → 涉及的真实文件 → 编号讲解段(含真实代码)\n-// → 提示框 → 练习 → 常见错误 → 迁移模式 → 要点 → 学完打勾 → 上下节\n+// 课头(位置 / 估时 / 学完没有 / 上下节)→ 学完你会 / 考什么 →\n+// 涉及的真实文件 → 编号讲解段(含真实代码)→ 提示框 → 练习 →\n+// 常见错误 → 迁移模式 → 要点 → 一块「接下来」面板\n //\n // 【为什么是服务端组件】课程内容里带 JSX。以前这个文件是 \"use client\",\n // 结果 42 节课的全文全被打进客户端包(实测单个 chunk 784 KB,每页都下载)。\n // 现在正文在服务端渲染,只有真正需要交互的部分是客户","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Flesson-body.tsx"},{"filename":"components/lesson-islands.tsx","previous_filename":null,"status":"modified","additions":40,"deletions":2,"changes":42,"patch":"@@ -9,15 +9,26 @@ import { useProgress } from \"@/lib/progress\";\n import { useActiveHeading } from \"@/lib/use-active-heading\";\n import { T } from \"./t\";\n \n-/** 记录「最近学到哪」,首页的「继续上次」靠它 */\n+/**\n+ * 记录「最近学到哪」。顶栏那颗「继续」、首页那张卡、Learn 侧栏的 Resume 都靠它。\n+ *\n+ * titleEn / course 是给那三处渲染句子用的 —— 「继续 → 某某」在英文界面下\n+ * 不能嵌一个中文标题。没传就回落中文(<T> 的既有行为)。\n+ */\n export function LessonVisit({\n examId,\n lessonId,\n title,\n+ titleEn,\n+ cours","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Flesson-islands.tsx"},{"filename":"components/lesson-kit.tsx","previous_filename":null,"status":"modified","additions":194,"deletions":42,"changes":236,"patch":"@@ -16,6 +16,17 @@ import { snapshotOf } from \"@/content/source-files\";\n \n /* ---------- 课头 ---------- */\n \n+/**\n+ * 课头 —— 不用往下滚就能看清六件事:\n+ * 在哪门课、哪个模块、第几节 / 共几节、大概多久、标记完成没有、前后是哪一节。\n+ *\n+ * 【为什么这六件事必须一起给】\n+ * 另外七个同系列的 app 之所以「点左边一步一步做就不会错过任何信息」,\n+ * 靠的就是「章节位置 + 进度 + 一个明确的下一步」这三样一直在眼前。\n+ * 这个站以前只给了「第 6 / 21 节」和面包屑,估时挤在一排徽章里,\n+ * 「学完没有」要滚到页尾才知道,上一节 / 下一节也只在页尾 ——\n+ * 于是读到一半想跳回去,只能先滚到底。\n+ */\n export function LessonHeader(","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Flesson-kit.tsx"},{"filename":"components/mock-detail.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -16,6 +16,7 @@ import { L, T } from \"./t\";\n import { AnswerTabs, BilingualList, Section } from \"./lesson-kit\";\n import { LocalSetup } from \"./local-setup\";\n import { MockScore } from \"./mock-score\";\n+import { NoteRecent } from \"./recent\";\n \n export function MockDetail({ examId, mockId }: { examId: string; mockId: string }) {\n const exam = examById(examId);\n@@ -29,6 +30,14 @@ export function MockDetail({ examId, ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fmock-detail.tsx"},{"filename":"components/practice-page.tsx","previous_filename":null,"status":"modified","additions":69,"deletions":77,"changes":146,"patch":"@@ -17,7 +17,13 @@ import { ExerciseView } from \"./exercise\";\n import { Ladder } from \"./ladder\";\n import { PracticeFocus } from \"./practice-focus\";\n import { PracticeProgress } from \"./practice-progress\";\n+import { NoteRecent } from \"./recent\";\n import { KIND_LABEL } from \"@/lib/exercise-labels\";\n+// 筛选链接的拼法在 lib/list-query.ts —— Practice 模式的侧栏是客户端组件,\n+// 它也要拼同样的链接,而它不能 import 这个文件(会把练习正文拖进客户端包)。\n+import { practiceH","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fpractice-page.tsx"},{"filename":"components/recent.tsx","previous_filename":null,"status":"added","additions":54,"deletions":0,"changes":54,"patch":"@@ -0,0 +1,54 @@\n+\"use client\";\n+\n+// 一个不渲染任何东西的小岛:把「我现在在这个模式里的哪个位置」记下来。\n+//\n+// 【为什么要它,为什么不直接在侧栏里读 URL】\n+// 侧栏要给筛选项打高亮(Review 里当前是哪个方向、Practice 里当前筛了什么),\n+// 而这些条件都在 query string 里。侧栏在根 layout 里,是客户端组件 ——\n+// 在那儿调 useSearchParams(),252 个静态预渲染页面全部会因为\n+// 「useSearchParams() should be wrapped in a suspense boundary」构建失败。\n+// 用 Suspense 包起来能过,但那会让整块侧栏在 hydration 时被卸载重挂,\n+// <details> 的展开状态和焦点都会丢。\n+//\n+// 反过来做就干净了:**列表页本","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Frecent.tsx"},{"filename":"components/search.tsx","previous_filename":null,"status":"modified","additions":15,"deletions":13,"changes":28,"patch":"@@ -49,48 +49,50 @@ const PAGES: Hit[] = [\n },\n {\n href: \"/path\",\n- kind: L(\"页面\", \"Page\"),\n+ kind: L(\"学课程\", \"Learn\"),\n title: L(\"课程路线图\", \"Roadmap\"),\n sub: L(`${NAV.length} 门课分组的完整路线`, `The full route, grouped by ${NAV.length} courses`),\n- haystack: \"学习路径 路线图 roadmap 课程 path 归档\",\n+ // 四个模式的名字都要能搜到 —— 一个人记住的是顶栏上那个词,\n+ // 不是路由名(见 lib/modes.ts)\n+ haystack: \"学习路径 路线图 roadmap 课程 path 归档 学课","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fsearch.tsx"},{"filename":"components/sidebars.tsx","previous_filename":null,"status":"added","additions":1044,"deletions":0,"changes":1044,"patch":"@@ -0,0 +1,1044 @@\n+\"use client\";\n+\n+// 四个「随模式而变」的侧栏。\n+//\n+// 【分工】\n+// 顶栏回答:我现在想做哪一类事?(见 lib/modes.ts)\n+// 侧栏回答:在这件事里,我在哪、下一步是什么?\n+//\n+// 所以任何时候侧栏里**只有一个模式的结构**:\n+// Learn 只有课程路线图 —— 四张全量表一个都不出现\n+// Review 只有方向和掌握状态 —— 完整课程树一节都不出现\n+// Practice 先选「课内练习 / Coding」,选完才摊开那一档自己的筛选项\n+// Assess 只有考场和模拟考,按科目分组,带上你之前的尝试\n+//\n+// 上一版把这四样叠在同一个常驻侧栏里,于是一个人得先把这个站的内容模型\n+// 读懂,才能决定点哪儿。这一版每个模式最多两三组,一屏之内看得完。\n+//\n+// 【为什么全","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/components%2Fsidebars.tsx"},{"filename":"content/coding.ts","previous_filename":null,"status":"modified","additions":4,"deletions":12,"changes":16,"patch":"@@ -1368,15 +1368,7 @@ export function codingProblemById(id: string): CodingProblem | undefined {\n return PROBLEMS.find((p) => p.id === id);\n }\n \n-export const CODING_TRACK_LABEL: Record<CodingProblem[\"track\"], string> = {\n- react: \"React\",\n- js: \"JavaScript\",\n- graphql: \"GraphQL\",\n- java: \"Java / Spring\",\n-};\n-\n-export const DIFFICULTY_LABEL: Record<1 | 2 | 3, { zh: string; en: string }> = {\n- 1: { zh: \"简单\", ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/content%2Fcoding.ts"},{"filename":"content/nav.ts","previous_filename":null,"status":"modified","additions":66,"deletions":0,"changes":66,"patch":"@@ -78,6 +78,10 @@ export interface NavExam {\n debugLabs: number;\n rebuilds: number;\n rebuildIds: string[];\n+ /** 每种练习题型有几个(键是 Exercise[\"kind\"])—— Practice 侧栏的筛选项计数用 */\n+ exerciseKinds: Record<string, number>;\n+ /** 每个难度档有几个(键是 \"1\"–\"4\")—— 同上 */\n+ exerciseLevels: Record<string, number>;\n modules: NavModule[];\n mockExams: NavMock[];\n checklist: { task: string; covered: string; tested: boolean }[];\n@@ -1","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/content%2Fnav.ts"},{"filename":"docs/ia-nav/assess-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fassess-after.jpg"},{"filename":"docs/ia-nav/assess-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fassess-before.jpg"},{"filename":"docs/ia-nav/home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fhome-after.jpg"},{"filename":"docs/ia-nav/home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fhome-before.jpg"},{"filename":"docs/ia-nav/home-dark-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fhome-dark-after.jpg"},{"filename":"docs/ia-nav/home-dark-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fhome-dark-before.jpg"},{"filename":"docs/ia-nav/lesson-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Flesson-after.jpg"},{"filename":"docs/ia-nav/lesson-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Flesson-before.jpg"},{"filename":"docs/ia-nav/lesson-foot-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Flesson-foot-after.jpg"},{"filename":"docs/ia-nav/lesson-foot-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Flesson-foot-before.jpg"},{"filename":"docs/ia-nav/mobile-drawer-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fmobile-drawer-after.jpg"},{"filename":"docs/ia-nav/mobile-drawer-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fmobile-drawer-before.jpg"},{"filename":"docs/ia-nav/mobile-home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fmobile-home-after.jpg"},{"filename":"docs/ia-nav/mobile-home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fmobile-home-before.jpg"},{"filename":"docs/ia-nav/mobile-lesson-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fmobile-lesson-after.jpg"},{"filename":"docs/ia-nav/mobile-lesson-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fmobile-lesson-before.jpg"},{"filename":"docs/ia-nav/path-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fpath-after.jpg"},{"filename":"docs/ia-nav/path-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fpath-before.jpg"},{"filename":"docs/ia-nav/practice-code-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fpractice-code-after.jpg"},{"filename":"docs/ia-nav/practice-code-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Fpractice-code-before.jpg"},{"filename":"docs/ia-nav/review-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Freview-after.jpg"},{"filename":"docs/ia-nav/review-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Freview-before.jpg"},{"filename":"docs/ia-nav/tablet-lesson-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Ftablet-lesson-after.jpg"},{"filename":"docs/ia-nav/tablet-lesson-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fia-nav%2Ftablet-lesson-before.jpg"},{"filename":"docs/intent-nav.md","previous_filename":null,"status":"added","additions":171,"deletions":0,"changes":171,"patch":"@@ -0,0 +1,171 @@\n+# IA 第四轮 · 意图优先的导航\n+\n+前三轮的记录在 [ia-audit-round3.md](ia-audit-round3.md)。那三轮修的是\n+「首页第一屏该放什么」和「四条主线怎么解释」,这一轮修的是更上一层的东西。\n+\n+## 一、上一版错在哪\n+\n+上一版的分工是「顶栏 = 品牌 / 首页 / 使用说明,侧栏 = 全部内容结构,常驻」。\n+侧栏里同时挂着:完整课程树(5 门课、27 个模块、80 节)、平行支线、\n+四张全量表、速查、以及清空进度。\n+\n+它确实解决了上上一版「不知道该点左边还是点上边」,但换来一个更根本的毛病:\n+\n+> **要先读懂这个站的内容模型,才能决定点哪儿。**\n+\n+而这个产品有五种都成立的用法:\n+\n+1. 跟着课程从地基往下走\n+2. 用抽认卡背面试知识点\n+3. 按题目 / 技术点找练习\n+4. 进计时考场或模拟考\n+5. 只在卡住时回","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/docs%2Fintent-nav.md"},{"filename":"lib/coding-labels.ts","previous_filename":null,"status":"added","additions":30,"deletions":0,"changes":30,"patch":"@@ -0,0 +1,30 @@\n+// Coding 题的「方向」和「难度」两套标签 —— 全站唯一一份。\n+//\n+// 【为什么从 content/coding.ts 搬到这里】\n+// 这两张表原来长在 content/coding.ts 里(那个文件 196 KB,装着 25 道题的\n+// 题面、测试和参考答案)。服务端页面 import 它没问题,但**客户端组件不许**——\n+// 一 import 就把 25 道题的正文打进 JS 包(README 里记的 784 KB 那个坑)。\n+//\n+// 而 Practice 模式的侧栏是客户端组件,它需要这两套标签给筛选项写名字。\n+// 所以标签搬到这个纯数据模块里,content/coding.ts 改成从这儿 re-export ——\n+// 抄第二份就会出现「侧栏叫 Java、列表页叫 Java / Spring」。\n+//\n+// 和 lib/exerci","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/lib%2Fcoding-labels.ts"},{"filename":"lib/exercise-labels.ts","previous_filename":null,"status":"modified","additions":10,"deletions":0,"changes":10,"patch":"@@ -15,6 +15,16 @@\n \n import type { Exercise } from \"@/content/types\";\n \n+/**\n+ * 六种练习题型。\n+ *\n+ * 【为什么要 re-export 这个类型】content/types.ts 里带 ReactNode 的类型不少,\n+ * 而客户端组件(Practice 模式的侧栏)只需要这一个联合类型。\n+ * `import type` 会被 TS 完全擦掉,所以从这里取它不会把内容拖进客户端包,\n+ * 但从这里取比让每个消费方各自 import content/types 更不容易走错。\n+ */\n+export type ExerciseKind = Exercise[\"kind\"];\n+\n /** 难度只说刻度,不说题型 */\n export const LEVEL_LABEL: Record<number, string> = {\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/lib%2Fexercise-labels.ts"},{"filename":"lib/list-query.ts","previous_filename":null,"status":"added","additions":63,"deletions":0,"changes":63,"patch":"@@ -0,0 +1,63 @@\n+// 两张列表页的 URL 参数模型 —— 服务端和客户端都要用的纯函数。\n+//\n+// 【为什么单独一个文件】\n+// 和 components/drill-query.tsx 是同一个理由:/practice 和 /code 的列表是\n+// 服务端组件渲染的(要遍历练习和题目的正文,不能进客户端 chunk),\n+// 但 Practice 模式的**侧栏是客户端组件**,它也要拼同样的筛选链接。\n+//\n+// 这个文件没有 \"use client\"、也不 import 任何内容文件,所以两边都能 import。\n+// 抄第二份的后果是「侧栏点出来的链接和页面上的筛选按钮走向不同的 URL」。\n+\n+export interface PracticeQuery {\n+ exam?: string;\n+ kind?: string;\n+ level?: string;\n+ p","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/lib%2Flist-query.ts"},{"filename":"lib/modes.ts","previous_filename":null,"status":"added","additions":107,"deletions":0,"changes":107,"patch":"@@ -0,0 +1,107 @@\n+// 四种「模式」—— 全站导航的第一维。\n+//\n+// 【为什么要有这一层】\n+// 这个产品是二维的:一维是学什么(foundations / react / federation /\n+// cab-booking / 面试八股),一维是给你多少(八股 → 课内练习 → Coding → 考场)。\n+// 上一版把两维都塞进了同一个常驻侧栏,于是侧栏里同时挂着完整课程树、\n+// 平行支线、四张全量表、速查和清空进度 —— 一个人得先把这个站的内容模型\n+// 读懂,才能决定点哪儿。\n+//\n+// 这一版换成「先问意图」:顶栏只回答一个问题 ——\n+//\n+// 我现在想做哪一类事?\n+//\n+// 四个答案就是下面这四个模式。选定之后,侧栏才回答第二个问题 ——\n+//\n+// 在这件事里,我在哪、下一步是什么?\n+//\n+// 所以侧栏的内容随模式变,任何时候都","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/lib%2Fmodes.ts"},{"filename":"lib/progress.tsx","previous_filename":null,"status":"modified","additions":129,"deletions":8,"changes":137,"patch":"@@ -11,6 +11,13 @@\n // drills 八股题的掌握状态 键 = 题目 id(q269…)\n // arena 考场的尝试记录 键 = 考场题 id,值是尝试数组\n // coding coding 题完成标记 键 = coding 题 id\n+// recent 最近去过的地方(按模式各存一条) 顶栏「继续」和四个侧栏的高亮靠它\n+//\n+// 【recent 为什么是按模式各存一条,而不是只存一条全局的】\n+// 顶栏那个「继续」需要「最近一次有意义的落点」—— 一条就够。\n+// 但侧栏还要回答「我上次在这个模式里做到哪」:一个人可能上午在读课文、\n+// 下午在刷八股,进 Review 时该接上八股那条,不该被课文那条顶掉。\n+// 所以按模式各存一条,再由 ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/lib%2Fprogress.tsx"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":14,"deletions":0,"changes":14,"patch":"@@ -33,6 +33,20 @@ export function GET() {\n (n, r) => n + (r.lesson.exercises ?? []).filter((x) => x.kind === \"from-scratch\").length, 0),\n rebuildIds: lessonsOf(e).flatMap((r) =>\n (r.lesson.exercises ?? []).filter((x) => x.kind === \"from-scratch\").map((x) => x.id)),\n+ // 【为什么这两张小表值得进 nav】Practice 模式的侧栏要在「按题型 / 按难度」\n+ // 那两排筛选项上写出数量,而它是客户端组件,数不了 allExercises()。\n+ // 加起来是每门课 10 个整数(","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/nav-template.txt","previous_filename":null,"status":"modified","additions":4,"deletions":0,"changes":4,"patch":"@@ -78,6 +78,10 @@ export interface NavExam {\n debugLabs: number;\n rebuilds: number;\n rebuildIds: string[];\n+ /** 每种练习题型有几个(键是 Exercise[\"kind\"])—— Practice 侧栏的筛选项计数用 */\n+ exerciseKinds: Record<string, number>;\n+ /** 每个难度档有几个(键是 \"1\"–\"4\")—— 同上 */\n+ exerciseLevels: Record<string, number>;\n modules: NavModule[];\n mockExams: NavMock[];\n checklist: { task: string; covered: string; tested: boolean }[];","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/scripts%2Fnav-template.txt"},{"filename":"styles/lesson.css","previous_filename":null,"status":"modified","additions":11,"deletions":138,"changes":149,"patch":"@@ -30,9 +30,12 @@\n \n .crumb-sep {\n /* 【别改回 --rule-strong】那是画线用的颜色,当文字用时浅色下只有\n- 1.49:1 —— 面包屑里的「·」几乎看不见,路径读起来是黏在一起的。 */\n+ 1.49:1 —— 面包屑里的「·」几乎看不见,路径读起来是黏在一起的。\n+\n+ 【也别加 opacity】--ink-3 本身是 5.06:1,压到 0.75 之后实测 3.1:1,\n+ 又掉回不达标(这一轮把扫描脚本改成会把 opacity 算进合成之后才暴露出来)。\n+ 要更淡就换颜色变量,不要用透明度 —— 透明度会绕过所有按变量算的检查。 */\n color: var(--ink-3);\n- opacity: 0.75;\n }\n \n .lesson-n {\n@@ -628,64 +631,12 @@\n margin: 0;\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/styles%2Flesson.css"},{"filename":"styles/nav.css","previous_filename":null,"status":"added","additions":1483,"deletions":0,"changes":1483,"patch":"@@ -0,0 +1,1483 @@\n+/* ============================================================\n+ 意图优先的导航\n+ ------------------------------------------------------------\n+ 顶栏 = 四个模式(我现在想做哪一类事)\n+ 侧栏 = 那个模式自己的结构(我在哪、下一步是什么)\n+ 加上首页那张仪表盘,和课程页的方位信息。\n+\n+ 这个文件排在 shell.css 之后(见 app/globals.css 的 @import 顺序),\n+ 所以这里对 .topbar、.sidebar 的重写会盖掉那边的旧值。\n+ ============================================================ */\n+\n+/* -----","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/styles%2Fnav.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":19,"deletions":755,"changes":774,"patch":"@@ -1,7 +1,12 @@\n /* ============================================================\n- 外壳:顶栏 / 左侧课程导航 / 主内容 / 右侧目录栏\n+ 外壳:顶栏 / 左侧侧栏 / 主内容 / 右侧目录栏\n ------------------------------------------------------------\n 不做厚重三栏后台:两侧都是「无框」的,只用一条细线和留白分区。\n+\n+ 【顶栏和侧栏里面长什么样在 styles/nav.css】\n+ 这里只留外框:栅格、sticky、抽屉、滚动。\n+ 侧栏的内容随模式变(学课程 / 背知识点 / 做练习 / 模拟考试),\n+ 四套内容的样式和顶栏那四项一起放在 nav.css,不在这里。\n =============================","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/styles%2Fshell.css"},{"filename":"styles/tokens.css","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -34,7 +34,7 @@\n /* —— 强调色:一支鼠尾草绿,全站只有它是彩色主角 ——\n 【为什么从 #5c7f6c 压深了一档】它是主按钮 / CTA / 当前页 chip 的底色,\n 上面压白字。老值对白字只有 4.46:1(差 0.04 不达标),实测三处都栽在\n- 这一个颜色上:.hero-cta-sub、.pager-num[data-on]、.foot-next-title。\n+ 这一个颜色上(首页 CTA 的副标、.pager-num[data-on]、课尾那个大按钮)。\n #547563 对白字 5.12:1,对 --paper 也从 4.05 升到 4.65 —— 顺带把\n 「拿 --accent 当小字用」的地方一起救了。 */\n --accent: #547563;","blob_url":"https://github.com/renrenmimi/DrillLab/blob/54c4df0b86dd9adafda177907aee222de00c17ae/styles%2Ftokens.css"}]},"09b11bdaf58e8494af9015c85796d1e1444e5dd4":{"sha":"09b11bdaf58e8494af9015c85796d1e1444e5dd4","stats":{"additions":2634,"deletions":4,"total":2638},"files":[{"filename":"content/nav-exercises.ts","previous_filename":null,"status":"added","additions":1358,"deletions":0,"changes":1358,"patch":"@@ -0,0 +1,1358 @@\n+// 全站 148 个课内练习的清单 —— 生成物,不要手改。改完内容跑 `npm run gen:nav`。\n+//\n+// 【为什么和 content/nav.ts 分开】\n+// nav.ts 被每一个客户端页面下载。这一份只有引导计划用得上,而计划那一套\n+// 是懒加载的(components/plan-slots.tsx:没跟计划的人一个字节都不下)。\n+// 合成一份的实测代价:nav 133 → 160 KB,webpack 分块随之改变,\n+// 课程页从 470 kB 原始字节涨到 615 kB —— 而课程页本来一点 nav 都不需要。\n+//\n+// 只有 id / 所属课与节 / 题型 / 难度 / 标题,**没有题面和答案**。\n+\n+export interface NavExerciseRef {\n+ examId: string;\n+ lessonId","blob_url":"https://github.com/renrenmimi/DrillLab/blob/09b11bdaf58e8494af9015c85796d1e1444e5dd4/content%2Fnav-exercises.ts"},{"filename":"content/plans-assert.ts","previous_filename":null,"status":"added","additions":76,"deletions":0,"changes":76,"patch":"@@ -0,0 +1,76 @@\n+// 引导计划的完整性断言 —— **只给服务端页面 import**。\n+//\n+// 【为什么单独一个文件】\n+// 断言要把六条计划全部展开一遍才能发现「引用了不存在的东西」。\n+// 如果它留在 content/plans.ts 的模块作用域里,那么**每一个**页面\n+// (顶栏那枚计划徽标在每一页都有)都会在客户端把六条计划、\n+// 一千多个条目全展开一次 —— 白烧 CPU,而且那段代码也白进包。\n+//\n+// 现在它由 app/plans/page.tsx 和 app/plans/[planId]/page.tsx import。\n+// 这两页都是预渲染的,所以 next build 一定会跑到这里 ——\n+// 引用写错就是**构建失败**,不是页面上安静地少一格。\n+// dev 下打开 /plans 也会立刻炸。\n+\n+import { PLANS, resolv","blob_url":"https://github.com/renrenmimi/DrillLab/blob/09b11bdaf58e8494af9015c85796d1e1444e5dd4/content%2Fplans-assert.ts"},{"filename":"content/plans.ts","previous_filename":null,"status":"added","additions":1135,"deletions":0,"changes":1135,"patch":"@@ -0,0 +1,1135 @@\n+// 引导计划 —— 「告诉我下一步做什么」。\n+//\n+// 【为什么四个模式还不够】\n+// 上一轮把导航改成了「先问意图」:顶栏四个模式回答「我现在想做哪一类事」,\n+// 侧栏回答「在这件事里我在哪」。那一步是对的,但它只解决了一半 ——\n+// **四个模式里只有 Learn 是线性的**,Review / Practice / Assess 仍然是\n+// 题库、筛选器和列表。\n+//\n+// 于是下面这些话仍然得不到一条完整的路:\n+// 「我要准备 React 考试。」\n+// 「我要学 GraphQL Federation。」\n+// 「我要准备 Spring Boot 那道控制器题。」\n+// 「我要复习前端面试。」\n+// 「我要把 Cab Booking 重做一遍。」\n+// 「我从零开始。」\n+//\n+// 每一句都是一个**目标**,而不","blob_url":"https://github.com/renrenmimi/DrillLab/blob/09b11bdaf58e8494af9015c85796d1e1444e5dd4/content%2Fplans.ts"},{"filename":"scripts/gen-nav.mjs","previous_filename":null,"status":"modified","additions":11,"deletions":4,"changes":15,"patch":"@@ -48,16 +48,23 @@ if (!json) {\n process.exit(1);\n }\n \n-// 搜索专用的重字段单独出一份 —— 见 nav-template 里 NavLesson 的注释。\n-// 它有 130 KB 出头,进 nav.ts 会让每个页面都白下 80 kB(gzip 后)。\n-const { searchIndex, ...navJson } = json;\n+// 两份重数据单独出文件 —— 见各自模板顶部的注释。\n+// searchIndex 130 KB 出头,只有 ⌘K 搜索用,进 nav.ts 每页白下 80 kB(gzip 后)\n+// exercises 148 条,只有引导计划用,而计划那一套是懒加载的\n+const { searchIndex, exercises, ...navJson } = json;\n \n const tpl = readFi","blob_url":"https://github.com/renrenmimi/DrillLab/blob/09b11bdaf58e8494af9015c85796d1e1444e5dd4/scripts%2Fgen-nav.mjs"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":28,"deletions":0,"changes":28,"patch":"@@ -119,6 +119,34 @@ export function GET() {\n runnable: !!a.blankSandbox,\n })),\n \n+ /**\n+ * 练习清单 —— 单独一份,生成到 content/nav-exercises.ts。\n+ *\n+ * 【为什么不放进 nav.ts】\n+ * 引导计划里「做练习」那一档的每一格都是一个真实练习,完成状态读\n+ * progress.exercises[`${examId}/${id}`],所以计划要拿到这 148 个 id。\n+ * 但 nav.ts 被**每一个客户端页面**下载,而计划那一套是懒加载的\n+ * (只有真的在跟计划的人才下载,见 components/plan-slots.tsx)。\n+ *\n+ * 把练习塞进 nav.ts 实测的代","blob_url":"https://github.com/renrenmimi/DrillLab/blob/09b11bdaf58e8494af9015c85796d1e1444e5dd4/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/nav-exercises-template.txt","previous_filename":null,"status":"added","additions":26,"deletions":0,"changes":26,"patch":"@@ -0,0 +1,26 @@\n+// 全站 148 个课内练习的清单 —— 生成物,不要手改。改完内容跑 `npm run gen:nav`。\n+//\n+// 【为什么和 content/nav.ts 分开】\n+// nav.ts 被每一个客户端页面下载。这一份只有引导计划用得上,而计划那一套\n+// 是懒加载的(components/plan-slots.tsx:没跟计划的人一个字节都不下)。\n+// 合成一份的实测代价:nav 133 → 160 KB,webpack 分块随之改变,\n+// 课程页从 470 kB 原始字节涨到 615 kB —— 而课程页本来一点 nav 都不需要。\n+//\n+// 只有 id / 所属课与节 / 题型 / 难度 / 标题,**没有题面和答案**。\n+\n+export interface NavExerciseRef {\n+ examId: string;\n+ lessonId: ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/09b11bdaf58e8494af9015c85796d1e1444e5dd4/scripts%2Fnav-exercises-template.txt"}]},"ada860994382405835d822da7ecdcfd3bdd1cdc8":{"sha":"ada860994382405835d822da7ecdcfd3bdd1cdc8","stats":{"additions":328,"deletions":0,"total":328},"files":[{"filename":"lib/plan-progress.ts","previous_filename":null,"status":"added","additions":251,"deletions":0,"changes":251,"patch":"@@ -0,0 +1,251 @@\n+// 计划的进度 —— 全部从**已有**的进度记录推导,不新开一套完成度。\n+//\n+// 【为什么必须推导,不能另存】\n+// 一个人可能先自己在 /drill 里刷了三十道,几天后才选了「前端面试复习」这条计划。\n+// 如果计划自己记一套「计划内完成」,那三十道就白刷了 —— 他会看到 0 / 105。\n+// 反过来,跟着计划做完一节课,回到 Learn 模式那一节也必须是打过勾的。\n+// 所以计划里的每一格都只是「已有记录的一个视图」:\n+//\n+// 课文 progress.lessons[`${examId}/${lessonId}`]\n+// 练习 progress.exercises 或 rebuilds(从零重写那一类走 rebuilds)\n+// 八股 progress.drills[id] —— 有记录就算「过过一遍」,mark","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ada860994382405835d822da7ecdcfd3bdd1cdc8/lib%2Fplan-progress.ts"},{"filename":"lib/progress.tsx","previous_filename":null,"status":"modified","additions":77,"deletions":0,"changes":77,"patch":"@@ -12,6 +12,16 @@\n // arena 考场的尝试记录 键 = 考场题 id,值是尝试数组\n // coding coding 题完成标记 键 = coding 题 id\n // recent 最近去过的地方(按模式各存一条) 顶栏「继续」和四个侧栏的高亮靠它\n+// plan 当前跟着走的引导计划 只存 id 和两个时间戳\n+// planSeen 最近看过哪条计划的详情页 /plans 上标一下「上次看的是这条」\n+// planOptOut 用户明确说了「我自己逛」 首页就不再把选计划摆在第一位\n+//\n+// 【引导计划为什么只存一个 id】\n+// 计划里每一格的完成状态**全部从上面那些记录推导**(见 lib/pl","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ada860994382405835d822da7ecdcfd3bdd1cdc8/lib%2Fprogress.tsx"}]},"e37d490baeae8be4443c27c4c6eb2b168425e81a":{"sha":"e37d490baeae8be4443c27c4c6eb2b168425e81a","stats":{"additions":3381,"deletions":0,"total":3381},"files":[{"filename":"app/globals.css","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -4,6 +4,9 @@\n /* nav.css 必须排在 shell.css 之后 —— 它重写了 .topbar 的布局,\n 也在 media query 里改了 --topbar-h。同优先级下靠源码顺序决定胜负。 */\n @import \"../styles/nav.css\";\n+/* plans.css 排在 nav.css 之后 —— 它重写了 .topbar-nav 里的一部分间距,\n+ 而计划徽标是那一行的第一个元素。 */\n+@import \"../styles/plans.css\";\n @import \"../styles/lesson.css\";\n @import \"../styles/code.css\";\n @import \"../styles/exercise.css\";","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/app%2Fglobals.css"},{"filename":"app/plans/[planId]/page.tsx","previous_filename":null,"status":"added","additions":35,"deletions":0,"changes":35,"patch":"@@ -0,0 +1,35 @@\n+import { notFound } from \"next/navigation\";\n+import { PlanDetail } from \"@/components/plan-detail\";\n+import { PLANS, resolvedPlanById } from \"@/content/plans\";\n+import { assertPlans } from \"@/content/plans-assert\";\n+import { slashTitle } from \"@/content/path\";\n+\n+export function generateStaticParams() {\n+ return PLANS.map((p) => ({ planId: p.id }));\n+}\n+\n+export async function generateMetadata({\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/app%2Fplans%2F%5BplanId%5D%2Fpage.tsx"},{"filename":"app/plans/page.tsx","previous_filename":null,"status":"added","additions":20,"deletions":0,"changes":20,"patch":"@@ -0,0 +1,20 @@\n+// /plans —— 薄壳。\n+//\n+// 【这一页承担一件额外的事:让引用写错时构建失败】\n+// content/plans 在模块作用域跑完整性断言(引用了不存在的课文 / coding 题 /\n+// 考场题 / 模拟考,或者某一档解析出 0 条,就抛)。这一页是预渲染的,\n+// 所以 next build 一定会 import 到它 —— 断言因此是构建期检查,不是运行期祈祷。\n+import { PlanList } from \"@/components/plan-list\";\n+import { assertPlans } from \"@/content/plans-assert\";\n+\n+export const metadata = {\n+ title: \"引导计划 / Guided plans\",\n+ description:\n+ \"按目标走的六条学习计划","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/app%2Fplans%2Fpage.tsx"},{"filename":"components/plan-cards.tsx","previous_filename":null,"status":"added","additions":219,"deletions":0,"changes":219,"patch":"@@ -0,0 +1,219 @@\n+\"use client\";\n+\n+// 计划卡片 —— 首页和 /plans 共用。\n+//\n+// 两种形态:\n+// PlanCard 紧凑卡。六张并排也要在 390px 上读得完,所以不做大卡片。\n+// ActivePlanCard 首页第一屏那张。它回答的是「我在跟哪条走、下一格是什么」,\n+// 所以里面有整页唯一的主按钮。\n+//\n+// 【为什么两处共用一份】\n+// 首页和 /plans 都要「一条计划长什么样」。抄两份的结果是改一边忘一边 ——\n+// 上一轮 IA 审计里记的「同一个地方两个名字」就是这么来的。\n+\n+import Link from \"next/link\";\n+import { PLANS, type Plan, type PlanPhase } from \"@/content/plans\";","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/components%2Fplan-cards.tsx"},{"filename":"components/plan-detail.tsx","previous_filename":null,"status":"added","additions":515,"deletions":0,"changes":515,"patch":"@@ -0,0 +1,515 @@\n+\"use client\";\n+\n+// 一条计划的完整路线图 —— 这一版的主角。\n+//\n+// 【为什么是客户端组件,而且这次没问题】\n+// 它需要的东西只有两样:计划定义(content/plans)和标题(content/nav),\n+// 两个都是纯数据模块。完成度由 lib/plan-progress 从已有进度推导。\n+// **一个字的课程正文都没有**,所以不存在「客户端 import 内容」那个坑\n+// (实测踩过一次,单 chunk 784 KB)。也不加载 Sandpack。\n+//\n+// 【路线图的设计】\n+// 竖着一条线,每一档一个编号站点。三种状态各有自己的**形状和文字**,\n+// 不靠颜色单独表意:\n+// 做完了 实心站点 + 对勾 + 「读完了」\n+// 你在这 带光环的强调色站点 + 「你在这」徽标 + 默认展开\n+// ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/components%2Fplan-detail.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"added","additions":581,"deletions":0,"changes":581,"patch":"@@ -0,0 +1,581 @@\n+\"use client\";\n+\n+// 引导计划的公用零件:一个 hook、一枚顶栏徽标、一块侧栏面板、一条页内位置条。\n+//\n+// 【为什么全部只读 content/plans 和 content/nav】\n+// 计划定义和 nav 都是「只有文字和数字」的纯数据模块,客户端安全。\n+// 完成度由 lib/plan-progress 从已有进度推导,也是纯函数。\n+// 所以这一整套零件不会把任何课程正文拖进客户端包。\n+\n+import Link from \"next/link\";\n+import { useMemo, type ReactNode } from \"react\";\n+import { resolvedPlanById, type PlanItem, type PlanPhase } from \"@/content/plans\";\n+import { useT } ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/components%2Fplan-kit.tsx"},{"filename":"components/plan-list.tsx","previous_filename":null,"status":"added","additions":91,"deletions":0,"changes":91,"patch":"@@ -0,0 +1,91 @@\n+\"use client\";\n+\n+// /plans —— 六条计划,加上「你正在跟哪条走」。\n+//\n+// 这一页要回答的问题只有一个:**我现在想达成什么目标?**\n+// 所以它不解释产品架构,也不重复四个模式那一套 —— 那些在首页下半和 /guide 里。\n+\n+import Link from \"next/link\";\n+import { PLANS } from \"@/content/plans\";\n+import { useProgress } from \"@/lib/progress\";\n+import { ActivePlanCard, PlanCard } from \"./plan-cards\";\n+import { PlanMark } from \"./plan-mark\";\n+import { T } from \"./t\";\n+\n+export function P","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/components%2Fplan-list.tsx"},{"filename":"components/plan-mark.tsx","previous_filename":null,"status":"added","additions":28,"deletions":0,"changes":28,"patch":"@@ -0,0 +1,28 @@\n+// 计划的标记 —— 单独一个文件,因为它要被**两边**用:\n+// 一边是外壳里那个不加载任何内容的静态入口(components/plan-slots.tsx),\n+// 一边是真正的计划零件(components/plan-kit.tsx)。\n+// 放在 plan-kit 里就会把 content/plans 和 content/nav 拖进外壳的首屏包。\n+//\n+// 图形是一条竖着的路线:两个空心站点、一条线、一个实心终点。\n+// 三处刻意的处理:\n+// ① 终点实心并用 var(--accent),前面两站空心 —— 和品牌那个「四根递减的柱子」\n+// 同一个语气:越往后越是主角。\n+// ② 竖着而不是横着。计划页的路线图就是竖的,图标和它同向。\n+// ③ 只有三站。四站在 14px 里挤成一团,两站看不出「一条路」。\n+\n+export function ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/components%2Fplan-mark.tsx"},{"filename":"components/plan-slots.tsx","previous_filename":null,"status":"added","additions":104,"deletions":0,"changes":104,"patch":"@@ -0,0 +1,104 @@\n+\"use client\";\n+\n+// 计划零件的**懒加载插槽**。\n+//\n+// 【为什么必须这么做 —— 实测的数字】\n+// 计划要算完成度,就得展开计划,就得读 content/nav(120 KB 原始字节)\n+// 和 content/plans(40 KB)。第一版把 PlanChip / PlanPanel 直接 import 进\n+// 外壳(app-shell 在根 layout 里),于是**每一个路由**都开始下载 nav ——\n+// 那些原本不需要它的页面涨得最狠:\n+//\n+// /drill/[id] 373 → 525 kB 原始字节(+152)\n+// /code/[id] 387 → 539(+152)\n+// 课程页 470 → 625(+155)\n+//\n+// 而这些零件有一个共同点:**没跟计划的人身上它们","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/components%2Fplan-slots.tsx"},{"filename":"styles/plans.css","previous_filename":null,"status":"added","additions":1785,"deletions":0,"changes":1785,"patch":"@@ -0,0 +1,1785 @@\n+/* ============================================================\n+ 引导计划\n+ ------------------------------------------------------------\n+ 这一层的主角是**路线图**:竖着一条线,每一档一个编号站点。\n+ 它是这个站的签名视觉,所以刻意做得比别处更「有形」—— 但仍然只用\n+ 留白、细线、极浅的底色和一个强调色,不做渐变、不做玻璃、不做发光。\n+\n+ 【状态一律给两个以上通道】\n+ 做完 / 你在这 / 还没到,三种状态各有自己的**形状、文字和颜色**。\n+ 只靠颜色区分对色觉障碍不成立,而这一页全是状态。\n+\n+ 【不要用 opacity 压暗文字】\n+ 透明度会绕过所有按颜色变量算对比度的检查(上一轮实测栽过两次","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e37d490baeae8be4443c27c4c6eb2b168425e81a/styles%2Fplans.css"}]},"ae3ed59cdf29d58cdef2e376da6a73bef9b63e8b":{"sha":"ae3ed59cdf29d58cdef2e376da6a73bef9b63e8b","stats":{"additions":146,"deletions":59,"total":205},"files":[{"filename":"components/continue.tsx","previous_filename":null,"status":"modified","additions":59,"deletions":1,"changes":60,"patch":"@@ -16,6 +16,7 @@\n // 否则 hydration 警告。所以两边都先渲染「开始第一课」,挂载后 ready 翻 true\n // 再换成真实目标 —— 换的是文字,不是结构,不会跳版。\n \n+import dynamic from \"next/dynamic\";\n import Link from \"next/link\";\n import { NAV, lessonPath, navExam, navLessonsOf } from \"@/content/nav\";\n import { pathGroups } from \"@/content/path\";\n@@ -57,10 +58,20 @@ function foundationsStart(): ContinueTarget {\n };\n }\n \n-/** 顶栏和首页共用:跨模式最新的那一条,没有就是地基第一节 */\n+/**\n+ * 「继续」的目","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ae3ed59cdf29d58cdef2e376da6a73bef9b63e8b/components%2Fcontinue.tsx"},{"filename":"components/home.tsx","previous_filename":null,"status":"modified","additions":87,"deletions":58,"changes":145,"patch":"@@ -2,16 +2,26 @@\n \n // 首页 —— 一张「今天做什么」的仪表盘。\n //\n-// 【这一版要修的问题】\n-// 上一版的第一屏是一个主 CTA(继续上次那一节课),下面是四档练法。\n-// 它解决了「无从下手」,但只对一种人成立 —— **打算按课程往下走的人**。\n-// 一个只想复习 React 的人、一个想找一道能在浏览器里跑的题的人,\n-// 在这一页得不到答案:四档说的是「给你多少东西」,不是「你想做什么」。\n+// 【这一版换了第一个问题】\n+// 上一版第一屏问的是「你今天想做哪一类事」,答案是四个模式。那一步是对的,\n+// 但四个模式里只有 Learn 是线性的 —— 一个说「我要准备 React 考试」的人,\n+// 点进 Review 看到的是 105 道题的题库,点进 Practice 看到的是筛选器。\n+// 他要的是一条从现在到考试的路,而首页给不出。\n //\n-// 现在第一","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ae3ed59cdf29d58cdef2e376da6a73bef9b63e8b/components%2Fhome.tsx"}]},"017f77a3b730c7843ad146866432f61a6811cefb":{"sha":"017f77a3b730c7843ad146866432f61a6811cefb","stats":{"additions":276,"deletions":30,"total":306},"files":[{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":22,"deletions":5,"changes":27,"patch":"@@ -30,8 +30,15 @@\n // 它仍然是 components/ladder.tsx 那套解释,只是不再自己充当一级导航:\n // 它现在分布在 Review / Practice / Assess 三个模式里。\n //\n-// 首页、使用说明、速查这三页不属于任何模式,所以它们**没有侧栏**\n-// (data-nav=\"off\")—— 首页本身就是那张仪表盘,不需要旁边再摆一份导航。\n+// 首页、使用说明、速查、计划页不属于任何模式,所以它们**没有侧栏**\n+// (data-nav=\"off\")—— 首页本身就是那张仪表盘,/plans 本身就是那张选择表。\n+//\n+// 【这一轮加的:引导计划】\n+// 四个模式解决了「我想做哪一类事」,但四个里只有 Learn 是线性的 ——\n+// Review / Practice / Assess 仍然是题库和筛选器。所以顶栏在四个模式**左边**\n+// ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Fapp-shell.tsx"},{"filename":"components/arena-brief.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":0,"changes":4,"patch":"@@ -15,6 +15,8 @@ import { arenaPublicById } from \"@/content/arena\";\n import { examPath, navExam } from \"@/content/nav\";\n import { ArenaRules } from \"./arena-bits\";\n import { ArenaStartPanel } from \"./arena-start\";\n+import { itemKey } from \"@/lib/plan-progress\";\n+import { PlanItemBannerSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n \n@@ -51,6 +53,8 @@ export function Are","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Farena-brief.tsx"},{"filename":"components/arena-list.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -14,6 +14,7 @@ import { ARENA, arenaPath, navExam } from \"@/content/nav\";\n import { useProgress } from \"@/lib/progress\";\n import { T } from \"./t\";\n import { ArenaWhy, AttemptTags, attemptMs, bestPass, fmtClock } from \"./arena-bits\";\n+import { PlanStripSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n import { Ladder } from \"./ladder\";\n \n@@ -65,6 +66,8 @@ export function ArenaList() {\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Farena-list.tsx"},{"filename":"components/arena-review.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -13,7 +13,9 @@ import Link from \"next/link\";\n import { notFound } from \"next/navigation\";\n import { arenaById } from \"@/content/arena\";\n import { arenaPath, examPath, lessonPath, mockPath, navExam } from \"@/content/nav\";\n+import { itemKey } from \"@/lib/plan-progress\";\n import { ArenaCheckoff } from \"./arena-checkoff\";\n+import { PlanNextStepSlot } from \"./plan-slots\";\n import { CodeBlock } from \"./code\";\n import { ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Farena-review.tsx"},{"filename":"components/arena-run.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":0,"changes":6,"patch":"@@ -18,6 +18,8 @@ import { ArenaClock } from \"./arena-clock\";\n import { TerminalCommand } from \"./code\";\n import { FileExplorer } from \"./lesson-kit\";\n import { NoRunnerNote } from \"./local-setup\";\n+import { itemKey } from \"@/lib/plan-progress\";\n+import { PlanItemBannerSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n \n@@ -77,6 +79,10 @@ export function ArenaRun({ id }: { ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Farena-run.tsx"},{"filename":"components/coding-detail.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -25,6 +25,8 @@ import { CodeBlock } from \"./code\";\n import { CodingWorkspace } from \"./coding-workspace\";\n import { HintPanel, SolutionGate } from \"./hint-panel\";\n import { AnswerTabs, Section, BilingualList } from \"./lesson-kit\";\n+import { itemKey } from \"@/lib/plan-progress\";\n+import { PlanItemBannerSlot, PlanNextStepSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n import { L, T, type Localiz","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Fcoding-detail.tsx"},{"filename":"components/coding-list.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -15,6 +15,7 @@ import { codingHref, type CodingQuery } from \"@/lib/list-query\";\n \n export type { CodingQuery };\n import { CodingCount, CodingDoneBadge } from \"./coding-progress\";\n+import { PlanStripSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n import { Ladder } from \"./ladder\";\n@@ -87,6 +88,8 @@ export function CodingList({ query }: { query: CodingQuery }) {\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Fcoding-list.tsx"},{"filename":"components/drill-detail.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -10,6 +10,8 @@ import { drillById, drillNeighbours, TRACK_LABEL } from \"@/content/drills\";\n import { lessonPath } from \"@/content/nav\";\n import { DrillAnswer } from \"./drill-answer\";\n import { DrillMarks } from \"./drill-marks\";\n+import { itemKey } from \"@/lib/plan-progress\";\n+import { PlanItemBannerSlot, PlanNextStepSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n \n@@ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Fdrill-detail.tsx"},{"filename":"components/drill-list.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -18,6 +18,7 @@ import { DrillProgressStrip } from \"./drill-marks\";\n import { lessonRef } from \"@/content/path\";\n import { lessonPath } from \"@/content/nav\";\n import { DRILL_PAGE, drillListHref, drillMatchesKeyword, type DrillQuery } from \"./drill-query\";\n+import { PlanStripSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n import { T } from \"./t\";\n import { Ladder } from \"./ladder\";\n@@ -126,6 +12","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Fdrill-list.tsx"},{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"modified","additions":22,"deletions":0,"changes":22,"patch":"@@ -19,6 +19,8 @@ import { CodeBlock } from \"./code\";\n import { drillListHref } from \"./drill-query\";\n import { ExerciseView } from \"./exercise\";\n import { LessonDoneBar, LessonStatusChip, LessonToc, LessonVisit } from \"./lesson-islands\";\n+import { LessonPlanStep } from \"./lesson-plan\";\n+import { PlanItemBanner } from \"./plan-kit\";\n import {\n AnswerTabs,\n Callout,\n@@ -32,6 +34,7 @@ import {\n Section,\n Transfe","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Flesson-body.tsx"},{"filename":"components/lesson-kit.tsx","previous_filename":null,"status":"modified","additions":36,"deletions":24,"changes":60,"patch":"@@ -614,6 +614,7 @@ export function LessonNextPanel({\n coding,\n arenaHref,\n doneBar,\n+ primaryStep,\n }: {\n exerciseCount: number;\n // title 收 ReactNode —— 课文标题是双语的,传进来的是 <T zh en />\n@@ -627,6 +628,15 @@ export function LessonNextPanel({\n arenaHref?: string;\n /** 「标记这节学完」—— 要读 localStorage,由客户端小岛传进来 */\n doneBar?: ReactNode;\n+ /**\n+ * 第 2 步那一整个 <li>。\n+ *\n+ * 跟着引导计划走的时候这一步要换说法(「接着走计划」而不是「接着看下一节」)","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Flesson-kit.tsx"},{"filename":"components/lesson-plan.tsx","previous_filename":null,"status":"added","additions":104,"deletions":0,"changes":104,"patch":"@@ -0,0 +1,104 @@\n+\"use client\";\n+\n+// 课尾「接下来」面板里那一步的**轻壳**。\n+//\n+// 【为什么要有壳】\n+// 这一步在没跟计划时就是原来那句「接着看下一节」—— 那是首屏可见内容,\n+// 必须服务端就渲染出来。而计划版本要读 content/nav(120 KB),\n+// 课程页原本不需要它。\n+//\n+// 所以:默认那一支留在这里(只用字符串,不 import 任何内容模块),\n+// 计划那两支放在 lesson-plan-live.tsx,跟着计划的人才下载。\n+//\n+// 【三种情况,任何时候只有一个实心按钮】\n+// 没跟计划 / 这一节不在计划里 → 原样:「接着看下一节」\n+// 在计划里、下一格不是这一节 → 「接着走计划」当主按钮,\n+// 「课程里的下一节」退成一行小字(两者不","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Flesson-plan.tsx"},{"filename":"components/mock-detail.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":0,"changes":4,"patch":"@@ -16,6 +16,8 @@ import { L, T } from \"./t\";\n import { AnswerTabs, BilingualList, Section } from \"./lesson-kit\";\n import { LocalSetup } from \"./local-setup\";\n import { MockScore } from \"./mock-score\";\n+import { itemKey } from \"@/lib/plan-progress\";\n+import { PlanItemBannerSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n \n export function MockDetail({ examId, mockId }: { examId: string; mockId: st","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Fmock-detail.tsx"},{"filename":"components/practice-page.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -17,6 +17,7 @@ import { ExerciseView } from \"./exercise\";\n import { Ladder } from \"./ladder\";\n import { PracticeFocus } from \"./practice-focus\";\n import { PracticeProgress } from \"./practice-progress\";\n+import { PlanStripSlot } from \"./plan-slots\";\n import { NoteRecent } from \"./recent\";\n import { KIND_LABEL } from \"@/lib/exercise-labels\";\n // 筛选链接的拼法在 lib/list-query.ts —— Practice 模式的侧栏是客户端组件,\n@@ -127,6 +128,8 @@","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Fpractice-page.tsx"},{"filename":"components/search.tsx","previous_filename":null,"status":"modified","additions":44,"deletions":1,"changes":45,"patch":"@@ -16,6 +16,7 @@ import Link from \"next/link\";\n import { usePathname, useRouter } from \"next/navigation\";\n import { useEffect, useMemo, useRef, useState } from \"react\";\n import { ARENA, CODING, DRILLS, NAV, arenaPath, codingPath, drillPath, examPath, lessonPath, mockPath, navLessonsOf } from \"@/content/nav\";\n+import { PLANS, resolvePlan } from \"@/content/plans\";\n import { stageEn } from \"@/content/path\";\n import { u","blob_url":"https://github.com/renrenmimi/DrillLab/blob/017f77a3b730c7843ad146866432f61a6811cefb/components%2Fsearch.tsx"}]},"2aa28c1c302d45630f26562f6b7112535bf31f43":{"sha":"2aa28c1c302d45630f26562f6b7112535bf31f43","stats":{"additions":8,"deletions":0,"total":8},"files":[{"filename":"styles/code.css","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -154,6 +154,14 @@\n color: #96a39a;\n }\n \n+/* 同一个坑的第二处:**注释**。--tk-com 对普通代码底是 5.24:1,\n+ 但高亮行的底叠了 rgba(255,255,255,0.055) 之后掉到 4.45:1(实测,差 0.05)。\n+ 而高亮行上的注释往往正是那句「看这里」的说明,不能比别处糊。\n+ #94a499 在高亮底上是 5.00:1。 */\n+.cl.hl .tk-com {\n+ color: #94a499;\n+}\n+\n .cl-n {\n flex: 0 0 auto;\n width: 3.1em;","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2aa28c1c302d45630f26562f6b7112535bf31f43/styles%2Fcode.css"}]},"522d561139cd9ab3babc87440b4b729f44b5d418":{"sha":"522d561139cd9ab3babc87440b4b729f44b5d418","stats":{"additions":398,"deletions":17,"total":415},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":92,"deletions":4,"changes":96,"patch":"@@ -4,7 +4,14 @@\n \n ## 这是什么\n \n-一个**刷题 App**。材料分成四条主线,**导航按「你想做哪一类事」分成四个模式**\n+一个**刷题 App**。进这个站有两条路,互补,不是替代:\n+\n+```\n+引导计划 「告诉我下一步做什么」 /plans 六条按目标走的有序路径\n+四个模式 「让我自己挑」 顶栏 学课程 / 背知识点 / 做练习 / 模拟考试\n+```\n+\n+材料分成四条主线,**导航按「你想做哪一类事」分成四个模式**\n (见 [lib/modes.ts](lib/modes.ts)):\n \n ```\n@@ -15,8 +22,15 @@\n 模拟考试 Assess /arena /mock 7 道计时题(含 2 套模拟考);**故意不给运行环境**\n ```\n \n-不属于任何模式的三页:`/`(仪表盘)、`/guid","blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/CLAUDE.md"},{"filename":"README.md","previous_filename":null,"status":"modified","additions":36,"deletions":13,"changes":49,"patch":"@@ -17,20 +17,41 @@ problem was inspired by a format I had seen, I wrote a new scenario for it rathe\n reproducing anything. It is a study project, and it is public in case it is useful to\n someone learning the same things.\n \n-![The home — continue where you left off, then four things you might want to do](docs/ia-nav/home-after.jpg)\n+![The home — pick an outcome, and the order comes with it](docs/plans/home-fresh-aft","blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/README.md"},{"filename":"docs/guided-plans.md","previous_filename":null,"status":"added","additions":270,"deletions":0,"changes":270,"patch":"@@ -0,0 +1,270 @@\n+# 引导计划 · 架构与实测\n+\n+上一轮把导航改成了「意图优先」([intent-nav.md](intent-nav.md)):顶栏四个模式回答\n+「我现在想做哪一类事」,侧栏回答「在这件事里我在哪」。那一步是对的,但它只解决了一半。\n+\n+## 一、剩下的那一半\n+\n+**四个模式里只有 Learn 是线性的。** Review / Practice / Assess 仍然是题库、筛选器和列表。\n+于是下面这些话在站里得不到一条完整的路:\n+\n+| 用户说的 | 上一版给他的 |\n+| --- | --- |\n+| 「我要准备 React 考试。」 | 105 道题的题库、一堆筛选器、7 道考场题 |\n+| 「我要学 GraphQL Federation。」 | 一门 17 节的课,和三张不知道什么时候该来的全量表 |\n+| 「我要准备 Spring Boot 那道控制器题。」 | 无","blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fguided-plans.md"},{"filename":"docs/plans/arena-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Farena-after.jpg"},{"filename":"docs/plans/arena-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Farena-before.jpg"},{"filename":"docs/plans/coding-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fcoding-after.jpg"},{"filename":"docs/plans/coding-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fcoding-before.jpg"},{"filename":"docs/plans/drill-one-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fdrill-one-after.jpg"},{"filename":"docs/plans/drill-one-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fdrill-one-before.jpg"},{"filename":"docs/plans/home-fresh-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fhome-fresh-after.jpg"},{"filename":"docs/plans/home-fresh-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fhome-fresh-before.jpg"},{"filename":"docs/plans/home-noplan-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fhome-noplan-after.jpg"},{"filename":"docs/plans/home-noplan-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fhome-noplan-before.jpg"},{"filename":"docs/plans/home-plan-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fhome-plan-after.jpg"},{"filename":"docs/plans/home-plan-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fhome-plan-before.jpg"},{"filename":"docs/plans/lesson-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Flesson-after.jpg"},{"filename":"docs/plans/lesson-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Flesson-before.jpg"},{"filename":"docs/plans/lesson-foot-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Flesson-foot-after.jpg"},{"filename":"docs/plans/lesson-foot-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Flesson-foot-before.jpg"},{"filename":"docs/plans/mobile-drawer-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fmobile-drawer-after.jpg"},{"filename":"docs/plans/mobile-drawer-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fmobile-drawer-before.jpg"},{"filename":"docs/plans/mobile-home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fmobile-home-after.jpg"},{"filename":"docs/plans/mobile-home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fmobile-home-before.jpg"},{"filename":"docs/plans/mobile-plan-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fmobile-plan-after.jpg"},{"filename":"docs/plans/mobile-plan-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fmobile-plan-before.jpg"},{"filename":"docs/plans/plan-complete-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fplan-complete-after.jpg"},{"filename":"docs/plans/plan-complete-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fplan-complete-before.jpg"},{"filename":"docs/plans/plan-list-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fplan-list-after.jpg"},{"filename":"docs/plans/plan-list-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fplan-list-before.jpg"},{"filename":"docs/plans/plan-react-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fplan-react-after.jpg"},{"filename":"docs/plans/plan-react-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fplan-react-before.jpg"},{"filename":"docs/plans/plan-react-dark-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fplan-react-dark-after.jpg"},{"filename":"docs/plans/plan-react-dark-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Fplan-react-dark-before.jpg"},{"filename":"docs/plans/review-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Freview-after.jpg"},{"filename":"docs/plans/review-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Freview-before.jpg"},{"filename":"docs/plans/tablet-plan-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Ftablet-plan-after.jpg"},{"filename":"docs/plans/tablet-plan-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/522d561139cd9ab3babc87440b4b729f44b5d418/docs%2Fplans%2Ftablet-plan-before.jpg"}]},"78d1f5490f2ff02fa1bab638aba0396508c95ea1":{"sha":"78d1f5490f2ff02fa1bab638aba0396508c95ea1","stats":{"additions":2245,"deletions":116,"total":2361},"files":[{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":2,"changes":8,"patch":"@@ -20,7 +20,11 @@ import { drillListHref } from \"./drill-query\";\n import { ExerciseView } from \"./exercise\";\n import { LessonDoneBar, LessonStatusChip, LessonToc, LessonVisit } from \"./lesson-islands\";\n import { LessonPlanStep } from \"./lesson-plan\";\n-import { PlanItemBanner } from \"./plan-kit\";\n+// 【只许用 Slot,不许直接 import plan-kit】\n+// 直接 import 会把计划清单(85 KB)打进课程页的初始 chunk ——\n+// 实测 First Load JS 因此从 142 涨到 168 kB。Sl","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/components%2Flesson-body.tsx"},{"filename":"components/plan-cards.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":15,"changes":18,"patch":"@@ -12,29 +12,17 @@\n // 上一轮 IA 审计里记的「同一个地方两个名字」就是这么来的。\n \n import Link from \"next/link\";\n-import { PLANS, type Plan, type PlanPhase } from \"@/content/plans\";\n+import { litePlans, phasesOf, type LitePlan } from \"@/lib/plan-lite\";\n import { useProgress } from \"@/lib/progress\";\n import { PhaseBadge, PlanMeter, usePlanStatus, useActivePlan } from \"./plan-kit\";\n import { PlanMark } from \"./plan-mark\";\n import { T } from \".","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/components%2Fplan-cards.tsx"},{"filename":"components/plan-detail.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":14,"changes":25,"patch":"@@ -27,16 +27,9 @@ import {\n } from \"@/content/plans\";\n import { useT } from \"@/lib/locale\";\n import { modeById } from \"@/lib/modes\";\n-import type { ItemState, PlanStatus } from \"@/lib/plan-progress\";\n+import { planStatus, type ItemState, type PlanStatus } from \"@/lib/plan-progress\";\n import { useProgress } from \"@/lib/progress\";\n-import {\n- PHASE,\n- PhaseBadge,\n- PlanMeter,\n- STATE_LABEL,\n- StateDot,\n- usePlan","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/components%2Fplan-detail.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":21,"deletions":11,"changes":32,"patch":"@@ -1,18 +1,28 @@\n \"use client\";\n \n-// 引导计划的公用零件:一个 hook、一枚顶栏徽标、一块侧栏面板、一条页内位置条。\n+// 引导计划里**全站挂载**的那几个零件:顶栏徽标、顶栏「继续」、侧栏面板、\n+// 页内位置条、列表页提示条、课尾那一步。\n //\n-// 【为什么全部只读 content/plans 和 content/nav】\n-// 计划定义和 nav 都是「只有文字和数字」的纯数据模块,客户端安全。\n-// 完成度由 lib/plan-progress 从已有进度推导,也是纯函数。\n-// 所以这一整套零件不会把任何课程正文拖进客户端包。\n+// 【这个文件只许读 lib/plan-lite,不许碰 content/plans】\n+// 这几个零件挂在根 layout 上,每一个路由都会经过它们。而 content/plans.ts\n+// 要 import conten","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/components%2Fplan-kit.tsx"},{"filename":"components/plan-list.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":4,"changes":9,"patch":"@@ -6,7 +6,7 @@\n // 所以它不解释产品架构,也不重复四个模式那一套 —— 那些在首页下半和 /guide 里。\n \n import Link from \"next/link\";\n-import { PLANS } from \"@/content/plans\";\n+import { litePlans } from \"@/lib/plan-lite\";\n import { useProgress } from \"@/lib/progress\";\n import { ActivePlanCard, PlanCard } from \"./plan-cards\";\n import { PlanMark } from \"./plan-mark\";\n@@ -15,7 +15,8 @@ import { T } from \"./t\";\n export function PlanList() {\n const { acti","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/components%2Fplan-list.tsx"},{"filename":"components/search.tsx","previous_filename":null,"status":"modified","additions":36,"deletions":33,"changes":69,"patch":"@@ -16,9 +16,9 @@ import Link from \"next/link\";\n import { usePathname, useRouter } from \"next/navigation\";\n import { useEffect, useMemo, useRef, useState } from \"react\";\n import { ARENA, CODING, DRILLS, NAV, arenaPath, codingPath, drillPath, examPath, lessonPath, mockPath, navLessonsOf } from \"@/content/nav\";\n-import { PLANS, resolvePlan } from \"@/content/plans\";\n import { stageEn } from \"@/content/path\";\n import { u","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/components%2Fsearch.tsx"},{"filename":"content/plan-manifest.ts","previous_filename":null,"status":"added","additions":1679,"deletions":0,"changes":1679,"patch":"@@ -0,0 +1,1679 @@\n+// 引导计划的**轻量清单** —— 生成物,不要手改。改完计划或内容跑 `npm run gen:nav`。\n+//\n+// 【为什么要有这一份,而不是让客户端去展开 content/plans】\n+// content/plans.ts 要 import content/nav(134 KB)和 content/nav-exercises(39 KB)\n+// 才能把「这门课的全部课文」这种查询展开成真实条目。而顶栏那枚计划徽标、\n+// 侧栏那块面板、页内那条位置条是**全站挂载**的 —— 它们一旦顺着 plan-kit\n+// 拖上 content/plans,webpack 就会把 nav 从「所有页面共用的那个 chunk」\n+// 拆成一个独立 chunk,于是每个路由都要单独再下一遍。\n+//\n+// 实测代价(origin/main → 第一版):课程页 First ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/content%2Fplan-manifest.ts"},{"filename":"content/plans.ts","previous_filename":null,"status":"modified","additions":13,"deletions":17,"changes":30,"patch":"@@ -56,20 +56,16 @@ import {\n // 合进 nav.ts 的实测代价见 content/nav-exercises.ts 顶部那段。\n import { EXERCISES } from \"./nav-exercises\";\n import type { ModeId } from \"@/lib/modes\";\n+import { MODE_OF_KIND, type PlanItemKind, type PlanPhase } from \"@/lib/plan-types\";\n \n /* ============================================================\n 类型\n ============================================================ */\n \n-/** 一档的性质。决定徽章文字、颜色","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/content%2Fplans.ts"},{"filename":"lib/plan-lite.ts","previous_filename":null,"status":"added","additions":130,"deletions":0,"changes":130,"patch":"@@ -0,0 +1,130 @@\n+// 把生成的轻量清单展开成能用的对象。\n+//\n+// 【这一层存在的唯一理由是首屏字节】\n+// 顶栏那枚计划徽标、侧栏那块面板、页内那条位置条是**全站挂载**的。\n+// 它们要算「我在计划的第几档第几格、下一格是什么」,而完整地展开一条计划\n+// 需要 content/nav(134 KB)+ content/nav-exercises(39 KB)——\n+// 一旦这条依赖出现在全站挂载的组件里,webpack 就把 nav 从「所有页面共用的\n+// 那个 chunk」拆出去,于是每个路由都要单独再下一遍。\n+// 实测:课程页 First Load JS 142 → 195 kB(+37%)。\n+//\n+// content/plan-manifest.ts 是构建期压好的结果,**不 import 任何内容模块**。\n+// 这个文件只负责把「按列存的数组」还原成对象,并","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/lib%2Fplan-lite.ts"},{"filename":"lib/plan-progress.ts","previous_filename":null,"status":"modified","additions":62,"deletions":18,"changes":80,"patch":"@@ -18,9 +18,42 @@\n // 但课文页是服务端组件 —— 服务端渲染的那部分要判断「这一节在不在当前计划里」,\n // 也得能 import 这里。ProgressData 只按类型 import,会被 TS 完全擦掉。\n \n-import type { PlanItem, ResolvedPlan, ResolvedStage } from \"@/content/plans\";\n+import type { PlanItemKind } from \"./plan-types\";\n import type { DrillMark, ProgressData } from \"./progress\";\n \n+/* ============================================================\n+ 这个模块对「一条计划」的最小要求\n+ -----------------","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/lib%2Fplan-progress.ts"},{"filename":"lib/plan-search.ts","previous_filename":null,"status":"added","additions":89,"deletions":0,"changes":89,"patch":"@@ -0,0 +1,89 @@\n+// 搜索里的「引导计划」条目 —— 六条计划本身,加上每一档。\n+//\n+// 【为什么单独一个文件】\n+// components/search.tsx 挂在根 layout 上,每个路由都会下载它。\n+// 计划清单(content/plan-manifest.ts)有 80 KB 出头,不该跟着搜索框白下 ——\n+// 而搜索要按 ⌘K 才打开。所以这里和 content/search-index.ts 一样是懒加载的:\n+// 第一次打开搜索时才 import(),没加载完之前搜索照常工作,只是暂时搜不到计划。\n+//\n+// 返回的是纯数据(没有 JSX),Hit 的形状由 search.tsx 自己拼。\n+\n+import { litePlans, type LiteItem } from \"./plan-lite\";\n+import type { PlanItemKind } ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/lib%2Fplan-search.ts"},{"filename":"lib/plan-types.ts","previous_filename":null,"status":"added","additions":48,"deletions":0,"changes":48,"patch":"@@ -0,0 +1,48 @@\n+// 引导计划的几个基础类型 —— 手写,全站唯一一份。\n+//\n+// 【为什么单独一个文件】\n+// 这几个类型被三处用:\n+// content/plans.ts 完整定义(构建期 / 服务端)\n+// content/plan-manifest.ts 生成的轻量清单(全站挂载的客户端零件读它)\n+// lib/plan-progress.ts 完成度推导\n+// 放在 content/plans.ts 里的话,清单就得 import 它 —— 而那正是这一轮要断开的\n+// 那条依赖(content/plans 会拖上 content/nav 的 134 KB)。\n+//\n+// 纯类型加一张常量表,不带 JSX、不 import 任何内容模块。\n+\n+import type { ModeId } from \"./modes\";\n+\n+/** ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/lib%2Fplan-types.ts"},{"filename":"scripts/gen-nav.mjs","previous_filename":null,"status":"modified","additions":12,"deletions":2,"changes":14,"patch":"@@ -51,7 +51,7 @@ if (!json) {\n // 两份重数据单独出文件 —— 见各自模板顶部的注释。\n // searchIndex 130 KB 出头,只有 ⌘K 搜索用,进 nav.ts 每页白下 80 kB(gzip 后)\n // exercises 148 条,只有引导计划用,而计划那一套是懒加载的\n-const { searchIndex, exercises, ...navJson } = json;\n+const { searchIndex, exercises, planManifest, ...navJson } = json;\n \n const tpl = readFileSync(\"scripts/nav-template.txt\", \"utf8\");\n writeFileSync(\n@@ -65,6 +65,15 @@ writeFileSync(\n exTpl.rep","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/scripts%2Fgen-nav.mjs"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":56,"deletions":0,"changes":56,"patch":"@@ -6,6 +6,7 @@ import {\n allDrills,\n lessonsOf,\n } from \"@/content/registry\";\n+import { PLANS, resolvePlan } from \"@/content/plans\";\n \n export function GET() {\n return Response.json({\n@@ -119,6 +120,61 @@ export function GET() {\n runnable: !!a.blankSandbox,\n })),\n \n+ /**\n+ * 引导计划的轻量清单 —— 生成到 content/plan-manifest.ts。\n+ *\n+ * 【为什么要在构建期把它压出来】\n+ * 展开一条计划要读 content/nav(134 KB)+ content","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/plan-manifest-template.txt","previous_filename":null,"status":"added","additions":74,"deletions":0,"changes":74,"patch":"@@ -0,0 +1,74 @@\n+// 引导计划的**轻量清单** —— 生成物,不要手改。改完计划或内容跑 `npm run gen:nav`。\n+//\n+// 【为什么要有这一份,而不是让客户端去展开 content/plans】\n+// content/plans.ts 要 import content/nav(134 KB)和 content/nav-exercises(39 KB)\n+// 才能把「这门课的全部课文」这种查询展开成真实条目。而顶栏那枚计划徽标、\n+// 侧栏那块面板、页内那条位置条是**全站挂载**的 —— 它们一旦顺着 plan-kit\n+// 拖上 content/plans,webpack 就会把 nav 从「所有页面共用的那个 chunk」\n+// 拆成一个独立 chunk,于是每个路由都要单独再下一遍。\n+//\n+// 实测代价(origin/main → 第一版):课程页 First Lo","blob_url":"https://github.com/renrenmimi/DrillLab/blob/78d1f5490f2ff02fa1bab638aba0396508c95ea1/scripts%2Fplan-manifest-template.txt"}]},"cb77d966c78c39870fb30dd78e68374ad8246c08":{"sha":"cb77d966c78c39870fb30dd78e68374ad8246c08","stats":{"additions":148,"deletions":12,"total":160},"files":[{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":7,"deletions":2,"changes":9,"patch":"@@ -47,6 +47,7 @@ import { useLocale } from \"@/lib/locale\";\n import { MODES, modeOf } from \"@/lib/modes\";\n import { useTheme } from \"@/lib/theme\";\n import { ContinueButton } from \"./continue\";\n+import { PlanSignalProvider } from \"@/lib/plan-signal\";\n import { PlanChipSlot, PlanPanelSlot } from \"./plan-slots\";\n import { Search } from \"./search\";\n import { ContextSidebar } from \"./sidebars\";\n@@ -424,8 +425,12 @@ export","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cb77d966c78c39870fb30dd78e68374ad8246c08/components%2Fapp-shell.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":1,"changes":9,"patch":"@@ -22,6 +22,7 @@ import { useT } from \"@/lib/locale\";\n import type { ModeId } from \"@/lib/modes\";\n import { litePlanById, type LiteItem, type LiteStatus } from \"@/lib/plan-lite\";\n import { itemKey, pct, planStatus, type ItemState } from \"@/lib/plan-progress\";\n+import { usePublishPlanNext } from \"@/lib/plan-signal\";\n import type { PlanPhase } from \"@/lib/plan-types\";\n import { useProgress } from \"@/lib/progress\";\n im","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cb77d966c78c39870fb30dd78e68374ad8246c08/components%2Fplan-kit.tsx"},{"filename":"components/sidebars.tsx","previous_filename":null,"status":"modified","additions":46,"deletions":9,"changes":55,"patch":"@@ -47,6 +47,7 @@ import { KIND_LABEL, LEVEL_LABEL, type ExerciseKind } from \"@/lib/exercise-label\n import { codingHref, practiceHref, queryOfHref } from \"@/lib/list-query\";\n import { useLocale } from \"@/lib/locale\";\n import { modeById, type ModeId } from \"@/lib/modes\";\n+import { usePlanSignal } from \"@/lib/plan-signal\";\n import { useProgress } from \"@/lib/progress\";\n import { attemptMs, bestPass, fmtClock } from \"./","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cb77d966c78c39870fb30dd78e68374ad8246c08/components%2Fsidebars.tsx"},{"filename":"lib/plan-signal.tsx","previous_filename":null,"status":"added","additions":65,"deletions":0,"changes":65,"patch":"@@ -0,0 +1,65 @@\n+\"use client\";\n+\n+// 侧栏里「下一步」的**唯一一份**。\n+//\n+// 【为什么要这么一个中转】\n+// 侧栏上下叠着两块东西:上面是引导计划的面板(components/plan-kit.tsx 的\n+// PlanPanel),下面是当前模式的上下文侧栏(components/sidebars.tsx)。\n+// Learn 那一档侧栏自己有一颗「接着学」的大按钮。跟着计划走的时候,\n+// 这两处十有八九指向**同一节课** —— 于是同一屏上出现两张一模一样的实心大卡,\n+// 都写着「往这儿走」。两个入口指同一个地方,那就是没有入口。\n+//\n+// 计划面板算得出「下一格是什么」,但它是懒加载的(只有真的在跟计划的人才\n+// 下载那一套,见 components/plan-slots.tsx);侧栏是每个路由都渲染的,\n+// 不能顺着 plan-kit 把","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cb77d966c78c39870fb30dd78e68374ad8246c08/lib%2Fplan-signal.tsx"},{"filename":"styles/nav.css","previous_filename":null,"status":"modified","additions":22,"deletions":0,"changes":22,"patch":"@@ -1481,3 +1481,25 @@\n gap: 5px;\n }\n }\n+\n+/* 计划之外的「上次自己看到」—— 安静的文字链,不是第二颗按钮。\n+ 跟着计划走、而计划的下一步指向别处时才出现(见 lib/plan-signal.tsx)。\n+ 刻意不给底色和边框:同一屏上只能有一个实心的「往这儿走」。 */\n+.ctx-alt {\n+ margin: -4px 16px 16px;\n+ font-size: 12px;\n+ line-height: 1.55;\n+ color: var(--ink-2);\n+}\n+\n+.ctx-alt-link {\n+ color: var(--ink-2);\n+ text-decoration: underline;\n+ text-decoration-color: var(--rule-strong);\n+ text-underline-off","blob_url":"https://github.com/renrenmimi/DrillLab/blob/cb77d966c78c39870fb30dd78e68374ad8246c08/styles%2Fnav.css"}]},"e911e2960dbe1cb549cd0a6eab1beec9b06457e5":{"sha":"e911e2960dbe1cb549cd0a6eab1beec9b06457e5","stats":{"additions":18,"deletions":1,"total":19},"files":[{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -175,6 +175,7 @@ export function PlanChip({ onNavigate }: { onNavigate?: () => void }) {\n data-empty\n onClick={onNavigate}\n title={t(\"按目标走的引导计划\", \"Goal-based guided plans\")}\n+ aria-label={t(\"引导计划\", \"Guided plans\")}\n >\n <PlanMark />\n <span className=\"topbar-plan-name\">\n@@ -193,6 +194,13 @@ export function PlanChip({ onNavigate }: { onNavigate?: () => void }) {\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e911e2960dbe1cb549cd0a6eab1beec9b06457e5/components%2Fplan-kit.tsx"},{"filename":"components/plan-slots.tsx","previous_filename":null,"status":"modified","additions":10,"deletions":1,"changes":11,"patch":"@@ -31,6 +31,7 @@\n \n import dynamic from \"next/dynamic\";\n import Link from \"next/link\";\n+import { useT } from \"@/lib/locale\";\n import { useProgress } from \"@/lib/progress\";\n import { PlanMark } from \"./plan-mark\";\n import { T } from \"./t\";\n@@ -63,12 +64,20 @@ function useFollowing(): boolean {\n \n export function PlanChipSlot({ onNavigate }: { onNavigate?: () => void }) {\n const following = useFollowing();\n+ const ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e911e2960dbe1cb549cd0a6eab1beec9b06457e5/components%2Fplan-slots.tsx"}]},"8f46ce6689ce0a574bf2f9b27c1bc6727f572386":{"sha":"8f46ce6689ce0a574bf2f9b27c1bc6727f572386","stats":{"additions":5067,"deletions":3015,"total":8082},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":112,"deletions":50,"changes":162,"patch":"@@ -8,23 +8,26 @@\n \n ```\n 引导计划 「告诉我下一步做什么」 /plans 六条按目标走的有序路径\n-四个模式 「让我自己挑」 顶栏 学课程 / 背知识点 / 做练习 / 模拟考试\n+资料库 「让我自己挑」 侧栏 学课程 / 背知识点 / 做练习 / 检验\n ```\n \n-材料分成四条主线,**导航按「你想做哪一类事」分成四个模式**\n-(见 [lib/modes.ts](lib/modes.ts)):\n+材料分成四条主线,**导航整套在左侧栏里**(见 [lib/side-nav.ts](lib/side-nav.ts)\n+和 [docs/ui-v2.md](docs/ui-v2.md)):\n \n ```\n-模式(顶栏) 路由 材料\n-学课程 Learn /pat","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/CLAUDE.md"},{"filename":"README.md","previous_filename":null,"status":"modified","additions":29,"deletions":12,"changes":41,"patch":"@@ -46,27 +46,44 @@ into them, and **completion is derived from the progress you already have**. Que\n you rated before picking a plan count towards it; anything you finish inside a plan is\n still ticked in the courses.\n \n-**The four modes** answer \"let me browse and choose.\" They are unchanged.\n+**The library** answers \"let me browse and choose.\" Nothing is ever locked.\n \n-## Four ways to explore\n+## One navigation, ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/README.md"},{"filename":"app/globals.css","previous_filename":null,"status":"modified","additions":8,"deletions":0,"changes":8,"patch":"@@ -1,12 +1,20 @@\n @import \"../styles/tokens.css\";\n @import \"../styles/base.css\";\n+/* layout.css 排在 base 之后、各页面样式之前 —— 它是**布局原语**\n+ (容器 / 页面头 / 卡片 / 进度 / 筛选条),页面样式在它之上做特例。\n+ 见 docs/ui-v2.md。 */\n+@import \"../styles/layout.css\";\n @import \"../styles/shell.css\";\n /* nav.css 必须排在 shell.css 之后 —— 它重写了 .topbar 的布局,\n 也在 media query 里改了 --topbar-h。同优先级下靠源码顺序决定胜负。 */\n @import \"../styles/nav.css\";\n /* plans.css 排在 nav.cs","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/app%2Fglobals.css"},{"filename":"app/layout.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -26,7 +26,7 @@ export const viewport: Viewport = {\n \n export default function RootLayout({ children }: { children: ReactNode }) {\n return (\n- <html lang=\"en\" data-theme=\"light\" suppressHydrationWarning>\n+ <html lang=\"en\" data-theme=\"dark\" suppressHydrationWarning>\n <head>\n {/* 在 React 接管前定好主题和语言,避免首屏闪一下 */}\n <script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP }} />","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/app%2Flayout.tsx"},{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":218,"deletions":237,"changes":455,"patch":"@@ -1,110 +1,65 @@\n \"use client\";\n \n-// 外壳:顶栏 + 随模式而变的侧栏 + 主内容(+ 可选右侧目录栏)。\n+// 外壳(UI v2):**左侧一套导航,顶栏只有工具。**\n //\n // 【这一版换掉了什么,以及为什么】\n //\n-// 上一版是「一个常驻侧栏装下全部」:完整课程树 + 平行支线 + 四张全量表 +\n-// 速查 + 清空进度,全部同时露出。它解决了上上一版「不知道该点左边还是点上边」\n-// 的问题,但换来一个更根本的毛病 ——\n+// 上一版是「顶栏 = 模式,侧栏 = 那个模式的结构」。那一步解决了更早那版\n+// 「不知道该点左边还是点上边」,但它自己留下一个更难受的毛病:\n //\n-// **要先读懂这个站的内容模型,才能决定点哪儿。**\n+// **两套导航同屏竞争。**\n //\n-// 一个刚来的人不知道该从哪开始;一个只想复习 React 的人也","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fapp-shell.tsx"},{"filename":"components/arena-brief.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -59,7 +59,7 @@ export function ArenaBrief({ id }: { id: string }) {\n <div className=\"eyebrow\">\n <T zh=\"开考前\" en=\"Before you start\" />\n </div>\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T zh={a.title} en={a.titleEn} />\n </h1>\n <p className=\"page-lede\">","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Farena-brief.tsx"},{"filename":"components/arena-list.tsx","previous_filename":null,"status":"modified","additions":184,"deletions":149,"changes":333,"patch":"@@ -2,6 +2,10 @@\n \n // 考场列表 —— 客户端组件,因为它要读 localStorage 里的尝试记录。\n //\n+// 【页面骨架】和 /drill、/practice、/code 完全一样,见 styles/layout.css\n+// (ui-* 原语)与 styles/lesson.css 里 .mode-page 那一段:\n+// 眉题 → 大标题 → 一句话 → 计划上下文 → 进度摘要 →(这一页没有可筛的东西)→ 内容\n+//\n // 它只 import content/nav(生成物,纯数据),不 import content/arena。\n // 理由见 CLAUDE.md:客户端组件一旦 import 内容,全部课程正文会被打进同一个\n // chunk(踩过一次,784 KB)。题面、需求、答案全部留在服务端那三页。\n@@ -50,174 +54,205 @@ export fu","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Farena-list.tsx"},{"filename":"components/arena-review.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -45,7 +45,7 @@ export function ArenaReview({ id }: { id: string }) {\n <div className=\"eyebrow\">\n <T zh=\"交卷后 · 自评与复盘\" en=\"Handed in · self-assessment\" />\n </div>\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T zh=\"先自评,再看答案\" en=\"Self-assess first, then read the answer\" />\n </h1>\n <p className=\"page-lede\">","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Farena-review.tsx"},{"filename":"components/arena-run.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -87,7 +87,7 @@ export function ArenaRun({ id }: { id: string }) {\n <div className=\"eyebrow\">\n <T zh=\"考场 · 进行中\" en=\"Arena · in progress\" />\n </div>\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T zh={a.title} en={a.titleEn} />\n </h1>\n <p className=\"page-lede\">{a.scenario}</p>","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Farena-run.tsx"},{"filename":"components/coding-detail.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -124,7 +124,7 @@ export function CodingDetail({ id }: { id: string }) {\n <PlanItemBannerSlot itemKey={itemKey(\"coding\", problem.id)} />\n \n <div className=\"page-head\">\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T zh={problem.title} en={problem.titleEn} />\n </h1>\n <div className=\"lesson-meta\" style={{ marginTop: 14 }}>","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fcoding-detail.tsx"},{"filename":"components/coding-list.tsx","previous_filename":null,"status":"modified","additions":261,"deletions":221,"changes":482,"patch":"@@ -1,5 +1,9 @@\n // Coding 题列表 —— 服务端组件。\n //\n+// 【页面骨架】和 /drill、/practice、/arena 完全一样,见 styles/layout.css\n+// (ui-* 原语)与 styles/lesson.css 里 .mode-page 那一段:\n+// 眉题 → 大标题 → 一句话 → 计划上下文 → 进度摘要 → 筛选行 → 内容\n+//\n // 和 /practice 一个路子:筛选条件放 URL,按钮就是普通链接。\n // 这样筛选状态能分享、能后退,而且这一页不用把全部题面塞进客户端包。\n //\n@@ -63,7 +67,10 @@ export function CodingList({ query }: { query: CodingQuery }) {\n const trackName = track === \"all\" ? undefin","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fcoding-list.tsx"},{"filename":"components/coding-progress.tsx","previous_filename":null,"status":"modified","additions":22,"deletions":15,"changes":37,"patch":"@@ -59,31 +59,38 @@ export function CodingDoneToggle({\n );\n }\n \n-/** 列表页右栏的计数 */\n+/**\n+ * 列表页页头下面那条进度。\n+ *\n+ * 【为什么不再是右栏里的一个大数字】\n+ * 四个模式列表页现在共用同一条进度原语(.ui-prog,见 styles/layout.css)。\n+ * 老版本这里是右栏的 .rail-stat:22px 的大数字 + 一条 5px 的轨道,\n+ * 而同一个「N / M」在别的列表页上又是另一套字号 —— 一份东西四种长相。\n+ */\n export function CodingCount({ total }: { total: number }) {\n const { ready, data } = useProgress();\n const done = ready ? Object.keys","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fcoding-progress.tsx"},{"filename":"components/continue.tsx","previous_filename":null,"status":"modified","additions":56,"deletions":67,"changes":123,"patch":"@@ -18,6 +18,7 @@\n \n import dynamic from \"next/dynamic\";\n import Link from \"next/link\";\n+import { usePathname } from \"next/navigation\";\n import { NAV, lessonPath, navExam, navLessonsOf } from \"@/content/nav\";\n import { pathGroups } from \"@/content/path\";\n import { useT } from \"@/lib/locale\";\n@@ -189,6 +190,60 @@ export function ContinueButton() {\n return <RecentContinueButton />;\n }\n \n+/* ==========================","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fcontinue.tsx"},{"filename":"components/drill-card.tsx","previous_filename":null,"status":"modified","additions":35,"deletions":28,"changes":63,"patch":"@@ -68,7 +68,9 @@ export function DrillCard({\n const track = DRILL_TRACK_LABEL[meta.track];\n \n return (\n- <article className=\"drill-card\" id={`d-${id}`} data-mark={mark}>\n+ // 骨架是共享的 .ui-card[data-rows]:头 / 正文 / 页脚三层,正文占 1fr。\n+ // 所以头上的标签换行、答案展开,都不会让页脚离开那条底线。\n+ <article className=\"ui-card drill-card\" data-rows id={`d-${id}`} data-mark={mark}>\n <div className=\"drill-card-top\">\n <span clas","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fdrill-card.tsx"},{"filename":"components/drill-detail.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -73,7 +73,7 @@ export function DrillDetail({ id }: { id: string }) {\n \n <PlanItemBannerSlot itemKey={itemKey(\"drill\", q.id)} />\n \n- <h1 className=\"drill-detail-title serif\">{q.zh}</h1>\n+ <h1 className=\"drill-detail-title display\">{q.zh}</h1>\n <p className=\"drill-detail-en\" lang=\"en\">\n {q.en}\n </p>","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fdrill-detail.tsx"},{"filename":"components/drill-list.tsx","previous_filename":null,"status":"modified","additions":229,"deletions":213,"changes":442,"patch":"@@ -1,5 +1,10 @@\n // 八股题库列表 —— 服务端组件。\n //\n+// 【页面骨架】和 /practice、/code、/arena 完全一样,见\n+// styles/layout.css(ui-* 原语)与 styles/lesson.css 里 .mode-page 那一段:\n+// 眉题 → 大标题 → 一句话 → 计划上下文 → 进度摘要 → 筛选行 → 内容\n+// 这一页不再自己写容器宽度、标题字号、进度条、空状态。\n+//\n // 【筛选的分工】(详见 components/drill-card.tsx 顶部那段)\n // track + q → 这里真筛,还分页,URL 可以分享、可以后退;\n // mark → 存在 localStorage 里,服务端读不到,只能由客户端小岛\n@@ -81,7 +86,9 @@ export function DrillList","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fdrill-list.tsx"},{"filename":"components/drill-marks.tsx","previous_filename":null,"status":"modified","additions":20,"deletions":24,"changes":44,"patch":"@@ -87,7 +87,13 @@ export function DrillMarkBadge({ id }: { id: string }) {\n }\n \n /**\n- * 顶部那条进度:99 道里会了多少、模糊多少、不会多少、还没做多少。\n+ * 顶部那条进度 —— 四个模式列表页共用同一个进度原语(.ui-prog,见\n+ * styles/layout.css)。这里只多挂一行四档明细:会 / 模糊 / 不会 / 还没做。\n+ *\n+ * 【为什么不再自己画一条四色分段条】\n+ * 老版本 .drill-strip 自己写了一条 6px 的分段轨道、一个卡片底和一行小标题,\n+ * 于是同一个「N / M」在四个列表页上有四种字号和四种轨道高度。\n+ * 分段的信息一条也没丢:四个数字仍然在下面那行,颜色点还在。\n *\n * 「没做」= 总数 − 有记录的三档之和,不单独存。这样不用担心\n * localSto","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fdrill-marks.tsx"},{"filename":"components/drill-session.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":3,"changes":6,"patch":"@@ -197,7 +197,7 @@ function SessionSetup({ emptyScope }: { emptyScope?: string }) {\n <div className=\"eyebrow\">\n <T zh=\"抽认卡\" en=\"Flashcards\" />\n </div>\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T zh=\"先选范围\" en=\"Pick a range\" />\n </h1>\n <p className=\"page-lede\">\n@@ -359,7 +359,7 @@ function RoundResult({\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fdrill-session.tsx"},{"filename":"components/exam-overview.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -45,7 +45,7 @@ export function ExamOverview({ examId }: { examId: string }) {\n <div className=\"eyebrow\">\n {exam.category} · <T en=\"Course\" zh=\"考试\" />\n </div>\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T zh={exam.title} en={exam.titleEn} />\n </h1>\n <p className=\"page-lede\">","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fexam-overview.tsx"},{"filename":"components/guide.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -27,7 +27,7 @@ export function Guide() {\n <div className=\"eyebrow\">\n <T zh=\"使用说明\" en=\"How to use this\" />\n </div>\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T zh=\"怎么用这个 App\" en=\"How to use DrillLab\" />\n </h1>\n <p className=\"page-lede\">","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fguide.tsx"},{"filename":"components/home-library.tsx","previous_filename":null,"status":"added","additions":356,"deletions":0,"changes":356,"patch":"@@ -0,0 +1,356 @@\n+\"use client\";\n+\n+// 首页第一屏**以下**的部分 —— 「浏览全部材料」、按技术点的快捷入口、\n+// 你的进度,以及那个折叠的「其他」。\n+//\n+// 【为什么单独一个文件,而且是懒加载的】\n+// 这一整块要读 content/nav(92 KB 原始字节:五门课、105 道八股、25 道 coding、\n+// 7 道考场题的清单)才能写出「5 门课 · 80 节」这种计数。而它**整块都在第一屏\n+// 以下** —— 第一屏是三选一(新访客)或那张「下一件事」的卡(回访者)。\n+//\n+// 首页同时要读计划清单(content/plan-manifest,82 KB)才能画第一屏,\n+// 两份数据加起来让首屏 JS 到了 181 kB。把这一块拆成异步 chunk 之后\n+// 首页首屏不再需要 nav —— 实测 181 → 153 kB。\n+//\n+/","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fhome-library.tsx"},{"filename":"components/home.tsx","previous_filename":null,"status":"modified","additions":35,"deletions":379,"changes":414,"patch":"@@ -1,184 +1,58 @@\n \"use client\";\n \n-// 首页 —— 一张「今天做什么」的仪表盘。\n+// 首页 —— 第一屏只让人做**一个**决定。\n //\n-// 【这一版换了第一个问题】\n-// 上一版第一屏问的是「你今天想做哪一类事」,答案是四个模式。那一步是对的,\n-// 但四个模式里只有 Learn 是线性的 —— 一个说「我要准备 React 考试」的人,\n-// 点进 Review 看到的是 105 道题的题库,点进 Practice 看到的是筛选器。\n-// 他要的是一条从现在到考试的路,而首页给不出。\n+// 【UI v2 换掉了什么】\n+// 上一版第一屏对新访客是六张计划卡:每张有名字、结果、适合谁、六七个档位徽章、\n+// 「20 档 · 350 条 · 约 42 小时」和一颗 Start。六张一起看是六个同等重要的选择,\n+// 而一个刚到的人这时候连「Federation 是","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fhome.tsx"},{"filename":"components/learning-path.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":3,"changes":8,"patch":"@@ -13,7 +13,6 @@ import Link from \"next/link\";\n import { NAV, lessonPath, navStages } from \"@/content/nav\";\n import { pathGroups, stageEn } from \"@/content/path\";\n import { useProgress } from \"@/lib/progress\";\n-import { ResumeBar } from \"./continue\";\n import { T } from \"./t\";\n \n \n@@ -85,7 +84,7 @@ export function LearningPath() {\n <div className=\"eyebrow\">\n <T zh=\"学课程 · 路线图\" en=\"Learn · roadmap","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Flearning-path.tsx"},{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"modified","additions":26,"deletions":0,"changes":26,"patch":"@@ -158,6 +158,32 @@ export function LessonBody({ examId, lessonId }: { examId: string; lessonId: str\n 自由浏览的人不该被计划的横幅打扰。 */}\n <PlanItemBannerSlot itemKey={itemKey(\"lesson\", lessonId, examId)} />\n \n+ {/* 【窄于 1280px 时的目录】右栏只在 ≥1280px 出现,所以这里给一个\n+ 折叠的「这一页有什么」。\n+\n+ 两个要点,别改:\n+ ① 它和右栏那一份**任何时刻只有一个在无障碍树里** ——\n+ 另一份是 display: none,而 display: none 会把整棵子","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Flesson-body.tsx"},{"filename":"components/lesson-kit.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -88,7 +88,7 @@ export function LessonHeader({\n {status}\n </div>\n \n- <h1 className=\"lesson-title serif\">{title}</h1>\n+ <h1 className=\"lesson-title display\">{title}</h1>\n <p className=\"lesson-blurb\">{blurb}</p>\n \n {tags && <div className=\"lesson-meta\">{tags}</div>}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Flesson-kit.tsx"},{"filename":"components/mock-detail.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -52,7 +52,7 @@ export function MockDetail({ examId, mockId }: { examId: string; mockId: string\n <PlanItemBannerSlot itemKey={itemKey(\"mock\", mock.id, exam.id)} />\n \n <div className=\"page-head\">\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T zh={mock.title} en={mock.titleEn} />\n </h1>\n <p className=\"page-lede\">","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fmock-detail.tsx"},{"filename":"components/mock-workspace.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -21,7 +21,7 @@ export function MockList() {\n <div className=\"eyebrow\">\n <T en=\"Mock exams\" zh=\"模拟考\" />\n </div>\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T en=\"Do it once with no answer in front of you\" zh=\"在没有答案的情况下做一遍\" />\n </h1>\n <p className=\"page-lede\">","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fmock-workspace.tsx"},{"filename":"components/plan-cards.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -138,7 +138,7 @@ export function ActivePlanCard() {\n )}\n </div>\n \n- <h1 className=\"apc-title serif\" id=\"apc-h\">\n+ <h1 className=\"apc-title display\" id=\"apc-h\">\n <T zh={status.plan.zh} en={status.plan.en} />\n </h1>\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fplan-cards.tsx"},{"filename":"components/plan-dash.tsx","previous_filename":null,"status":"added","additions":208,"deletions":0,"changes":208,"patch":"@@ -0,0 +1,208 @@\n+\"use client\";\n+\n+// 回访者的首页 —— 一张**个人学习仪表盘**。\n+//\n+// 第一屏只回答三个问题,其余全部推到下面:\n+//\n+// 我在准备什么? 计划名 + 当前档\n+// 我走到哪了? 4 / 130 + 剩余估时\n+// 下一步做什么? 一张大卡 + 整页唯一的实心按钮\n+//\n+// 【和上一版的区别】\n+// 上一版第一屏是「计划名 + 进度 + 下一格标题 + 一颗按钮 + 两条文字链」,\n+// 紧接着就是四个模式的等重卡片。四张卡和那颗按钮抢注意力,\n+// 而它们回答的是完全不同的问题(「让我自己挑」)。\n+// 现在四个模式退到最下面一节「浏览全部材料」,标题层级明显更低,\n+// 而且它们本来就一直在侧栏里。\n+//\n+// 【为什么「下一件事」那张卡要写「为什么是它」】\n+// 「下一步是《两个考试项目的目录","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fplan-dash.tsx"},{"filename":"components/plan-detail.tsx","previous_filename":null,"status":"modified","additions":149,"deletions":32,"changes":181,"patch":"@@ -27,9 +27,9 @@ import {\n } from \"@/content/plans\";\n import { useT } from \"@/lib/locale\";\n import { modeById } from \"@/lib/modes\";\n-import { planStatus, type ItemState, type PlanStatus } from \"@/lib/plan-progress\";\n+import { pct, planStatus, type ItemState, type PlanStatus } from \"@/lib/plan-progress\";\n import { useProgress } from \"@/lib/progress\";\n-import { PHASE, PhaseBadge, PlanMeter, STATE_LABEL, StateDot } fro","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fplan-detail.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":79,"deletions":143,"changes":222,"patch":"@@ -22,7 +22,6 @@ import { useT } from \"@/lib/locale\";\n import type { ModeId } from \"@/lib/modes\";\n import { litePlanById, type LiteItem, type LiteStatus } from \"@/lib/plan-lite\";\n import { itemKey, pct, planStatus, type ItemState } from \"@/lib/plan-progress\";\n-import { usePublishPlanNext } from \"@/lib/plan-signal\";\n import type { PlanPhase } from \"@/lib/plan-types\";\n import { useProgress } from \"@/lib/progress\";\n im","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fplan-kit.tsx"},{"filename":"components/plan-list.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":8,"changes":16,"patch":"@@ -20,16 +20,16 @@ export function PlanList() {\n \n return (\n <main className=\"main\" data-rail=\"off\">\n- <div className=\"content pl-list\">\n- <div className=\"page-head\">\n- <div className=\"eyebrow\">\n+ <div className=\"content ui-page pl-list\">\n+ <div className=\"ui-head\">\n+ <div className=\"ui-eyebrow\">\n <PlanMark />\n <T zh=\"引导计划\" en=\"Guided plans\" />\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fplan-list.tsx"},{"filename":"components/plan-picker.tsx","previous_filename":null,"status":"added","additions":235,"deletions":0,"changes":235,"patch":"@@ -0,0 +1,235 @@\n+\"use client\";\n+\n+// 新访客的第一屏 —— **三个决定,不是六张卡**。\n+//\n+// 【为什么从六张改成三个】\n+// 上一版第一屏直接摊开六条计划:每张卡有名字、结果、适合谁、六七个档位徽章、\n+// 「20 档 · 350 条 · 约 42 小时」,还有一颗 Start。六张一起看是\n+// **六个都挺重要的选择**,而一个刚到的人这时候连「Federation 是什么」都不知道。\n+// 密度本身就是劝退:要读完六张卡才敢点第一下。\n+//\n+// 现在第一屏只问一次,答案只有三个:\n+//\n+// A 什么都想学 → 完整学习路线(推荐,整屏唯一的实心按钮)\n+// B 准备一场考试 → 展开四个紧凑子项(React / Federation / Spring / Cab)\n+// C 准备面试 →","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fplan-picker.tsx"},{"filename":"components/plan-slots.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":40,"changes":48,"patch":"@@ -24,24 +24,17 @@\n // 结果:没跟计划的人首屏字节和改动前一样;跟着计划的人在 hydration 之后\n // 多下一个 chunk —— 而那正是那些零件本来就要等的时刻。\n //\n-// 【为什么「计划」那个入口是静态的】\n-// 顶栏那枚徽标在没选计划时是一个普通的「计划」链接,它必须在首屏 HTML 里\n-// (否则四个模式会在 hydration 后横向跳一下)。所以它在这个文件里,\n-// 不走懒加载 —— 它只是一个 Link 加一个内联 SVG。\n+// 【UI v2 删掉了两个插槽】\n+// 顶栏那枚计划徽标(PlanChipSlot)和侧栏那块旧面板(PlanPanelSlot)都没了:\n+// 顶栏不再有导航,计划状态改成侧栏里那块紧凑的 PlanSideBlock,\n+// 「下一步」由全站唯一那颗 SideContinue 负责。\n \n import dynamic from","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fplan-slots.tsx"},{"filename":"components/practice-page.tsx","previous_filename":null,"status":"modified","additions":236,"deletions":220,"changes":456,"patch":"@@ -1,7 +1,11 @@\n // 练习场 —— 服务端组件。\n //\n+// 【页面骨架】和 /drill、/code、/arena 完全一样,见 styles/layout.css\n+// (ui-* 原语)与 styles/lesson.css 里 .mode-page 那一段:\n+// 眉题 → 大标题 → 一句话 → 计划上下文 → 进度摘要 → 筛选行 → 内容\n+//\n // 【为什么改成服务端 + URL 参数筛选】\n-// 这一页要遍历全站 103 个练习。以前它是 \"use client\" 并 import registry,\n+// 这一页要遍历全站 148 个练习。以前它是 \"use client\" 并 import registry,\n // 于是全部课程内容都被打进客户端包。改成服务端之后:\n // · 筛选条件放在 URL 里(?exam=&kind=&level=&page=),按钮","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fpractice-page.tsx"},{"filename":"components/practice-progress.tsx","previous_filename":null,"status":"modified","additions":27,"deletions":27,"changes":54,"patch":"@@ -1,7 +1,12 @@\n \"use client\";\n \n-// 练习场右栏的「你做对过」—— 只有这一小块需要读进度。\n+// 练习场页头下面那条进度 —— 只有这一小块需要读 localStorage。\n // 它按 nav 里的 id 清单去数,不需要 import 全文内容。\n+//\n+// 【为什么不再是右栏里的一个大数字】\n+// 四个模式列表页现在共用同一条进度原语(.ui-prog,见 styles/layout.css)。\n+// 老版本这里是右栏的 .rail-stat:22px 的大数字 + 一条 5px 的轨道,\n+// 而同一个「N / M」在别的列表页上又是另一套字号 —— 一份东西四种长相。\n \n import { NAV } from \"@/content/nav\";\n import { useProgress } from \"@/lib/progress\";\n@@ -18,36 +23","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fpractice-progress.tsx"},{"filename":"components/reference.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -45,7 +45,7 @@ export function Reference() {\n <div className=\"eyebrow\">\n <T en=\"Reference\" zh=\"速查\" />\n </div>\n- <h1 className=\"page-title serif\">\n+ <h1 className=\"page-title\">\n <T en=\"The pages you actually flip to\" zh=\"考场上会翻的那几页\" />\n </h1>\n <p className=\"page-lede\">","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Freference.tsx"},{"filename":"components/side-nav.tsx","previous_filename":null,"status":"added","additions":98,"deletions":0,"changes":98,"patch":"@@ -0,0 +1,98 @@\n+\"use client\";\n+\n+// 侧栏的**主导航** —— 全站唯一一套,位置在每一页上都一样。\n+//\n+// 顺序是刻意的:\n+// ① 品牌\n+// ② 今天 / 我的计划 —— 「我在准备什么」\n+// ③ 计划进度 + 一颗〔继续〕 —— 「下一步做哪一件事」\n+// ④ 资料库 / 检验 / 速查 —— 「让我自己挑」\n+//\n+// ③ 在 ④ 上面,是因为「继续」是这个产品的主动作。把它放在导航列表下面,\n+// 它就变成了第九个链接。\n+//\n+// 【这个文件不许 import 任何内容模块】\n+// 它每一页都渲染。lib/side-nav.ts 是静态结构(没有计数),\n+// 计划那一块是懒加载的(components/plan-slots.tsx)——\n+// 没跟计划的人身上它一个字节都不下载。\n+\n+import Link","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fside-nav.tsx"},{"filename":"components/sidebars.tsx","previous_filename":null,"status":"modified","additions":13,"deletions":49,"changes":62,"patch":"@@ -47,7 +47,6 @@ import { KIND_LABEL, LEVEL_LABEL, type ExerciseKind } from \"@/lib/exercise-label\n import { codingHref, practiceHref, queryOfHref } from \"@/lib/list-query\";\n import { useLocale } from \"@/lib/locale\";\n import { modeById, type ModeId } from \"@/lib/modes\";\n-import { usePlanSignal } from \"@/lib/plan-signal\";\n import { useProgress } from \"@/lib/progress\";\n import { attemptMs, bestPass, fmtClock } from \"./","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/components%2Fsidebars.tsx"},{"filename":"content/plan-manifest.ts","previous_filename":null,"status":"modified","additions":107,"deletions":0,"changes":107,"patch":"@@ -51,6 +51,9 @@ export interface ManifestStage {\n phase: PlanPhase;\n zh: string;\n en: string;\n+ /** 这一档为什么在这儿。计划唯一自己拥有的文字 */\n+ whyZh: string;\n+ whyEn: string;\n /** 指向 PLAN_ITEMS 的下标。跨计划共享同一张表,所以「从零完整学习」\n * 和「React 考试准备」里同一节课只存一份 */\n items: number[];\n@@ -438,6 +441,8 @@ export const PLAN_MANIFEST: ManifestPlan[] = [\n \"phase\": \"learn\",\n \"zh\": \"地基:一个项目是怎么跑起来的\",\n \"en\": \"Foundati","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/content%2Fplan-manifest.ts"},{"filename":"docs/ui-v2-screens.md","previous_filename":null,"status":"added","additions":36,"deletions":0,"changes":36,"patch":"@@ -0,0 +1,36 @@\n+# UI v2 的前后对照\n+\n+左边是 `feat/guided-learning-plans`(这条分支的父分支),右边是这一版。\n+截图都在 1440×900(移动端三张在 390×844),英文界面,深色主题为默认。\n+造数据的种子:跟着 React Assessment、读完地基前三节、标了一道八股、\n+做完一道 coding —— 也就是「回访者」那一档。\n+\n+| 页面 | 之前 | 之后 |\n+| --- | --- | --- |\n+| 首页(新访客) | ![](ui-v2/fresh-home-before.jpg) | ![](ui-v2/fresh-home-after.jpg) |\n+| 首页(跟着计划) | ![](ui-v2/active-home-before.jpg) | ![](ui-v2/active-home-after.jpg) |\n+| 计划列表 ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2-screens.md"},{"filename":"docs/ui-v2.md","previous_filename":null,"status":"added","additions":203,"deletions":0,"changes":203,"patch":"@@ -0,0 +1,203 @@\n+# UI v2 — 训练指挥台(Training command center)\n+\n+这一版**不改任何内容、计划定义、进度语义、URL、双语行为、搜索覆盖或判分逻辑**。\n+改的是「一个人打开这个站,两秒之内能不能回答三个问题」:\n+\n+```\n+我在准备什么? → 侧栏常驻的「我的计划」+ 进度\n+我走到哪了? → 计划页的路线图 / 页内那条位置带\n+下一步做哪一件事? → 全站每一屏只有一个视觉主导的动作\n+```\n+\n+上一版(意图优先导航 + 引导计划)功能是对的,毛病在**同等重量的选择太多**:\n+顶栏有「计划徽标 + 四个模式 + 继续」,侧栏又有「计划面板 + 接着学 + 课程树 +\n+下一节卡片」。两套导航体系同屏竞争,于是「下一步」被稀释成六七个都挺重要的东西。\n+\n+---\n+\n+## 1. 新的导航层级:**只有一套**\n+\n+```\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2.md"},{"filename":"docs/ui-v2/active-home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Factive-home-after.jpg"},{"filename":"docs/ui-v2/active-home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Factive-home-before.jpg"},{"filename":"docs/ui-v2/arena-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Farena-after.jpg"},{"filename":"docs/ui-v2/arena-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Farena-before.jpg"},{"filename":"docs/ui-v2/code-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fcode-after.jpg"},{"filename":"docs/ui-v2/code-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fcode-before.jpg"},{"filename":"docs/ui-v2/fresh-home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Ffresh-home-after.jpg"},{"filename":"docs/ui-v2/fresh-home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Ffresh-home-before.jpg"},{"filename":"docs/ui-v2/lesson-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Flesson-after.jpg"},{"filename":"docs/ui-v2/lesson-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Flesson-before.jpg"},{"filename":"docs/ui-v2/m-home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fm-home-after.jpg"},{"filename":"docs/ui-v2/m-home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fm-home-before.jpg"},{"filename":"docs/ui-v2/m-lesson-drawer-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fm-lesson-drawer-after.jpg"},{"filename":"docs/ui-v2/m-lesson-drawer-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fm-lesson-drawer-before.jpg"},{"filename":"docs/ui-v2/m-plan-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fm-plan-after.jpg"},{"filename":"docs/ui-v2/m-plan-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fm-plan-before.jpg"},{"filename":"docs/ui-v2/plan-list-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fplan-list-after.jpg"},{"filename":"docs/ui-v2/plan-list-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fplan-list-before.jpg"},{"filename":"docs/ui-v2/plan-react-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fplan-react-after.jpg"},{"filename":"docs/ui-v2/plan-react-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fplan-react-before.jpg"},{"filename":"docs/ui-v2/practice-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fpractice-after.jpg"},{"filename":"docs/ui-v2/practice-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Fpractice-before.jpg"},{"filename":"docs/ui-v2/review-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Freview-after.jpg"},{"filename":"docs/ui-v2/review-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/docs%2Fui-v2%2Freview-before.jpg"},{"filename":"lib/plan-lite.ts","previous_filename":null,"status":"modified","additions":5,"deletions":0,"changes":5,"patch":"@@ -38,6 +38,9 @@ export interface LiteStage {\n phase: PlanPhase;\n zh: string;\n en: string;\n+ /** 这一档为什么在这儿 —— 首页那张「下一件事」的卡用它回答「为什么是这一件」 */\n+ whyZh: string;\n+ whyEn: string;\n items: LiteItem[];\n }\n \n@@ -90,6 +93,8 @@ function expand(m: (typeof PLAN_MANIFEST)[number]): LitePlan {\n phase: s.phase,\n zh: s.zh,\n en: s.en,\n+ whyZh: s.whyZh,\n+ whyEn: s.whyEn,\n items: s.items.map(itemAt),\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/lib%2Fplan-lite.ts"},{"filename":"lib/plan-signal.tsx","previous_filename":null,"status":"removed","additions":0,"deletions":65,"changes":65,"patch":"@@ -1,65 +0,0 @@\n-\"use client\";\n-\n-// 侧栏里「下一步」的**唯一一份**。\n-//\n-// 【为什么要这么一个中转】\n-// 侧栏上下叠着两块东西:上面是引导计划的面板(components/plan-kit.tsx 的\n-// PlanPanel),下面是当前模式的上下文侧栏(components/sidebars.tsx)。\n-// Learn 那一档侧栏自己有一颗「接着学」的大按钮。跟着计划走的时候,\n-// 这两处十有八九指向**同一节课** —— 于是同一屏上出现两张一模一样的实心大卡,\n-// 都写着「往这儿走」。两个入口指同一个地方,那就是没有入口。\n-//\n-// 计划面板算得出「下一格是什么」,但它是懒加载的(只有真的在跟计划的人才\n-// 下载那一套,见 components/plan-slots.tsx);侧栏是每个路由都渲染的,\n-// 不能顺着 plan-kit 把","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e911e2960dbe1cb549cd0a6eab1beec9b06457e5/lib%2Fplan-signal.tsx"},{"filename":"lib/side-nav.ts","previous_filename":null,"status":"added","additions":114,"deletions":0,"changes":114,"patch":"@@ -0,0 +1,114 @@\n+// 侧栏那一套**主导航**的结构 —— 全站唯一一份,位置在每一页上都一样。\n+//\n+// 【为什么这一版把导航搬进侧栏】\n+// 上一版是「顶栏四个模式 + 侧栏那个模式的结构」。它比更早那版好,但仍然是\n+// **两套导航同屏竞争**:顶栏有计划徽标 + 四个模式 + 一颗「继续」,侧栏又有\n+// 计划面板 + 「接着学」+ 课程树。同一屏上七八个都挺重要的东西,\n+// 于是「下一步做什么」被稀释掉了。\n+//\n+// 现在只有一套:**侧栏是导航,顶栏是工具。**\n+// 顶栏只剩「我在哪」(面包屑)和搜索 / 语言 / 主题 / 帮助。\n+//\n+// 【为什么这里一个内容模块都不 import】\n+// 侧栏现在**每一页都渲染**(老版本只在四个模式的页面上渲染)。\n+// 一旦这里 import content/nav(134 KB),每个路由的首屏都要带上它。\n+","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/lib%2Fside-nav.ts"},{"filename":"lib/theme.tsx","previous_filename":null,"status":"modified","additions":13,"deletions":10,"changes":23,"patch":"@@ -1,13 +1,16 @@\n \"use client\";\n \n-// 主题(浅色 / 深色)—— 没选过就跟随系统,选过就记住。\n+// 主题(深色 / 浅色)—— 没选过就是深色,选过就记住。\n // 首屏闪烁由 layout.tsx 里那段 beforeInteractive 脚本消掉。\n //\n-// 【为什么不是「默认浅色」】老版本无视 prefers-color-scheme,\n-// 系统开着深色的人打开这个站会被一整屏米白闪一下。\n-// 现在:localStorage 里有值就用它(用户的明确选择优先),\n-// 没有就问系统。用户点过切换之后才写 localStorage,所以\n-// 「从没点过」和「点回了浅色」是两种状态,不会混。\n+// 【UI v2 为什么默认深色,而不是跟随系统】\n+// 这一版的界面方向就是深色高对比(见 docs/ui-v2.md):柠檬绿强调色、\n+// 四层深表面、代码","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/lib%2Ftheme.tsx"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":5,"deletions":0,"changes":5,"patch":"@@ -153,6 +153,11 @@ export function GET() {\n phase: st.phase,\n zh: st.zh,\n en: st.en,\n+ // 「这一档为什么在这儿」是计划**唯一自己拥有的文字**。\n+ // 首页那张「下一件事」的卡要用它回答「为什么是这一件」,\n+ // 所以它必须进轻量清单(45 档 × 两种语言,约 10 KB)。\n+ whyZh: st.whyZh,\n+ whyEn: st.whyEn,\n items: st.items.map((it) => {\n if (!index.has(it.key)) {\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/plan-manifest-template.txt","previous_filename":null,"status":"modified","additions":3,"deletions":0,"changes":3,"patch":"@@ -51,6 +51,9 @@ export interface ManifestStage {\n phase: PlanPhase;\n zh: string;\n en: string;\n+ /** 这一档为什么在这儿。计划唯一自己拥有的文字 */\n+ whyZh: string;\n+ whyEn: string;\n /** 指向 PLAN_ITEMS 的下标。跨计划共享同一张表,所以「从零完整学习」\n * 和「React 考试准备」里同一节课只存一份 */\n items: number[];","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/scripts%2Fplan-manifest-template.txt"},{"filename":"styles/arena.css","previous_filename":null,"status":"modified","additions":9,"deletions":31,"changes":40,"patch":"@@ -17,10 +17,10 @@\n .arena-live {\n display: flex;\n align-items: center;\n- gap: 14px;\n+ gap: var(--sp-2);\n flex-wrap: wrap;\n- margin: 0 0 22px;\n- padding: 14px 16px;\n+ margin: 0;\n+ padding: var(--sp-2);\n border: 1px solid var(--danger-line);\n border-left: 3px solid var(--danger);\n border-radius: var(--r);\n@@ -45,35 +45,13 @@\n margin-left: auto;\n }\n \n-/* 三个数字的概览条 */\n-.arena-sum {\n- display: flex;\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Farena.css"},{"filename":"styles/base.css","previous_filename":null,"status":"modified","additions":22,"deletions":31,"changes":53,"patch":"@@ -18,11 +18,9 @@ body {\n margin: 0;\n background: var(--paper);\n color: var(--ink);\n- font-family:\n- -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Inter\", \"Noto Sans SC\",\n- \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", Roboto, sans-serif;\n- font-size: 17px;\n- line-height: 1.72;\n+ font-family: var(--font-text);\n+ font-size: var(--fs-body);\n+ line-height: 1.68;\n font-feature-settings: \"ke","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fbase.css"},{"filename":"styles/coding.css","previous_filename":null,"status":"modified","additions":39,"deletions":46,"changes":85,"patch":"@@ -2,80 +2,83 @@\n Coding 题:列表页 (/code) 与详情页 (/code/[id])\n ------------------------------------------------------------\n 【这个文件曾经是空的】原来只有一行注释,48 字节 ——\n- coding-list.tsx 用的 .cd-list / .cd-row / .cd-row-main /\n- .cd-row-title / .cd-row-meta,以及 .cd-badge / .cd-brief /\n- .cd-fold / .cd-hidden,全站一条规则都没有。\n+ coding-list.tsx 用的那一族 .cd-* 全站一条规则都没有,\n 于是 /code 的题目列表就是一个裸 <ol>:标题、四个标签、\n 「8 条验收标准」、「未开始」全挤在一行","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fcoding.css"},{"filename":"styles/dash.css","previous_filename":null,"status":"added","additions":507,"deletions":0,"changes":507,"patch":"@@ -0,0 +1,507 @@\n+/* ============================================================\n+ 首页的两个第一屏\n+ ------------------------------------------------------------\n+ .pick-* 新访客:三个决定(components/plan-picker.tsx)\n+ .dash2-* 回访者:学习仪表盘(components/plan-dash.tsx)\n+\n+ 两者互斥,任何时候只渲染一个。共同的规矩:\n+ ① 整屏**只有一颗实心按钮**(.pick-cta / .dash2-cta);\n+ ② 强调色只出现在那颗按钮、进度条已完成的那一段、和「现在」那个标记上;\n+ ③ 第一屏之外的东西一律用 .ui-sec[data-quiet] 那一档标题","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fdash.css"},{"filename":"styles/drill.css","previous_filename":null,"status":"modified","additions":36,"deletions":88,"changes":124,"patch":"@@ -7,29 +7,18 @@\n \n /* ============================================================\n 顶部那条「你的掌握情况」\n- ============================================================ */\n-\n-.drill-strip {\n- margin-bottom: 26px;\n- padding: 14px 16px;\n- background: var(--raised);\n- border: 1px solid var(--rule);\n- border-radius: var(--r);\n-}\n-\n-.drill-strip-head {\n- display: flex;\n- align-items: baseline;\n- gap: 12px;\n- margin","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fdrill.css"},{"filename":"styles/exercise.css","previous_filename":null,"status":"modified","additions":5,"deletions":76,"changes":81,"patch":"@@ -181,13 +181,13 @@\n .opt[data-state=\"right\"] .opt-key {\n background: var(--accent);\n border-color: var(--accent);\n- color: #fff;\n+ color: var(--on-accent);\n }\n \n .opt[data-state=\"wrong\"] .opt-key {\n background: var(--danger);\n border-color: var(--danger);\n- color: #fff;\n+ color: var(--on-danger);\n }\n \n .opt-label {\n@@ -475,85 +475,17 @@\n \n /* ---------- 测试结果 ---------- */\n \n-.testres {\n- margin: 16px ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fexercise.css"},{"filename":"styles/layout.css","previous_filename":null,"status":"added","additions":366,"deletions":0,"changes":366,"patch":"@@ -0,0 +1,366 @@\n+/* ============================================================\n+ 布局原语(UI v2)\n+ ------------------------------------------------------------\n+ 「感觉没对齐」的根子不是某一处写错了 padding,而是\n+ **十几个页面各自手写了一套容器、标题、元信息行和筛选条**。\n+ 同一个东西写十遍,就会有十个略微不同的值。\n+\n+ 所以这里定义一次,所有页面用同一套。\n+ **全部带 ui- 前缀** —— 一眼看得出「这是共享原语,不是某一页自己的类」,\n+ 而且不会撞上已有的类名(.sec-head / .page-head / .sec-title / .empty\n+ 在 styles/lesson.css 里早就","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Flayout.css"},{"filename":"styles/lesson.css","previous_filename":null,"status":"modified","additions":168,"deletions":37,"changes":205,"patch":"@@ -46,15 +46,21 @@\n font-variant-numeric: tabular-nums;\n }\n \n+/* 【UI v2】课名从 31px 提到 40px 并换成几何无衬线。\n+ 它是这一页的主角 —— 老版本它和右边目录里的条目、左边侧栏的课程树\n+ 字号差得不够多,一屏上有三处都像标题。 */\n .lesson-title {\n- font-size: 31px;\n- line-height: 1.24;\n- margin: 6px 0 10px;\n+ font-family: var(--font-display);\n+ font-size: 40px;\n+ font-weight: 680;\n+ letter-spacing: -0.025em;\n+ line-height: 1.1;\n+ margin: var(--sp-1) 0 var(--sp-2);\n }\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Flesson.css"},{"filename":"styles/nav.css","previous_filename":null,"status":"modified","additions":252,"deletions":249,"changes":501,"patch":"@@ -1,119 +1,227 @@\n /* ============================================================\n- 意图优先的导航\n+ 导航(UI v2)\n ------------------------------------------------------------\n- 顶栏 = 四个模式(我现在想做哪一类事)\n- 侧栏 = 那个模式自己的结构(我在哪、下一步是什么)\n- 加上首页那张仪表盘,和课程页的方位信息。\n+ 侧栏 = 全站唯一一套导航(主导航位 + 当前这一类事情的结构)\n+ 顶栏 = 位置 + 四个工具,一个导航链接都没有\n \n 这个文件排在 shell.css 之后(见 app/globals.css 的 @import 顺序),\n- 所以这里对 .topbar、.sidebar 的重写会盖掉那边的旧值","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fnav.css"},{"filename":"styles/plans.css","previous_filename":null,"status":"modified","additions":147,"deletions":219,"changes":366,"patch":"@@ -79,13 +79,9 @@\n .st-dot[data-state=\"passed\"] {\n border-color: var(--ok);\n background: var(--ok);\n- color: #fff;\n+ color: var(--on-ok);\n }\n \n-[data-theme=\"dark\"] .st-dot[data-state=\"confident\"],\n-[data-theme=\"dark\"] .st-dot[data-state=\"passed\"] {\n- color: #0f1512;\n-}\n \n .st-dot[data-state=\"attempted\"] {\n border-color: var(--warn-line);\n@@ -223,118 +219,20 @@\n 侧栏顶部那块面板\n ==============================","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fplans.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":135,"deletions":102,"changes":237,"patch":"@@ -1,126 +1,141 @@\n /* ============================================================\n- 外壳:顶栏 / 左侧侧栏 / 主内容 / 右侧目录栏\n+ 外壳(UI v2):左侧导航列 / 顶栏工具条 / 主内容 / 可选右侧目录\n ------------------------------------------------------------\n- 不做厚重三栏后台:两侧都是「无框」的,只用一条细线和留白分区。\n+ 栅格是两列两行,**侧栏跨满两行** —— 品牌在侧栏顶部,不在顶栏。\n \n- 【顶栏和侧栏里面长什么样在 styles/nav.css】\n- 这里只留外框:栅格、sticky、抽屉、滚动。\n- 侧栏的内容随模式变(学课程 / 背知识点 / 做练习 / 模拟考试),\n- 四套内容的样式和顶栏那四","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fshell.css"},{"filename":"styles/tokens.css","previous_filename":null,"status":"modified","additions":217,"deletions":127,"changes":344,"patch":"@@ -1,147 +1,237 @@\n /* ============================================================\n- DrillLab · Design Tokens\n+ DrillLab · Design Tokens (UI v2)\n ------------------------------------------------------------\n- 方向:纸感、克制、久读不累。\n- 明确不做:紫蓝渐变 / 玻璃拟态 / 霓虹描边 / 发光阴影 /\n- backdrop-filter / 渐变文字 / 大面积纯白。\n- 层级只靠四件事:留白、字号、极浅的背景差、一像素细线。\n+ 方向:深色、高对比的**训练指挥台**。\n+ 见 docs/ui-v2.md。\n+\n+ 明确不做:薄荷绿洗色 / 衬线标题 /","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Ftokens.css"},{"filename":"styles/viz.css","previous_filename":null,"status":"modified","additions":5,"deletions":1,"changes":6,"patch":"@@ -87,13 +87,17 @@\n word-break: break-word;\n }\n \n+/* 【为什么箭头用 --ink-3 而不是画线的颜色】\n+ 它是一个「→」字符,说的是数据往哪边流 —— 那是信息,不是装饰。\n+ 老值用 --rule-strong(画线的颜色),深浅两个主题下都只有 1.6–1.7:1,\n+ 扫描一直报它。--ink-3 在 --raised 上是 6.41:1。 */\n .viz-arrow {\n flex: 0 0 auto;\n width: 34px;\n display: flex;\n align-items: center;\n justify-content: center;\n- color: var(--rule-strong);\n+ color: var(--ink-3);\n font-size: 14px;\n }\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8f46ce6689ce0a574bf2f9b27c1bc6727f572386/styles%2Fviz.css"}]},"24f5290b4e2eb369b66865c87a91c93c034df681":{"sha":"24f5290b4e2eb369b66865c87a91c93c034df681","stats":{"additions":42,"deletions":22,"total":64},"files":[{"filename":"styles/layout.css","previous_filename":null,"status":"modified","additions":4,"deletions":6,"changes":10,"patch":"@@ -29,15 +29,13 @@\n \n /* ---------- 页面容器 ---------- */\n \n+/* 【左对齐,不居中】容器只收口宽度,不移动左边界 ——\n+ 居中会让每一页的正文轴随视口和各自的 max-width 漂移,\n+ 而侧栏和顶栏都不动。理由见 styles/shell.css 里 .main 那段。\n+ 外边距由 .main 给,这里只管宽度和垂直节奏。 */\n .ui-page {\n- /* 【必须配 width: 100%】.ui-page 常常是 grid item。单写 margin-inline: auto\n- 会让它从「拉伸填满栅格列」变成「按内容宽度」,窄屏下 max-width 比视口\n- 还宽,整页横向溢出。踩过一次,见 CLAUDE.md。 */\n width: 100%;\n max-width: 1040px;\n- margin-i","blob_url":"https://github.com/renrenmimi/DrillLab/blob/24f5290b4e2eb369b66865c87a91c93c034df681/styles%2Flayout.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":23,"deletions":16,"changes":39,"patch":"@@ -33,7 +33,8 @@\n display: flex;\n align-items: center;\n gap: var(--sp-2);\n- padding: 0 var(--sp-3);\n+ /* 左内边距用 --gutter:顶栏那行「我在哪」因此和页面 h1 落在同一条竖线上 */\n+ padding: 0 var(--gutter);\n background: color-mix(in srgb, var(--paper) 88%, transparent);\n border-bottom: 1px solid var(--rule);\n }\n@@ -167,11 +168,13 @@\n \n /* 分隔线以下:当前这一类事情自己的结构。\n 导航位(上面那一段)在所有页面完全一致,这一段才随页面变。 */\n+/* 分隔线以下:当前这一类事情自己的结构。\n+ 块边界和上半段一样","blob_url":"https://github.com/renrenmimi/DrillLab/blob/24f5290b4e2eb369b66865c87a91c93c034df681/styles%2Fshell.css"},{"filename":"styles/tokens.css","previous_filename":null,"status":"modified","additions":15,"deletions":0,"changes":15,"patch":"@@ -122,6 +122,21 @@\n --rail-w: 232px;\n --topbar-h: 56px;\n \n+ /* —— 对齐轴。全站只有三个数,别在别处手写 ——\n+ 【为什么要把它们提成变量】上一版实测出四条不同的正文左边界\n+ (1440px 上 h1 分别落在 284 / 326 / 433 / 453)加三条不同的侧栏左边界\n+ (16 / 24 / 51),顶栏又是第四条(276)。原因不是某处写错,\n+ 是「容器居中」和「每个块自己写内边距」两件事叠在一起。\n+\n+ --gutter 正文轴 = 侧栏宽 + 这个值。顶栏的内边距也用它,\n+ 所以顶栏那行位置和页面 h1 落在同一条竖线上。\n+ --rail-pad 侧栏里每个**块**的左边界\n+ --rail-","blob_url":"https://github.com/renrenmimi/DrillLab/blob/24f5290b4e2eb369b66865c87a91c93c034df681/styles%2Ftokens.css"}]},"adc4505c3fbd8ffb75299e31286a337dfc5879ab":{"sha":"adc4505c3fbd8ffb75299e31286a337dfc5879ab","stats":{"additions":304,"deletions":202,"total":506},"files":[{"filename":"components/sidebars.tsx","previous_filename":null,"status":"modified","additions":37,"deletions":56,"changes":93,"patch":"@@ -57,28 +57,6 @@ import { T } from \"./t\";\n 公用零件\n ============================================================ */\n \n-function CtxHead({\n- mode,\n- link,\n-}: {\n- mode: ModeId;\n- link?: { href: string; zh: string; en: string };\n-}) {\n- const m = modeById(mode);\n- return (\n- <div className=\"ctx-head\">\n- <span className=\"ctx-head-mode\">\n- <T zh={m.zh} en={m.en} />\n- </span>\n- {link &&","blob_url":"https://github.com/renrenmimi/DrillLab/blob/adc4505c3fbd8ffb75299e31286a337dfc5879ab/components%2Fsidebars.tsx"},{"filename":"styles/nav.css","previous_filename":null,"status":"modified","additions":267,"deletions":146,"changes":413,"patch":"@@ -12,18 +12,29 @@\n 顶栏不再是第二套导航。四个模式搬进了下面的 .snav-list。\n ============================================================ */\n \n-/* ---------- 侧栏:主导航 ---------- */\n+/* ---------- 侧栏:一套东西,一条轴 ----------\n+\n+ 【上一版的毛病】同一个 252px 的列里有八种视觉语言:品牌(图标 + 字,轴 55)、\n+ 导航项(无边框,轴 30)、组标题(等宽大写,轴 24)、计划卡(有边框的盒子,\n+ 轴 32)、〔继续〕(实心块)、模式眉题、全局进度(轴 16)、课程行 + 一个\n+ 柠檬绿的「Start here」药丸。八种语言、四条左边界 —— 看起来就是「换了层皮」。\n+\n+ 现在只有两条规矩:\n+ ① **块*","blob_url":"https://github.com/renrenmimi/DrillLab/blob/adc4505c3fbd8ffb75299e31286a337dfc5879ab/styles%2Fnav.css"}]},"39b537dbc989abfc7c68b07bbdb1ad2ff851b415":{"sha":"39b537dbc989abfc7c68b07bbdb1ad2ff851b415","stats":{"additions":34,"deletions":0,"total":34},"files":[{"filename":"components/plan-dash.tsx","previous_filename":null,"status":"modified","additions":12,"deletions":0,"changes":12,"patch":"@@ -157,6 +157,18 @@ export function PlanDash() {\n <Link className=\"dash2-cta\" href={next.item.href}>\n <T zh=\"继续\" en=\"Continue\" />\n </Link>\n+\n+ {/* 【次要动作必须在同一屏上看得见】选了一条计划之后,\n+ 「看全程 / 换一条 / 先不跟」三条路都得有出口。\n+ 上一版这一屏一个都没有,只能自己想到去点侧栏的「我的计划」。 */}\n+ <p className=\"dash2-alts\">\n+ <Link className=\"ui-quiet\" href={`/plans/${plan.id}`}>\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/39b537dbc989abfc7c68b07bbdb1ad2ff851b415/components%2Fplan-dash.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":12,"deletions":0,"changes":12,"patch":"@@ -243,6 +243,18 @@ export function PlanSideBlock({ onNavigate }: { onNavigate: () => void }) {\n `${status.plan.en} plan, ${status.done} of ${status.total} completed`,\n )}\n >\n+ {/* 【「换一条」必须一直在这儿】上一版选完计划之后,首页和侧栏加起来\n+ 一个换计划的入口都没有 —— 只有点进 /plans 才找得到,\n+ 而那一项写的是「我的计划」,读起来像「看看我的计划」而不是「换一条」。 */}\n+ <div className=\"side-plan-eyebrow\">\n+ <span>\n+ <T zh=\"当前计划\" en=\"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/39b537dbc989abfc7c68b07bbdb1ad2ff851b415/components%2Fplan-kit.tsx"},{"filename":"styles/dash.css","previous_filename":null,"status":"modified","additions":10,"deletions":0,"changes":10,"patch":"@@ -423,6 +423,16 @@\n text-decoration: none;\n }\n \n+/* 主按钮下面那一行次要动作。安静的文字链,不是第二颗按钮 ——\n+ 但必须在同一屏上看得见:上一版选完计划之后,整个首页一个换计划的入口都没有。 */\n+.dash2-alts {\n+ display: flex;\n+ flex-wrap: wrap;\n+ align-items: baseline;\n+ gap: var(--sp-3);\n+ margin: var(--sp-2) 0 0;\n+}\n+\n /* ---------- 当前档的紧凑预览 ---------- */\n \n .dash2-peek {","blob_url":"https://github.com/renrenmimi/DrillLab/blob/39b537dbc989abfc7c68b07bbdb1ad2ff851b415/styles%2Fdash.css"}]},"53438553e3dfc1c56d1dacfb5d14e102139b2d9a":{"sha":"53438553e3dfc1c56d1dacfb5d14e102139b2d9a","stats":{"additions":4,"deletions":0,"total":4},"files":[{"filename":"lib/side-nav.ts","previous_filename":null,"status":"modified","additions":4,"deletions":0,"changes":4,"patch":"@@ -103,6 +103,10 @@ export function activeSideHref(path: string): string | undefined {\n * 顶栏再写一遍是重复。区段名是页面自己不说的那一半。\n */\n export function sectionOf(path: string): { zh: string; en: string } | undefined {\n+ // /guide 不在侧栏里(它在顶栏那个 ? 菜单后面),但顶栏那一行不该因此空着 ——\n+ // 空着的话页面之间会少一条「我在哪」,看起来像漏渲染。\n+ if (path === \"/guide\") return { zh: \"使用说明\", en: \"How to use this\" };\n+\n const href = activeSideHref(path);\n if (!href) retur","blob_url":"https://github.com/renrenmimi/DrillLab/blob/53438553e3dfc1c56d1dacfb5d14e102139b2d9a/lib%2Fside-nav.ts"}]},"1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76":{"sha":"1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76","stats":{"additions":119,"deletions":0,"total":119},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":62,"deletions":0,"changes":62,"patch":"@@ -222,6 +222,68 @@ Sandpack 在浏览器 iframe 里没有 Node,那套确实跑不了)。\n 它的 WebContainers 把 Node 编译进了浏览器,所以连 Federation 那套要\n `npm test` 的服务端项目也能跑,这一点比 Sandpack 强。\n \n+### 【重要】三条轴,全站只有三条\n+\n+「感觉粗糙」的实测根子不是某处写错了 padding,是**同一屏上有八条左边界**。\n+上一版量出来:1440px 上 h1 落在 284 / 326 / 433 / 453 四个位置(因为\n+`.ui-page` 居中,每页 max-width 又不一样),顶栏在 276,侧栏里三条\n+(16 / 24 / 51)。侧栏和顶栏都不动,正文自己漂 —— 人看到的就是「每页都错开」。\n+\n+现在只有三个数,在 `styles/tokens.css`:\n+\n+```\n+--gutte","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/CLAUDE.md"},{"filename":"docs/ui-v2-polish.md","previous_filename":null,"status":"added","additions":57,"deletions":0,"changes":57,"patch":"@@ -0,0 +1,57 @@\n+# UI v2 的对齐与组件语言收敛\n+\n+「像把新皮肤套到旧页面结构上」的三条具体证据,以及修完之后的实测数字。\n+\n+## 一、之前有八条左边界\n+\n+1440px 上量到的(元素左边界 + 自身 paddingLeft):\n+\n+| 位置 | 之前 | 之后 |\n+| --- | --- | --- |\n+| 顶栏「我在哪」 | 276 | **284** |\n+| 首页 h1 | 453 | **284** |\n+| 计划页 h1 | 433 | **284** |\n+| `/drill` h1 | 326 | **284** |\n+| 课程页 h1 | 284 | **284** |\n+| 侧栏品牌 | 51 | **28**(图标起点) |\n+| 侧栏导航行 | 16 | **28** |\n+| 侧栏上下文段 | 24 / 36 | **28** |\n+\n+根子是两件事叠在一起:`","blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish.md"},{"filename":"docs/ui-v2-polish/active-home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Factive-home-after.jpg"},{"filename":"docs/ui-v2-polish/active-home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Factive-home-before.jpg"},{"filename":"docs/ui-v2-polish/code-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Fcode-after.jpg"},{"filename":"docs/ui-v2-polish/code-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Fcode-before.jpg"},{"filename":"docs/ui-v2-polish/fresh-home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Ffresh-home-after.jpg"},{"filename":"docs/ui-v2-polish/fresh-home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Ffresh-home-before.jpg"},{"filename":"docs/ui-v2-polish/lesson-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Flesson-after.jpg"},{"filename":"docs/ui-v2-polish/lesson-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Flesson-before.jpg"},{"filename":"docs/ui-v2-polish/m-home-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Fm-home-after.jpg"},{"filename":"docs/ui-v2-polish/m-home-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Fm-home-before.jpg"},{"filename":"docs/ui-v2-polish/plan-react-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Fplan-react-after.jpg"},{"filename":"docs/ui-v2-polish/plan-react-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Fplan-react-before.jpg"},{"filename":"docs/ui-v2-polish/practice-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Fpractice-after.jpg"},{"filename":"docs/ui-v2-polish/practice-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Fpractice-before.jpg"},{"filename":"docs/ui-v2-polish/review-after.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Freview-after.jpg"},{"filename":"docs/ui-v2-polish/review-before.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76/docs%2Fui-v2-polish%2Freview-before.jpg"}]},"ecb282d08522608886c21d89d71622edb1de0012":{"sha":"ecb282d08522608886c21d89d71622edb1de0012","stats":{"additions":46,"deletions":201,"total":247},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":23,"deletions":0,"changes":23,"patch":"@@ -271,6 +271,29 @@ Sandpack 在浏览器 iframe 里没有 Node,那套确实跑不了)。\n \n 加任何新零件之前先问:它是一行,还是那颗按钮?两个都不是的话,多半不该加。\n \n+### 【重要】语言和主题不许收进任何菜单\n+\n+这个站每一段文字都有中英两版,**换语言是随时会按的开关**,不是设置项。\n+曾经为了在 390px 上腾地方,把语言和主题收进了顶栏那个 ? 菜单 ——\n+结果是手机上根本找不到怎么换语言(用户报的)。\n+\n+390px 的宽度预算是这么算平的(视口 390,左右各 16,剩 358):\n+\n+```\n+菜单 44 + 品牌标记 44 + 〔继续〕~90 + 搜索 44 + 语言 44 + 主题 44 = 310\n+五个 8px 的间隙 = 40 共 350\n+```\n+\n+腾地方靠两件事,","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ecb282d08522608886c21d89d71622edb1de0012/CLAUDE.md"},{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":10,"deletions":149,"changes":159,"patch":"@@ -49,127 +49,6 @@ import { Mark, SideNav } from \"./side-nav\";\n import { ContextSidebar } from \"./sidebars\";\n import { T } from \"./t\";\n \n-/**\n- * 右边那个 ? 菜单 —— 「使用说明」和「速查表」的家。\n- *\n- * 速查表在侧栏里也有一项。这里留着它是因为「卡住了想查一下」发生在读课文的\n- * 中途,那时手在顶栏(搜索旁边),不在侧栏底部。\n- *\n- * 【键盘和焦点】\n- * 点按钮开合;开着时 Esc 关闭并把焦点还给按钮;焦点离开这一块也关闭。\n- * 三个行为都不依赖 hover —— 触屏上 hover 菜单等于没有。\n- */\n-function HelpMenu({ tools }: { tools?: ReactNode }) {\n- const path ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ecb282d08522608886c21d89d71622edb1de0012/components%2Fapp-shell.tsx"},{"filename":"lib/side-nav.ts","previous_filename":null,"status":"modified","additions":4,"deletions":5,"changes":9,"patch":"@@ -66,7 +66,10 @@ export const SIDE_NAV: SideGroup[] = [\n ],\n },\n {\n- items: [{ href: \"/reference\", zh: \"速查\", en: \"Reference\", owns: [\"/reference\"] }],\n+ items: [\n+ { href: \"/guide\", zh: \"使用说明\", en: \"How to use\", owns: [\"/guide\"] },\n+ { href: \"/reference\", zh: \"速查\", en: \"Reference\", owns: [\"/reference\"] },\n+ ],\n },\n ];\n \n@@ -103,10 +106,6 @@ export function activeSideHref(path: string): ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ecb282d08522608886c21d89d71622edb1de0012/lib%2Fside-nav.ts"},{"filename":"styles/nav.css","previous_filename":null,"status":"modified","additions":0,"deletions":47,"changes":47,"patch":"@@ -317,49 +317,11 @@\n \n /* ---------- ? 菜单 ---------- */\n \n-.helpmenu {\n- position: relative;\n- display: inline-flex;\n-}\n \n-.helpmenu-panel {\n- position: absolute;\n- top: calc(100% + 8px);\n- right: 0;\n- z-index: 70;\n- width: 272px;\n- padding: 6px;\n- background: var(--surface);\n- border: 1px solid var(--rule-strong);\n- border-radius: var(--r-lg);\n- box-shadow: var(--lift-2);\n-}\n \n-.helpmenu-item {\n- disp","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ecb282d08522608886c21d89d71622edb1de0012/styles%2Fnav.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -329,6 +329,15 @@\n min-width: 0;\n }\n \n+ /* 【390px 的宽度预算】菜单 44 + 标记 44 + 〔继续〕~90 + 搜索 44 +\n+ 语言 44 + 主题 44 = 310,加五个 8px 的间隙 = 350,视口去掉左右各 16\n+ 还剩 358 —— 刚好放得下。所以品牌只留标记,字样收起来。\n+ 语言和主题不许再往菜单里收:这个站每一段文字都有中英两版,\n+ 换语言是随时会按的东西。 */\n+ .topbar-brand-name {\n+ display: none;\n+ }\n+\n .sidebar {\n position: fixed;\n top: var(--topbar-h);","blob_url":"https://github.com/renrenmimi/DrillLab/blob/ecb282d08522608886c21d89d71622edb1de0012/styles%2Fshell.css"}]},"e20b57d9e5b6dd566b530483e68444bb48cfcb92":{"sha":"e20b57d9e5b6dd566b530483e68444bb48cfcb92","stats":{"additions":8,"deletions":8,"total":16},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -368,11 +368,11 @@ styles/layout.css 布局原语(全部带 ui- 前缀)\n \n ```\n 侧栏 去哪儿(导航) → lib/side-nav.ts,位置在每一页上都一样\n-顶栏 我在哪(区段名)+ 四个工具 → 搜索 / 语言 / 主题 / 帮助\n+顶栏 我在哪(区段名)+ 三个工具 → 搜索 / 语言 / 主题\n ```\n \n 侧栏从上到下:品牌 → 今天 / 我的计划 → **计划状态 + 那颗唯一的〔继续〕** →\n-资料库(学课程 / 背知识点 / 做练习)→ 检验(考场 / 模拟考)→ 速查 →\n+资料库(学课程 / 背知识点 / 做练习)→ 检验(考场 / 模拟考)→ 使用说明 / 速查 →\n 分隔线 → 当前这一类事情自己的结构(`components/sidebars.tsx`)。\n \n 「继续」在计划状态**下面**、资料库**","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/CLAUDE.md"},{"filename":"README.md","previous_filename":null,"status":"modified","additions":3,"deletions":3,"changes":6,"patch":"@@ -51,16 +51,16 @@ still ticked in the courses.\n ## One navigation, one next action\n \n Everything you can go to lives in the left sidebar, and its positions are identical on\n-every page. The top bar carries only where you are plus four tools: search, language,\n-theme and help.\n+every page. The top bar carries only where you are plus three tools: search, language\n+and theme.\n \n ```\n Today · My plan\n your plan's ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/README.md"},{"filename":"docs/ui-v2-polish/active-home-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2-polish%2Factive-home-after.jpg"},{"filename":"docs/ui-v2-polish/code-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2-polish%2Fcode-after.jpg"},{"filename":"docs/ui-v2-polish/fresh-home-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2-polish%2Ffresh-home-after.jpg"},{"filename":"docs/ui-v2-polish/lesson-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2-polish%2Flesson-after.jpg"},{"filename":"docs/ui-v2-polish/m-home-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2-polish%2Fm-home-after.jpg"},{"filename":"docs/ui-v2-polish/plan-react-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2-polish%2Fplan-react-after.jpg"},{"filename":"docs/ui-v2-polish/practice-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2-polish%2Fpractice-after.jpg"},{"filename":"docs/ui-v2-polish/review-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2-polish%2Freview-after.jpg"},{"filename":"docs/ui-v2.md","previous_filename":null,"status":"modified","additions":3,"deletions":3,"changes":6,"patch":"@@ -19,7 +19,7 @@\n \n ```\n ┌────────────────────────┬──────────────────────────────────────────┐\n-│ DrillLab │ 面包屑(我在哪) 搜索 语言 主题 帮助 │\n+│ DrillLab │ 面包屑(我在哪) 搜索 语言 主题 │\n ├────────────────────────┼──────────────────────────────────────────┤\n │ ● 今天 │ │\n │ ● 我的计划 │ 内容 │\n@@ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e20b57d9e5b6dd566b530483e68444bb48cfcb92/docs%2Fui-v2.md"}]},"902fde753bd7110d54d0be4dc48c140e1bf5db9e":{"sha":"902fde753bd7110d54d0be4dc48c140e1bf5db9e","stats":{"additions":96,"deletions":23,"total":119},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":22,"deletions":0,"changes":22,"patch":"@@ -222,6 +222,28 @@ Sandpack 在浏览器 iframe 里没有 Node,那套确实跑不了)。\n 它的 WebContainers 把 Node 编译进了浏览器,所以连 Federation 那套要\n `npm test` 的服务端项目也能跑,这一点比 Sandpack 强。\n \n+### 【重要】零件契约:四个变体、八档字号、两档高度、四种圆角\n+\n+审计过一次,数字在这儿:**32 条「有内边距 + 有圆角 + 能点」的规则散在九个\n+文件里,其中只有一条是 `.btn`**;全站写死了 **28 种字号**;圆角有两套药丸写法\n+(`100px` 和 `999px`)。没有哪一条是错的,加在一起就是「每一块都自己长一套」。\n+\n+所以现在是一份契约,写新零件之前先对一遍:\n+\n+```\n+按钮 .btn / .btn-primary / .btn-ghost / .btn-sm —— ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/902fde753bd7110d54d0be4dc48c140e1bf5db9e/CLAUDE.md"},{"filename":"styles/base.css","previous_filename":null,"status":"modified","additions":52,"deletions":19,"changes":71,"patch":"@@ -158,65 +158,98 @@ hr {\n }\n \n /* —— 按钮 —— */\n+/* ============================================================\n+ 按钮 —— **全站只有这一套**\n+ ------------------------------------------------------------\n+ 审计过一次:32 条「有内边距 + 有圆角 + 能点」的规则散在九个文件里,\n+ 其中只有一条是 .btn。于是同一屏上会出现三种高度、四种圆角、\n+ 两种描边色的按钮,而它们表达的是同一件事。\n+\n+ 四个变体,语义是「这一屏上它有多重」:\n+\n+ .btn 描边。默认档,绝大多数动作都是它\n+ .btn.btn-primary 实心强调色。**一屏只能有一个**","blob_url":"https://github.com/renrenmimi/DrillLab/blob/902fde753bd7110d54d0be4dc48c140e1bf5db9e/styles%2Fbase.css"},{"filename":"styles/tokens.css","previous_filename":null,"status":"modified","additions":22,"deletions":4,"changes":26,"patch":"@@ -137,10 +137,12 @@\n --rail-pad: 16px;\n --rail-inset: 12px;\n \n- /* 圆角:卡片 12–14,控件 8–10,小徽章 6 */\n+ /* 圆角。**只有四个**,别再写第五个数。\n+ 老版本药丸有两种写法(100px 和 999px),所以这里也给一个名字。 */\n --r-sm: 6px;\n --r: 10px;\n --r-lg: 14px;\n+ --r-pill: 999px;\n \n /* —— 间距:8 的倍数,一处都不许手写别的值 —— */\n --sp-1: 8px;\n@@ -150,18 +152,34 @@\n --sp-6: 48px;\n --sp-8: 64px;\n \n- /* —— 字号 —— */\n+ /* —— 字号:**八档,就这八档** ——\n+ 审计过一次:全站写死了 28","blob_url":"https://github.com/renrenmimi/DrillLab/blob/902fde753bd7110d54d0be4dc48c140e1bf5db9e/styles%2Ftokens.css"}]},"860d2b8613ae95c49f7b8daee6db0838cf3c1203":{"sha":"860d2b8613ae95c49f7b8daee6db0838cf3c1203","stats":{"additions":470,"deletions":299,"total":769},"files":[{"filename":"components/data-flow.tsx","previous_filename":null,"status":"modified","additions":10,"deletions":5,"changes":15,"patch":"@@ -71,15 +71,15 @@ export function DataFlowDiagram({\n <div className=\"viz\">\n <div className=\"viz-bar\">\n <span>{title}</span>\n- <span className=\"dimmer\" style={{ marginLeft: \"auto\", fontWeight: 500 }}>\n+ <span className=\"dimmer viz-count\">\n <T en={`Step ${step + 1} of ${frames.length}`} zh={`第 ${step + 1} / ${frames.length} 步`} />\n </span>\n </div>\n \n <div ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/860d2b8613ae95c49f7b8daee6db0838cf3c1203/components%2Fdata-flow.tsx"},{"filename":"components/exercise.tsx","previous_filename":null,"status":"modified","additions":31,"deletions":27,"changes":58,"patch":"@@ -125,7 +125,7 @@ function Recognition({ ex, examId }: { ex: RecognitionExercise; examId: string }\n {ex.code && <CodeBlock ex={ex.code} />}\n \n {multi && (\n- <p className=\"dim\" style={{ fontSize: 14 }}>\n+ <p className=\"ex-note\">\n <T en=\"More than one answer is correct.\" zh=\"这题是多选。\" />\n </p>\n )}\n@@ -143,7 +143,7 @@ function Recognition({ ex, examId }: { ex: Recognitio","blob_url":"https://github.com/renrenmimi/DrillLab/blob/860d2b8613ae95c49f7b8daee6db0838cf3c1203/components%2Fexercise.tsx"},{"filename":"components/hint-panel.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":3,"changes":8,"patch":"@@ -47,11 +47,14 @@ export function HintPanel({\n zh={`共 ${hints.length} 级,已看 ${shown} 级`}\n />\n </span>\n+ {/* 【两颗按钮怎么贴到右端】交给 CSS 的\n+ `.hints-head > .btn:first-of-type { margin-left: auto }`。\n+ 老代码是在这里按「有没有第二颗按钮」写两种 marginLeft,\n+ 分支一多就会漏掉一种组合。 */}\n {left > 0 && (\n <button\n className=\"btn btn-sm\"\n onClick={() ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/860d2b8613ae95c49f7b8daee6db0838cf3c1203/components%2Fhint-panel.tsx"},{"filename":"styles/exercise.css","previous_filename":null,"status":"modified","additions":352,"deletions":222,"changes":574,"patch":"@@ -3,11 +3,27 @@\n ------------------------------------------------------------\n 练习是唯一「配得上一张卡片」的东西 —— 它是独立、可交互、\n 有状态的。所以这里用了框,别处不用。\n+\n+ 【这一轮只做一件事:把这些零件接回全站契约,不动任何行为】\n+ ① 能点的东西一律走 base.css 的 .btn 家族;实在不能直接套 .btn 的\n+ (选项要左对齐、要换行),也必须用同一组 token 对齐几何:\n+ --h-ctl / --h-ctl-sm / --r / --r-sm / --r-pill;\n+ ② 字号只从八档里取,不再出现 15.5 / 13.5 / 10.5 这种半档;\n+ ③ 间距只用 --sp-1…--sp-8;8px 以下的视觉微调(2 / 4 / 6px)才写字面量","blob_url":"https://github.com/renrenmimi/DrillLab/blob/860d2b8613ae95c49f7b8daee6db0838cf3c1203/styles%2Fexercise.css"},{"filename":"styles/viz.css","previous_filename":null,"status":"modified","additions":72,"deletions":42,"changes":114,"patch":"@@ -1,13 +1,18 @@\n /* ============================================================\n 数据流图 / 请求流图\n ------------------------------------------------------------\n- 节点 + SVG 连线 + 逐帧高亮。不用插画,不用漂浮圆球。\n+ 节点 + 逐帧高亮。不用插画,不用漂浮圆球。\n+\n+ 这一轮和练习零件一起接回了全站契约:字号只取八档、间距只取 --sp-*、\n+ 圆角只取四种、卡片一律 --raised + --rule + --r-lg。\n+ 强调色只留给「这一帧正在发生的那个节点」和「当前是第几步」——\n+ 那是当前位置,不是装饰。\n ================================================","blob_url":"https://github.com/renrenmimi/DrillLab/blob/860d2b8613ae95c49f7b8daee6db0838cf3c1203/styles%2Fviz.css"}]},"b8a23768b3534a85e8198e606bffd94d8bdb5414":{"sha":"b8a23768b3534a85e8198e606bffd94d8bdb5414","stats":{"additions":416,"deletions":291,"total":707},"files":[{"filename":"components/lesson-body.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":8,"changes":14,"patch":"@@ -361,7 +361,10 @@ export function LessonBody({ examId, lessonId }: { examId: string; lessonId: str\n <T en=\"Where you are · real source\" zh=\"当前位置 · 真实来源\" />\n </summary>\n \n- <div style={{ color: \"var(--ink-2)\", marginTop: 8 }}>\n+ {/* 这两块原来是三处内联样式(字号 11.5、上边距 12、下边距 4)。\n+ 内联写死的数字不在任何一档里,也扫不到 —— 搬进 styles/lesson.css,\n+ 字号走 --fs-* 那八档,间距走 --sp-*。 */}\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b8a23768b3534a85e8198e606bffd94d8bdb5414/components%2Flesson-body.tsx"},{"filename":"components/lesson-islands.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":1,"changes":6,"patch":"@@ -96,9 +96,13 @@ export function LessonDoneBar({\n />\n )}\n </span>\n+ {/* 【为什么不是实心按钮】一屏只有一个实心强调动作,课程页那一个是\n+ 课尾面板里的「下一节」。打勾是记账,不是这一页的下一步 ——\n+ 它和「下一节」同为实心时,读完一节课眼前会有两个都说得通的动作。\n+ 做没做过的区别由 .done-bar[data-done] 那一圈边框和底色表达。 */}\n <button\n type=\"button\"\n- className={done ? \"btn btn-sm\" : \"btn btn-sm btn-primary\"}\n+ className=\"btn btn-sm\"\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b8a23768b3534a85e8198e606bffd94d8bdb5414/components%2Flesson-islands.tsx"},{"filename":"components/lesson-kit.tsx","previous_filename":null,"status":"modified","additions":9,"deletions":6,"changes":15,"patch":"@@ -57,7 +57,7 @@ export function LessonHeader({\n <header className=\"lesson-head\">\n <nav aria-label=\"面包屑 / Breadcrumb\" className=\"crumb\">\n {crumbs.map((c, i) => (\n- <span key={i} style={{ display: \"inline-flex\", gap: 7 }}>\n+ <span key={i} className=\"crumb-item\">\n {i > 0 && (\n <span className=\"crumb-sep\" aria-hidden>\n /\n@@ -369,10 +369,7 @@ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b8a23768b3534a85e8198e606bffd94d8bdb5414/components%2Flesson-kit.tsx"},{"filename":"styles/code.css","previous_filename":null,"status":"modified","additions":143,"deletions":90,"changes":233,"patch":"@@ -3,12 +3,48 @@\n ------------------------------------------------------------\n 代码是本站的视觉主角:窗口宽、字号够大、语法色低饱和、\n 永远显示文件名与语言、真实来源单独标注。\n+\n+ 【这一块为什么自带一套配色】\n+ 代码区在**两个主题下都是深底**(--code-bg / --code-bar 在浅色主题里\n+ 同样是深色)。所以窗框上的文字不能用 --ink / --ink-2 / --ink-3 ——\n+ 那三档在浅色主题下是深字,压在深底上读不出来。\n+ 下面的 --code-chrome* 就是「窗框专用的那三档」,值和以前逐处写死的\n+ 十六进制一模一样,只是收成了名字:一处改,三种窗一起跟着走。\n+\n+ 【窗框以外一律走全站契约】按钮几何 = --h-ctl-sm / --r-sm ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b8a23768b3534a85e8198e606bffd94d8bdb5414/styles%2Fcode.css"},{"filename":"styles/lesson.css","previous_filename":null,"status":"modified","additions":253,"deletions":186,"changes":439,"patch":"@@ -8,8 +8,8 @@\n /* ---------- 课头 ---------- */\n \n .lesson-head {\n- padding-bottom: 22px;\n- margin-bottom: 30px;\n+ padding-bottom: var(--sp-3);\n+ margin-bottom: var(--sp-4);\n border-bottom: 1px solid var(--rule);\n max-width: var(--measure);\n }\n@@ -18,16 +18,23 @@\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n- gap: 7px;\n- font-size: 12.5px;\n+ gap: var(--sp-1);\n+ font-size: var(--fs-meta);\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/b8a23768b3534a85e8198e606bffd94d8bdb5414/styles%2Flesson.css"}]},"795407906d94f5c0cf9015265ea796f0c8244ccb":{"sha":"795407906d94f5c0cf9015265ea796f0c8244ccb","stats":{"additions":484,"deletions":284,"total":768},"files":[{"filename":"components/arena-brief.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -38,7 +38,7 @@ export function ArenaBrief({ id }: { id: string }) {\n sub={`考场 · 限时 ${a.minutes} 分钟`}\n subEn={`Arena · ${a.minutes} min limit`}\n />\n- <div className=\"content\">\n+ <div className=\"content ui-page\">\n <nav className=\"crumb\" aria-label=\"面包屑 / Breadcrumb\">\n <Link href=\"/arena\">\n <T zh=\"考场\" en=\"Arena\" />\n@@ -65,7 +65,7 @@ export function ArenaBr","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Farena-brief.tsx"},{"filename":"components/arena-clock.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":1,"changes":5,"patch":"@@ -124,9 +124,12 @@ export function ArenaClock({ id, minutes }: { id: string; minutes: number }) {\n </span>\n \n <span className=\"arena-clock-actions\">\n+ {/* 【为什么这颗按钮的实心会让出去】\n+ 一屏只能有一个实心动作。确认框一打开,真正要按的就是框里那颗\n+ 「确定,交卷」—— 这颗降成描边,读起来也正好是「已经按过了」。 */}\n <button\n type=\"button\"\n- className=\"btn btn-sm btn-primary\"\n+ className={ask === \"submit\" ? \"btn","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Farena-clock.tsx"},{"filename":"components/arena-review.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -28,7 +28,7 @@ export function ArenaReview({ id }: { id: string }) {\n \n return (\n <main className=\"main\" data-rail=\"off\">\n- <div className=\"content\">\n+ <div className=\"content ui-page\">\n <nav className=\"crumb\" aria-label=\"面包屑 / Breadcrumb\">\n <Link href=\"/arena\">\n <T zh=\"考场\" en=\"Arena\" />\n@@ -81,7 +81,7 @@ export function ArenaReview({ id }: { id: string }) {\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Farena-review.tsx"},{"filename":"components/arena-run.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -76,7 +76,7 @@ export function ArenaRun({ id }: { id: string }) {\n sub=\"考场 · 正在计时\"\n subEn=\"Arena · on the clock\"\n />\n- <div className=\"content\">\n+ <div className=\"content ui-page\">\n <ArenaClock id={a.id} minutes={a.minutes} />\n \n {/* compact:计时已经在跑,只说「你在计划的哪一步」,不给别处的按钮 ——\n@@ -91,7 +91,7 @@ export function ArenaRun({ id }: { id: string }) {\n <T zh={a.title","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Farena-run.tsx"},{"filename":"components/arena-start.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":1,"changes":4,"patch":"@@ -110,7 +110,9 @@ export function ArenaStartPanel({\n en=\" — one at a time. Hand that one in or give it up before starting this one.\"\n />\n </div>\n- <Link className=\"btn btn-primary\" href={`${arenaPath(other.id)}/run`}>\n+ {/* 这一档是描边,不是实心 —— 这一页的主动作(开始计时)现在是\n+ 做不了的,一屏里就不该有一颗实心按钮把别的事情说成「下一步」。 */}\n+ <Link className=\"btn\" href={`${a","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Farena-start.tsx"},{"filename":"components/coding-detail.tsx","previous_filename":null,"status":"modified","additions":8,"deletions":8,"changes":16,"patch":"@@ -110,7 +110,7 @@ export function CodingDetail({ id }: { id: string }) {\n sub={`Coding 题 · ${CODING_TRACK_LABEL[problem.track]}`}\n subEn={`Coding · ${CODING_TRACK_LABEL[problem.track]}`}\n />\n- <div className=\"content\">\n+ <div className=\"content ui-page\">\n <nav aria-label=\"面包屑 / Breadcrumb\" className=\"crumb\">\n <Link href=\"/code\">\n <T zh=\"Coding 题\" en=\"Cod","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Fcoding-detail.tsx"},{"filename":"components/coding-workspace.tsx","previous_filename":null,"status":"modified","additions":10,"deletions":1,"changes":11,"patch":"@@ -52,7 +52,16 @@ export function CodingWorkspace({\n <LocalRunCard commands={commands ?? []} why={localWhy} />\n )}\n \n- <CodingDoneToggle id={id} title={title} titleEn={titleEn} />\n+ {/* 【为什么有沙箱时要套一层 .cd-done】\n+ 一屏只能有一个实心动作。有沙箱的题,那颗实心的是「打开工作区」\n+ (打开之后换成「跑测试」),打勾只是记账 —— styles/coding.css\n+ 里那条 .cd-done 把它降成描边。\n+ 没有沙箱的四道题这一页一颗按钮都没有,打勾就是这一页的动作,\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Fcoding-workspace.tsx"},{"filename":"components/exam-overview.tsx","previous_filename":null,"status":"modified","additions":51,"deletions":49,"changes":100,"patch":"@@ -18,7 +18,7 @@ export function ExamOverview({ examId }: { examId: string }) {\n if (!exam) {\n return (\n <main className=\"main\" data-rail=\"off\">\n- <div className=\"content\">\n+ <div className=\"content ui-page\">\n <p className=\"empty\">\n <T en=\"No such course.\" zh=\"没有这门考试。\" />\n </p>\n@@ -40,7 +40,7 @@ export function ExamOverview({ examId }: { examId: string }) {\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Fexam-overview.tsx"},{"filename":"components/guide.tsx","previous_filename":null,"status":"modified","additions":18,"deletions":12,"changes":30,"patch":"@@ -23,19 +23,25 @@ const sandboxCount = CODING.filter((c) => c.hasSandbox).length;\n export function Guide() {\n return (\n <main className=\"main\" data-rail=\"off\">\n- <div className=\"content\">\n- <div className=\"eyebrow\">\n- <T zh=\"使用说明\" en=\"How to use this\" />\n+ <div className=\"content ui-page\">\n+ {/* 页头用共享的那一份(styles/layout.css)——\n+ 眉题 / 标题 / 一句话的间距和分隔线由它给,\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Fguide.tsx"},{"filename":"components/mock-detail.tsx","previous_filename":null,"status":"modified","additions":10,"deletions":10,"changes":20,"patch":"@@ -40,7 +40,7 @@ export function MockDetail({ examId, mockId }: { examId: string; mockId: string\n sub=\"模拟考 · 自评\"\n subEn=\"Mock exam · self-scoring\"\n />\n- <div className=\"content\">\n+ <div className=\"content ui-page\">\n <nav className=\"crumb\">\n <Link href=\"/mock\">\n <T en=\"Mock exams\" zh=\"模拟考\" />\n@@ -58,7 +58,7 @@ export function MockDetail({ examId, mockId }:","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Fmock-detail.tsx"},{"filename":"components/mock-score.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":12,"changes":14,"patch":"@@ -24,7 +24,7 @@ export function MockScore({\n const rec = ready ? mockRecord(examId, mockId) : undefined;\n \n return (\n- <div className=\"done-bar\" data-done={!!rec} style={{ marginTop: 30 }}>\n+ <div className=\"done-bar\" data-done={!!rec} style={{ marginTop: \"var(--sp-4)\" }}>\n <span className=\"done-bar-text\">\n {rec ? (\n rec.score !== undefined ? (\n@@ -43,17 +43,7 @@ export function Mo","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Fmock-score.tsx"},{"filename":"components/mock-workspace.tsx","previous_filename":null,"status":"modified","additions":62,"deletions":60,"changes":122,"patch":"@@ -16,15 +16,15 @@ export function MockList() {\n \n return (\n <main className=\"main\" data-rail=\"off\">\n- <div className=\"content\">\n- <div className=\"page-head\">\n- <div className=\"eyebrow\">\n+ <div className=\"content ui-page\">\n+ <div className=\"ui-head\">\n+ <div className=\"ui-eyebrow\">\n <T en=\"Mock exams\" zh=\"模拟考\" />\n </div>\n- <h1 className=\"p","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Fmock-workspace.tsx"},{"filename":"components/reference.tsx","previous_filename":null,"status":"modified","additions":6,"deletions":6,"changes":12,"patch":"@@ -40,15 +40,15 @@ export function Reference() {\n \n return (\n <main className=\"main\">\n- <div className=\"content\">\n- <div className=\"page-head\">\n- <div className=\"eyebrow\">\n+ <div className=\"content ui-page\">\n+ <div className=\"ui-head\">\n+ <div className=\"ui-eyebrow\">\n <T en=\"Reference\" zh=\"速查\" />\n </div>\n- <h1 className=\"page-title\">\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Freference.tsx"},{"filename":"components/sandbox.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":3,"changes":6,"patch":"@@ -118,15 +118,15 @@ export function SandboxPanel({\n <div className=\"sbx-modes\" role=\"group\" aria-label=\"沙箱档位 / Sandbox mode\">\n <button\n type=\"button\"\n- className=\"filter-btn\"\n+ className=\"sbx-mode\"\n data-on={!blank}\n onClick={() => onBlank(false)}\n >\n <T zh=\"带脚手架\" en=\"With scaffold\" />\n </button>\n <button\n type","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/components%2Fsandbox.tsx"},{"filename":"styles/arena.css","previous_filename":null,"status":"modified","additions":146,"deletions":72,"changes":218,"patch":"@@ -1,10 +1,21 @@\n /* ============================================================\n- 考场 —— 阶段 C\n+ 考场与模拟考(Assess 模式)\n ------------------------------------------------------------\n- 只有这一份是考场自己的样式。列表长条、标签、提示框、答案门、\n- 文件树、终端全部复用现成的类(.exam-row / .tag / .callout /\n- .minihead / .btn / .filetree / .term)。\n- 颜色一律走 tokens.css 的变量,深色模式跟着一起翻。\n+ 列表长条、标签、提示框、答案门、文件树、终端全部复用现成的类\n+ (.exam-row / .tag / .callout / .m","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/styles%2Farena.css"},{"filename":"styles/coding.css","previous_filename":null,"status":"modified","additions":155,"deletions":43,"changes":198,"patch":"@@ -10,6 +10,16 @@\n 现在列表页的骨架来自 styles/layout.css 的共享原语\n (.ui-cards + .ui-card[data-rows]),这个文件只剩「卡里怎么排」\n 和详情页自己的那几块。\n+\n+ 【UI v2:值全部来自 tokens】\n+ 字号只从八档里取,间距只用 --sp-1…--sp-8,圆角只用\n+ --r-sm / --r / --r-lg / --r-pill,控件高度只有 --h-ctl / --h-ctl-sm。\n+ 小于 8px 的视觉微调是唯一的例外。\n+\n+ 【这一页只有一颗实心按钮:「打开工作区」】\n+ 它是这一页真正的下一步。工作区打开之后那颗实心按钮换成「跑测试」\n+ (门已经消失,同屏仍然只有一颗)。「标记为完成」是记账不是下一步,\n+ 所以在这一页降成描边 —— 见文件末尾 .cd-done 那条。\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/795407906d94f5c0cf9015265ea796f0c8244ccb/styles%2Fcoding.css"}]},"2b654cebf08610eab0f938a49570d23e4d23f321":{"sha":"2b654cebf08610eab0f938a49570d23e4d23f321","stats":{"additions":393,"deletions":322,"total":715},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":23,"deletions":0,"changes":23,"patch":"@@ -244,6 +244,29 @@ Sandpack 在浏览器 iframe 里没有 Node,那套确实跑不了)。\n **不要在页面样式里写 `padding: 6px 13px` 这种数**,也不要为了「就差一点」\n 新开一档字号。真的需要第九档时,先想清楚它和现有八档里哪一档是同一类东西。\n \n+【填充强调色的动作,一个「区域」只许有一个】\n+\n+「一屏一个」这句话不够用 —— 侧栏那颗〔继续〕永远在,那页面里就一个都不能有了。\n+实际的规矩是:\n+\n+```\n+外壳 一个(侧栏那颗〔继续〕;窄屏是顶栏那颗)\n+页面 一个(这一页要你做的那件事)\n+```\n+\n+其余一律描边或 ghost。实测过一次反例:课程页正文里同时有五个填充块\n+(Autoplay / Check the order / Check my code / Next item / Mark as finished),\n+`/plans`","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/CLAUDE.md"},{"filename":"components/drill-detail.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -94,7 +94,7 @@ export function DrillDetail({ id }: { id: string }) {\n <PlanNextStepSlot itemKey={itemKey(\"drill\", q.id)} />\n \n <div className=\"drill-detail-foot\">\n- <span className=\"dim\" style={{ fontSize: 14 }}>\n+ <span className=\"dim\" style={{ fontSize: \"var(--fs-sm)\" }}>\n <T zh=\"这道题的出处:\" en=\"Comes from:\" />{\" \"}\n <Link href={`${lessonPath(q.examId, q.les","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/components%2Fdrill-detail.tsx"},{"filename":"components/drill-session.tsx","previous_filename":null,"status":"modified","additions":3,"deletions":3,"changes":6,"patch":"@@ -287,7 +287,7 @@ function SessionSetup({ emptyScope }: { emptyScope?: string }) {\n <input type=\"checkbox\" checked={random} onChange={(e) => setRandom(e.target.checked)} />\n <span>\n <T zh=\"随机顺序\" en=\"Shuffle\" />\n- <span className=\"dimmer\" style={{ fontSize: 12.5 }}>\n+ <span className=\"dimmer\" style={{ fontSize: \"var(--fs-meta)\" }}>\n {\" \"}\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/components%2Fdrill-session.tsx"},{"filename":"components/home-library.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -324,13 +324,13 @@ export function HomeLibrary() {\n </span>\n </li>\n </ul>\n- <p className=\"dimmer\" style={{ fontSize: 13.5 }}>\n+ <p className=\"dimmer\" style={{ fontSize: \"var(--fs-sm)\" }}>\n <T\n zh=\"三门真题课读的是本机上的 react-notes-app、graphql-federation-practice 与 cab-booking-context,只读不改。标了「源项目」或「已跑通」的代码都在本机真实跑过。\"\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/components%2Fhome-library.tsx"},{"filename":"components/lesson-plan.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -96,7 +96,7 @@ function DefaultStep({\n )}\n </span>\n </div>\n- <Link className=\"lnext-cta\" href={next ? next.href : (arenaHref ?? \"/arena\")}>\n+ <Link className=\"btn btn-primary lnext-cta\" href={next ? next.href : (arenaHref ?? \"/arena\")}>\n {next ? <T zh=\"下一节\" en=\"Next lesson\" /> : <T zh=\"去考场\" en=\"To the arena\" />}\n </Link>\n </li>","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/components%2Flesson-plan.tsx"},{"filename":"components/plan-detail.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -561,7 +561,7 @@ export function PlanDetail({ plan }: { plan: ResolvedPlan }) {\n </ol>\n \n {!ready && (\n- <p className=\"dimmer\" style={{ fontSize: 13 }}>\n+ <p className=\"dimmer\" style={{ fontSize: \"var(--fs-meta)\" }}>\n <T\n zh=\"正在读这台浏览器里的进度…\"\n en=\"Reading your progress from this browser…\"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/components%2Fplan-detail.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":2,"deletions":2,"changes":4,"patch":"@@ -461,7 +461,7 @@ export function LessonPlanStepLive({\n <T zh={status.plan.zh} en={status.plan.en} />\n </span>\n </div>\n- <Link className=\"lnext-cta\" href={`/plans/${status.plan.id}`}>\n+ <Link className=\"btn btn-primary lnext-cta\" href={`/plans/${status.plan.id}`}>\n <T zh=\"回看整条计划\" en=\"Review the plan\" />\n </Link>\n </li>\n@@ -496,7 +496,7 @@ export fu","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/components%2Fplan-kit.tsx"},{"filename":"styles/base.css","previous_filename":null,"status":"modified","additions":3,"deletions":3,"changes":6,"patch":"@@ -259,7 +259,7 @@ hr {\n border-radius: var(--r-sm);\n border: 1px solid var(--rule);\n background: var(--sunken);\n- font-size: 11.5px;\n+ font-size: var(--fs-meta);\n font-weight: 600;\n line-height: 1.6;\n color: var(--ink-2);\n@@ -300,13 +300,13 @@ hr {\n .table {\n width: 100%;\n border-collapse: collapse;\n- font-size: 15px;\n+ font-size: var(--fs-sm);\n margin-bottom: 1.05em;\n }\n \n .table th {\n text","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/styles%2Fbase.css"},{"filename":"styles/dash.css","previous_filename":null,"status":"modified","additions":19,"deletions":19,"changes":38,"patch":"@@ -49,7 +49,7 @@\n .pick-lede {\n max-width: 56ch;\n margin: var(--sp-2) 0 0;\n- font-size: 17px;\n+ font-size: var(--fs-body);\n line-height: 1.6;\n color: var(--ink-2);\n }\n@@ -97,7 +97,7 @@\n }\n \n .pick-opt-name {\n- font-size: 21px;\n+ font-size: var(--fs-h3);\n font-weight: 680;\n letter-spacing: -0.019em;\n color: var(--ink);\n@@ -106,11 +106,11 @@\n \n .pick-flag {\n padding: 2px 7px;\n- border-radius: 999p","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/styles%2Fdash.css"},{"filename":"styles/drill.css","previous_filename":null,"status":"modified","additions":88,"deletions":70,"changes":158,"patch":"@@ -20,7 +20,7 @@\n .minihead 是整段的分隔条,塞进 flex 行会画出半截横线。\n (现在只有抽认卡和单题页在用,列表页那条已经是 .ui-prog。) */\n .strip-label {\n- font-size: 12px;\n+ font-size: var(--fs-meta);\n font-weight: 650;\n letter-spacing: 0.07em;\n text-transform: uppercase;\n@@ -83,11 +83,11 @@\n max-width: 46vw;\n padding: 4px 10px;\n font: inherit;\n- font-size: 13.5px;\n+ font-size: var(--fs-sm);\n color: var(--ink);\n background: var(--sun","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/styles%2Fdrill.css"},{"filename":"styles/layout.css","previous_filename":null,"status":"modified","additions":4,"deletions":4,"changes":8,"patch":"@@ -93,7 +93,7 @@\n .page-lede {\n max-width: 62ch;\n margin: 0;\n- font-size: 17px;\n+ font-size: var(--fs-body);\n line-height: 1.62;\n color: var(--ink-2);\n }\n@@ -213,7 +213,7 @@\n flex: 1 1 auto;\n min-width: 60px;\n height: 6px;\n- border-radius: 999px;\n+ border-radius: var(--r-pill);\n background: var(--sunken);\n border: 1px solid var(--rule);\n overflow: hidden;\n@@ -223,7 +223,7 @@\n display: block","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/styles%2Flayout.css"},{"filename":"styles/nav.css","previous_filename":null,"status":"modified","additions":75,"deletions":81,"changes":156,"patch":"@@ -43,7 +43,7 @@\n }\n \n .snav-brand-name {\n- font-size: 16.5px;\n+ font-size: var(--fs-body);\n letter-spacing: -0.022em;\n }\n \n@@ -74,7 +74,7 @@\n .ctx-head-mode,\n .ctx-sec-title {\n font-family: var(--font-mono);\n- font-size: 11px;\n+ font-size: var(--fs-xs);\n font-weight: 600;\n letter-spacing: 0.11em;\n text-transform: uppercase;\n@@ -105,7 +105,7 @@\n min-height: 36px;\n padding: 0 var(--sp-1) 0 var(--rai","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/styles%2Fnav.css"},{"filename":"styles/plans.css","previous_filename":null,"status":"modified","additions":109,"deletions":91,"changes":200,"patch":"@@ -32,10 +32,10 @@\n flex: 0 0 auto;\n padding: 1px 7px;\n border: 1px solid var(--rule);\n- border-radius: 100px;\n+ border-radius: var(--r-pill);\n background: var(--sunken);\n color: var(--ink-2);\n- font-size: 10.5px;\n+ font-size: var(--fs-xs);\n font-weight: 650;\n letter-spacing: 0.03em;\n line-height: 1.6;\n@@ -63,7 +63,7 @@\n border-radius: 50%;\n background: var(--paper);\n color: transparent;\n- ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/styles%2Fplans.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":58,"deletions":43,"changes":101,"patch":"@@ -48,7 +48,7 @@\n }\n \n .topbar-brand-name {\n- font-size: 16px;\n+ font-size: var(--fs-body);\n letter-spacing: -0.02em;\n }\n \n@@ -226,7 +226,7 @@\n .rail {\n position: sticky;\n top: calc(var(--topbar-h) + 34px);\n- font-size: 13px;\n+ font-size: var(--fs-meta);\n line-height: 1.55;\n }\n \n@@ -237,7 +237,7 @@\n }\n \n .rail-head {\n- font-size: 10.5px;\n+ font-size: var(--fs-xs);\n font-weight: 650;\n letter-spacing","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/styles%2Fshell.css"},{"filename":"styles/tokens.css","previous_filename":null,"status":"modified","additions":4,"deletions":1,"changes":5,"patch":"@@ -53,7 +53,10 @@\n \n /* —— 强调色:柠檬绿。全站只有它是彩色主角 —— */\n --accent: #b8f23d; /* 填充:主 CTA 的底、进度条、当前位置的那条竖线 */\n- --accent-h: #d0ff6a; /* 悬停时的填充 */\n+ /* 悬停时的填充。**填充按钮的 hover 一律用它** ——\n+ 曾经有五处写的是 var(--accent-ink),而那一档在深色下就是亮柠檬绿本身,\n+ 等于鼠标移上去几乎没变化。 */\n+ --accent-h: #d0ff6a;\n /* --accent-ink 是**强调色当文字用**的那一档(老语义,73 处在用)。\n 深色下就是亮柠檬绿本身:对 --paper 15.7:1。 */\n --accent-ink: #c3f455;","blob_url":"https://github.com/renrenmimi/DrillLab/blob/2b654cebf08610eab0f938a49570d23e4d23f321/styles%2Ftokens.css"}]},"37ea25484d4153f521cad123677077200a390be0":{"sha":"37ea25484d4153f521cad123677077200a390be0","stats":{"additions":47,"deletions":10,"total":57},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":35,"deletions":9,"changes":44,"patch":"@@ -883,10 +883,11 @@ npm run build # 应预渲染 259 个页面(252 + /plans + 6 条计划\n # content/plans-assert.ts 由两个计划页面 import:\n # 引用了不存在的课文 / coding / 考场 / 模拟考,\n # 或者某一档解析出 0 条 —— 构建直接失败\n- # 首屏 JS 的基准(2026-08 实测):\n- # / 147 kB 课程页 142 kB\n- # /code/[id] 121","blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/CLAUDE.md"},{"filename":"docs/ui-v2-polish/active-home-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/docs%2Fui-v2-polish%2Factive-home-after.jpg"},{"filename":"docs/ui-v2-polish/code-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/docs%2Fui-v2-polish%2Fcode-after.jpg"},{"filename":"docs/ui-v2-polish/fresh-home-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/docs%2Fui-v2-polish%2Ffresh-home-after.jpg"},{"filename":"docs/ui-v2-polish/lesson-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/docs%2Fui-v2-polish%2Flesson-after.jpg"},{"filename":"docs/ui-v2-polish/m-home-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/docs%2Fui-v2-polish%2Fm-home-after.jpg"},{"filename":"docs/ui-v2-polish/plan-react-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/docs%2Fui-v2-polish%2Fplan-react-after.jpg"},{"filename":"docs/ui-v2-polish/practice-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/docs%2Fui-v2-polish%2Fpractice-after.jpg"},{"filename":"docs/ui-v2-polish/review-after.jpg","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/docs%2Fui-v2-polish%2Freview-after.jpg"},{"filename":"styles/code.css","previous_filename":null,"status":"modified","additions":5,"deletions":1,"changes":6,"patch":"@@ -439,9 +439,13 @@\n overflow-x: auto;\n }\n \n+/* 用内阴影而不是 outline:这块 textarea 是窗体的最后一格,\n+ 外描边会被 .editor 的圆角裁掉一半。\n+ 颜色必须是 --accent 而不是 --accent-line —— 后者是很暗的橄榄色,\n+ 压在代码窗的深底上等于没有焦点提示。 */\n .editor-area:focus {\n outline: 0;\n- box-shadow: inset 0 0 0 2px var(--accent-line);\n+ box-shadow: inset 0 0 0 2px var(--accent);\n }\n \n .editor-foot {","blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/styles%2Fcode.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":7,"deletions":0,"changes":7,"patch":"@@ -466,8 +466,15 @@\n border-bottom: 1px solid var(--rule);\n }\n \n+/* 【不能只写 outline: 0】原来这一条把焦点环删了却没给替代物 ——\n+ 搜索面板里能 Tab 到的东西不止这一个输入框,键盘用户会跟丢。\n+ 这里不用外描边(输入框贴着面板边缘,外环会被裁掉),\n+ 改成底边加粗成强调色。 */\n .search-input:focus {\n outline: 0;\n+ border-bottom: 2px solid var(--accent);\n+ /* 底边从 1px 变成 2px,内边距少 1px 补回来,聚焦时输入框不会跳 */\n+ padding-bottom: 14px;\n }\n \n .search-input::placeholder {","blob_url":"https://github.com/renrenmimi/DrillLab/blob/37ea25484d4153f521cad123677077200a390be0/styles%2Fshell.css"}]},"8255bec9ca99586712bd18869ece5610fc81d6ad":{"sha":"8255bec9ca99586712bd18869ece5610fc81d6ad","stats":{"additions":345,"deletions":37,"total":382},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":52,"deletions":0,"changes":52,"patch":"@@ -339,6 +339,58 @@ Sandpack 在浏览器 iframe 里没有 Node,那套确实跑不了)。\n 每个都要满足「语言按钮**不用先点开任何东西**就可见 → 点一下真的换 →\n 存进 localStorage → 刷新后还在 → 顶栏不溢出 → 窄屏控件 ≥44px」。\n \n+### 【重要】「重走一遍」:记一个起点,不删任何记录\n+\n+用户问过一句:「我选了一个 track 之后想换别的 / 想从头开始,能吗?」\n+拆开是三件事,第三件当时**根本不存在**:\n+\n+```\n+换成另一条计划 有(侧栏「换一条」、首页「换一条计划」、/plans)\n+清空全部进度 有(首页最底那个折叠里)\n+同一条计划从头重走 没有 ← 这一节说的就是它\n+```\n+\n+**为什么不能靠删记录实现。** 计划的完成度是从课文 / 练习 / 八股这些\n+**共享**记录推导的(见 l","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/CLAUDE.md"},{"filename":"components/home-library.tsx","previous_filename":null,"status":"modified","additions":5,"deletions":2,"changes":7,"patch":"@@ -275,9 +275,12 @@ export function HomeLibrary() {\n 但也不该藏到找不着,所以给它一个固定的家。 */}\n <details className=\"dash-more\">\n <summary>\n+ {/* 【标题里必须写出「清空进度」】上一版这个 summary 写的是\n+ 「……内容来源、进度怎么存的」—— 清空进度就在里面,\n+ 但标题一个字都没提,等于藏着。实测过:用户想从头开始,找不到。 */}\n <T\n- zh=\"其他:速查表、模拟考自评、内容来源、进度怎么存的\"\n- en=\"More: reference, mock scoring, sources","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/components%2Fhome-library.tsx"},{"filename":"components/plan-dash.tsx","previous_filename":null,"status":"modified","additions":34,"deletions":6,"changes":40,"patch":"@@ -22,12 +22,13 @@\n // 课文里不存在,因为课文只说自己讲什么。\n \n import Link from \"next/link\";\n+import { useState } from \"react\";\n import { useT } from \"@/lib/locale\";\n import { modeById } from \"@/lib/modes\";\n import { pct } from \"@/lib/plan-progress\";\n import { T } from \"./t\";\n import { PlanMark } from \"./plan-mark\";\n-import { StateDot, useActivePlan } from \"./plan-kit\";\n+import { RestartRound, StateDot, useActivePlan } from \"./plan-","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/components%2Fplan-dash.tsx"},{"filename":"components/plan-detail.tsx","previous_filename":null,"status":"modified","additions":49,"deletions":3,"changes":52,"patch":"@@ -29,7 +29,7 @@ import { useT } from \"@/lib/locale\";\n import { modeById } from \"@/lib/modes\";\n import { pct, planStatus, type ItemState, type PlanStatus } from \"@/lib/plan-progress\";\n import { useProgress } from \"@/lib/progress\";\n-import { PHASE, PhaseBadge, STATE_LABEL, StateDot } from \"./plan-kit\";\n+import { fmtRoundDay, PHASE, PhaseBadge, RestartRound, STATE_LABEL, StateDot } from \"./plan-kit\";\n import { PlanMar","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/components%2Fplan-detail.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":60,"deletions":4,"changes":64,"patch":"@@ -67,9 +67,13 @@ export function usePlanStatus(planId?: string): {\n status: LiteStatus | undefined;\n ready: boolean;\n } {\n- const { data, ready } = useProgress();\n+ const { data, ready, planRound } = useProgress();\n const plan = litePlanById(planId);\n- const status = useMemo(() => (plan ? planStatus(plan, data) : undefined), [plan, data]);\n+ const round = plan ? planRound(plan.id) : undefined;\n+ const st","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/components%2Fplan-kit.tsx"},{"filename":"lib/plan-progress.ts","previous_filename":null,"status":"modified","additions":37,"deletions":9,"changes":46,"patch":"@@ -103,6 +103,8 @@ export interface PlanStatus<P extends StatusPlan> {\n currentStageIndex: number;\n /** 下一步就是这一格。计划走完了就是 undefined */\n next?: { item: ItemOf<P>; stageIndex: number; itemIndex: number };\n+ /** 这一轮从什么时候开始算。没重走过就是 undefined */\n+ roundStart?: number;\n /**\n * 计划里最不熟的那道八股(不会 → 模糊 → 会)。\n *\n@@ -119,25 +121,46 @@ export interface PlanStatus<P extends StatusPlan> {\n \n const DRILL_RANK: Record<D","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/lib%2Fplan-progress.ts"},{"filename":"lib/progress.tsx","previous_filename":null,"status":"modified","additions":65,"deletions":9,"changes":74,"patch":"@@ -100,9 +100,18 @@ export interface RecentItem {\n }\n \n export interface ProgressData {\n- lessons: Record<string, 1>;\n- exercises: Record<string, 1>;\n- rebuilds: Record<string, 1>;\n+ /* 【为什么这四个 bag 的值是时间戳而不是 1】\n+ 「重走一遍某条计划」要能回答「这一条是不是**这一轮**做的」。\n+ 计划的完成度是从这些记录推导的(见 lib/plan-progress.ts),\n+ 所以判据只能落在记录本身上。\n+\n+ 老数据里这些值是字面量 `1`。不用迁移:`1` 小于任何真实时间戳,\n+ 于是「重走之后」它自动落在「这一轮之前」那一侧 —— 正是想要的。\n+ 读的地方一直都","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/lib%2Fprogress.tsx"},{"filename":"styles/dash.css","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -433,6 +433,15 @@\n margin: var(--sp-2) 0 0;\n }\n \n+/* .dash2-alts 里那个 button —— 和旁边两条链接长一样,清掉浏览器默认外观 */\n+.dash2-alt-btn {\n+ border: 0;\n+ background: transparent;\n+ font: inherit;\n+ padding: 0;\n+ cursor: pointer;\n+}\n+\n /* ---------- 当前档的紧凑预览 ---------- */\n \n .dash2-peek {","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/styles%2Fdash.css"},{"filename":"styles/nav.css","previous_filename":null,"status":"modified","additions":8,"deletions":4,"changes":12,"patch":"@@ -165,20 +165,24 @@\n \n /* 「换一条」永远在这儿 —— 选了计划之后必须一眼看得到怎么改。\n 实测过:上一版选完之后,首页和侧栏加起来**一个**换计划的入口都没有。 */\n+/* 【为什么比上一版重】上一版它是最轻的一档(--ink-3 + 细下划线),\n+ 功能在,但用户实测没找到 —— 于是他以为「选了计划就换不了」。\n+ **可见 ≠ 找得到**,而我的回归脚本只断言了前者。\n+ 现在颜色提到强调色的文字档,字重提到 600。 */\n .side-plan-change {\n font-family: var(--font-text);\n font-size: var(--fs-meta);\n- font-weight: 500;\n+ font-weight: 600;\n letter-spacing: 0;\n text-transform: none;\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/styles%2Fnav.css"},{"filename":"styles/plans.css","previous_filename":null,"status":"modified","additions":26,"deletions":0,"changes":26,"patch":"@@ -604,6 +604,32 @@\n color: var(--ink-3);\n }\n \n+/* 「这是重走的一轮」。一个 0 / 130 不解释清楚就像进度丢了,\n+ 所以这一行必须说出从哪天起算,并且给一条退回去的路。 */\n+.pl-round {\n+ display: flex;\n+ flex-wrap: wrap;\n+ align-items: baseline;\n+ gap: var(--sp-1);\n+ max-width: 62ch;\n+ margin: var(--sp-2) 0 0;\n+ padding: var(--sp-1) var(--sp-2);\n+ background: var(--surface);\n+ border-left: 2px solid var(--accent);\n+ border-radius: 0 var(--r) var(--r) 0;\n+ ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8255bec9ca99586712bd18869ece5610fc81d6ad/styles%2Fplans.css"}]},"5f22b633f28569806c158035fdd957ab64590472":{"sha":"5f22b633f28569806c158035fdd957ab64590472","stats":{"additions":1308,"deletions":73,"total":1381},"files":[{"filename":"components/continue.tsx","previous_filename":null,"status":"modified","additions":57,"deletions":3,"changes":60,"patch":"@@ -205,12 +205,15 @@ export function ContinueButton() {\n /**\n * 这一页的主内容本身就是那颗〔继续〕吗。\n *\n- * 首页(新访客的三选一 / 回访者那张「下一件事」的卡)和计划详情页(页头那颗\n- * CTA)都是。这两页不许再在外壳里放一颗 —— 同一个目标两个入口等于没有入口。\n+ * 三页:首页(进度盘,每张卡都是一个「接着读」)、`/plans`(三选一,\n+ * 自带开始 / 继续)、计划详情页(页头那颗 CTA)。\n+ * 这三页不许再在外壳里放一颗 —— 同一个目标两个入口等于没有入口。\n * 侧栏和窄屏顶栏共用这一条判断。\n */\n export function cedesContinue(path: string) {\n- return path === \"/\" || path.startsWith(\"/plans/\");\n+ /","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/components%2Fcontinue.tsx"},{"filename":"components/home-tracks.tsx","previous_filename":null,"status":"added","additions":210,"deletions":0,"changes":210,"patch":"@@ -0,0 +1,210 @@\n+\"use client\";\n+\n+// 首页 —— 一张仪表盘,**不问任何问题**。\n+//\n+// 【为什么把「你想为什么做好准备?」删掉】\n+// 那一屏问的是一个长期承诺(三个答案分别是 42 / 15 / 若干小时),\n+// 而一个人今天打开这个站,最常见的念头是「我有二十分钟,让我刷点八股」。\n+// 那个意图在那一屏上一个入口都没有。\n+//\n+// 更糟的是它是一扇**单向门**:选中之后 `plan` 永远有值,那一屏再也回不来 ——\n+// 点 logo、点「今天」、手输 `/` 都回不去。\n+//\n+// 现在首页就是几条轨道的进度盘:你在每条上走到哪、点一下就接着上回那一节。\n+// 承诺是零。参考的是同一套壳的另外几个 app(DataData / AlgoAlgo / APIer /\n+// RedisVisual / AgentLab)—— 它们的首页从来不问","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/components%2Fhome-tracks.tsx"},{"filename":"components/home.tsx","previous_filename":null,"status":"modified","additions":33,"deletions":62,"changes":95,"patch":"@@ -1,85 +1,56 @@\n \"use client\";\n \n-// 首页 —— 第一屏只让人做**一个**决定。\n+// 首页 —— **一张仪表盘,不问任何问题。**\n //\n-// 【UI v2 换掉了什么】\n-// 上一版第一屏对新访客是六张计划卡:每张有名字、结果、适合谁、六七个档位徽章、\n-// 「20 档 · 350 条 · 约 42 小时」和一颗 Start。六张一起看是六个同等重要的选择,\n-// 而一个刚到的人这时候连「Federation 是什么」都不知道 —— 密度本身就是劝退。\n+// 【为什么删掉了那个「你想为什么做好准备?」】\n+// 那一屏问的是一个长期承诺(三个答案分别是 42 / 15 / 若干小时),\n+// 而一个人今天打开这个站最常见的念头是「我有二十分钟,让我刷点八股」——\n+// 那个意图在那一屏上一个入口都没有。\n //\n-// 现在分成两种人,各自一屏,互斥:\n+// 而","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/components%2Fhome.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":33,"deletions":0,"changes":33,"patch":"@@ -206,6 +206,39 @@ export function PlanContinueButton() {\n * 和 PlanContinueButton 是同一条数据(`status.next`),只是壳不一样:\n * 那一颗曾经在顶栏,这一版顶栏不再有导航,所以只剩这一颗。\n */\n+/**\n+ * 首页那一行「接着上次」的计划版本 —— 指向计划的下一格。\n+ *\n+ * 首页第一屏现在是进度盘,所以这里只有一行;计划的完整仪表盘\n+ * (档位、剩余估时、为什么是这一步)搬到了 /plans/[id] 页头。\n+ */\n+export function PlanStripLine() {\n+ const { status, ready } = useActivePlan();\n+ if (!ready || !status) return null;\n+\n+ const next = status","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/components%2Fplan-kit.tsx"},{"filename":"components/plan-list.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":3,"changes":14,"patch":"@@ -9,6 +9,7 @@ import Link from \"next/link\";\n import { litePlans } from \"@/lib/plan-lite\";\n import { useProgress } from \"@/lib/progress\";\n import { ActivePlanCard, PlanCard } from \"./plan-cards\";\n+import { PlanPicker } from \"./plan-picker\";\n import { PlanMark } from \"./plan-mark\";\n import { T } from \"./t\";\n \n@@ -21,13 +22,20 @@ export function PlanList() {\n return (\n <main className=\"main\" data-rail=\"off\">\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/components%2Fplan-list.tsx"},{"filename":"components/plan-picker.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":3,"changes":7,"patch":"@@ -220,9 +220,10 @@ export function PlanPicker() {\n </ul>\n \n <p className=\"pick-all\">\n- <Link className=\"ui-quiet\" href=\"/plans\">\n- <T zh=\"看全部六条计划\" en=\"View all plans\" />\n- </Link>\n+ {/* 这一段现在就住在 /plans 上,所以链接改成锚点 —— 指向自己会白刷一次页面 */}\n+ <a className=\"ui-quiet\" href=\"#all-plans\">\n+ <T zh=\"看全部六条的细节\" en=\"See all six in detail\" />\n+ </a>\n <span class","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/components%2Fplan-picker.tsx"},{"filename":"content/track-manifest.ts","previous_filename":null,"status":"added","additions":581,"deletions":0,"changes":581,"patch":"@@ -0,0 +1,581 @@\n+// 首页仪表盘用的**轨道清单** —— 生成物,不要手改。改完内容跑 `npm run gen:nav`。\n+//\n+// 【为什么不直接读 content/nav.ts】\n+// 首页要显示每门课的进度和「接着上次那一节」,需要的只有:课程名、一句话、\n+// 按顺序排好的课文 id / 标题 / 链接。而 nav.ts 是 134 KB —— 它还带着\n+// 模块结构、练习计数、八股清单、coding 清单、考场清单、模拟考题目名。\n+// 首页首屏用不上那些。\n+//\n+// 实测:把 nav 拉进首页首屏,First Load JS 从 178 kB 涨到两百多;\n+// 这一份只有几 KB。\n+//\n+// 【为什么不复用 content/plan-manifest.ts】\n+// 那一份只包含「出现在某条计划里」的课文,实测覆盖 65 / 80 节 ——\n+// 面试八股那","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/content%2Ftrack-manifest.ts"},{"filename":"scripts/gen-nav.mjs","previous_filename":null,"status":"modified","additions":9,"deletions":1,"changes":10,"patch":"@@ -51,7 +51,7 @@ if (!json) {\n // 两份重数据单独出文件 —— 见各自模板顶部的注释。\n // searchIndex 130 KB 出头,只有 ⌘K 搜索用,进 nav.ts 每页白下 80 kB(gzip 后)\n // exercises 148 条,只有引导计划用,而计划那一套是懒加载的\n-const { searchIndex, exercises, planManifest, ...navJson } = json;\n+const { searchIndex, exercises, planManifest, trackManifest, ...navJson } = json;\n \n const tpl = readFileSync(\"scripts/nav-template.txt\", \"utf8\");\n writeFileSync(\n@@ -65,6 +65,14 @","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/scripts%2Fgen-nav.mjs"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":36,"deletions":0,"changes":36,"patch":"@@ -120,6 +120,42 @@ export function GET() {\n runnable: !!a.blankSandbox,\n })),\n \n+ /**\n+ * 首页仪表盘用的轨道清单 —— 生成到 content/track-manifest.ts。\n+ * 只有课程名、一句话、和按顺序排好的课文(id / 标题 / 链接)。\n+ * 理由见那个文件顶部。\n+ */\n+ trackManifest: {\n+ tracks: EXAMS.map((e) => ({\n+ id: e.id,\n+ zh: e.shortTitle,\n+ en: e.shortTitleEn,\n+ blurbZh: e.description,\n+ blurbEn: e.descripti","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/track-manifest-template.txt","previous_filename":null,"status":"added","additions":48,"deletions":0,"changes":48,"patch":"@@ -0,0 +1,48 @@\n+// 首页仪表盘用的**轨道清单** —— 生成物,不要手改。改完内容跑 `npm run gen:nav`。\n+//\n+// 【为什么不直接读 content/nav.ts】\n+// 首页要显示每门课的进度和「接着上次那一节」,需要的只有:课程名、一句话、\n+// 按顺序排好的课文 id / 标题 / 链接。而 nav.ts 是 134 KB —— 它还带着\n+// 模块结构、练习计数、八股清单、coding 清单、考场清单、模拟考题目名。\n+// 首页首屏用不上那些。\n+//\n+// 实测:把 nav 拉进首页首屏,First Load JS 从 178 kB 涨到两百多;\n+// 这一份只有几 KB。\n+//\n+// 【为什么不复用 content/plan-manifest.ts】\n+// 那一份只包含「出现在某条计划里」的课文,实测覆盖 65 / 80 节 ——\n+// 面试八股那门","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/scripts%2Ftrack-manifest-template.txt"},{"filename":"styles/dash.css","previous_filename":null,"status":"modified","additions":277,"deletions":1,"changes":278,"patch":"@@ -14,10 +14,12 @@\n 新访客:三个决定\n ============================================================ */\n \n+/* 【不要再加上内边距】.main 已经给了 --sp-4 的上内边距。\n+ 这里再加一层,/plans 的第一块就落在 64px,而其余 11 个页面都是 32 ——\n+ 垂直节奏的回归脚本就是量这个。 */\n .pick {\n position: relative;\n max-width: 780px;\n- padding-top: var(--sp-4);\n }\n \n /* 全站唯一一处环境光晕。刻意很淡(0.06)而且不动 ——\n@@ -524,3 +526,277 @@\n white-space: normal;\n }\n }\n+\n+/* =======================","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/styles%2Fdash.css"},{"filename":"styles/plans.css","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -1755,3 +1755,12 @@\n flex-wrap: wrap;\n }\n }\n+\n+/* /plans 上「全部六条」那一段的页头。它排在三选一之后,\n+ 所以要一条分界线,并且锚点跳过来别被 sticky 顶栏压住。 */\n+.pl-list-head {\n+ margin-top: var(--sp-8);\n+ padding-top: var(--sp-4);\n+ border-top: 1px solid var(--rule);\n+ scroll-margin-top: calc(var(--topbar-h) + var(--sp-3));\n+}","blob_url":"https://github.com/renrenmimi/DrillLab/blob/5f22b633f28569806c158035fdd957ab64590472/styles%2Fplans.css"}]},"c6ea36c62dbb0cc30fd7202c42734fa12eb194f1":{"sha":"c6ea36c62dbb0cc30fd7202c42734fa12eb194f1","stats":{"additions":60,"deletions":16,"total":76},"files":[{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":60,"deletions":16,"changes":76,"patch":"@@ -454,9 +454,12 @@\n overflow: hidden;\n }\n \n+/* 贴着面板顶边,所以自己不留圆角 —— 面板的 overflow: hidden 会裁出正确的形状。\n+ 写死 0 是为了挡住任何来自别处的 border-radius(曾经被全局 :focus-visible 加过一个)。 */\n .search-input {\n display: block;\n width: 100%;\n+ border-radius: 0;\n padding: 15px 18px;\n font: inherit;\n font-size: var(--fs-body);\n@@ -553,9 +556,16 @@\n .search-kbd {\n display: none;\n }\n+ /* 窄屏没有物理键盘,「⏎ 打开 · Esc 关闭」那一行是噪音。\n+ 但","blob_url":"https://github.com/renrenmimi/DrillLab/blob/c6ea36c62dbb0cc30fd7202c42734fa12eb194f1/styles%2Fshell.css"}]},"e430935a4ddde6722b9c12cd8601bd1fc7b97763":{"sha":"e430935a4ddde6722b9c12cd8601bd1fc7b97763","stats":{"additions":9,"deletions":6,"total":15},"files":[{"filename":"components/search.tsx","previous_filename":null,"status":"modified","additions":4,"deletions":4,"changes":8,"patch":"@@ -382,10 +382,10 @@ export function Search() {\n ref={inputRef}\n className=\"search-input\"\n value={q}\n- placeholder={t(\n- \"搜课程、练习、模拟考…(试试「filter」「DataLoader」「状态码」)\",\n- \"Search lessons, problems, mocks… (try “filter”, “DataLoader”, “status code”)\",\n- )}\n+ /* 【占位符要短】原来那一句带三个例子,在 360px 的输入框里被切在词\n+ 中间(\"...(try \"filte\"),看着像坏了。例子本来就由","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e430935a4ddde6722b9c12cd8601bd1fc7b97763/components%2Fsearch.tsx"},{"filename":"styles/base.css","previous_filename":null,"status":"modified","additions":5,"deletions":2,"changes":7,"patch":"@@ -145,11 +145,14 @@ hr {\n font-variant-numeric: tabular-nums;\n }\n \n-/* —— 焦点可见 —— */\n+/* —— 焦点可见 ——\n+ 【不许在这里写 border-radius】它会改**被聚焦元素自己的几何**,不只是那个环。\n+ 实测踩到:搜索输入框一聚焦就凭空长出 6px 圆角,而它嵌在一个 14px 圆角、\n+ overflow: hidden 的面板顶部,两个圆角叠在一起,顶部看起来是双层的。\n+ 现代浏览器的 outline 本来就会跟着元素自己的圆角走,不需要在这儿指定。 */\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n- border-radius: var(--r-sm);\n }\n \n ::select","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e430935a4ddde6722b9c12cd8601bd1fc7b97763/styles%2Fbase.css"}]},"8b32b4b0269b883acbee33e1c71bfcd7277f78ff":{"sha":"8b32b4b0269b883acbee33e1c71bfcd7277f78ff","stats":{"additions":76,"deletions":0,"total":76},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":76,"deletions":0,"changes":76,"patch":"@@ -378,6 +378,82 @@ Sandpack 在浏览器 iframe 里没有 Node,那套确实跑不了)。\n 记录一条没删 → **完整路线仍然算得到那几节** → 课文页上的勾没被取消 →\n 重走后重新做的算进这一轮 → 撤销后恢复。\n \n+### 【重要】首页是一张进度盘,**不问任何问题**\n+\n+用户的原话:**「如果别人今天只是想复习八股呢?」**\n+\n+上一版首页第一屏是「你想为什么做好准备?」加三个选项 —— 三个答案分别是\n+42 / 15 / 若干小时的**长期承诺**。而一个人今天打开这个站最常见的念头是\n+「我有二十分钟,让我刷点八股」,那个意图在那一屏上一个入口都没有。\n+\n+而且它是一扇**单向门**:那一屏只在 `plan === undefined` 时渲染,选中之后\n+`plan` 永远有值 —— 点 logo、点「今天」、手输 `/` 全都回不去。\n+用户原话:**「为什么我点击 ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/8b32b4b0269b883acbee33e1c71bfcd7277f78ff/CLAUDE.md"}]},"36105245c2f613120923b0dac61584e8d945410b":{"sha":"36105245c2f613120923b0dac61584e8d945410b","stats":{"additions":211,"deletions":51,"total":262},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":18,"deletions":3,"changes":21,"patch":"@@ -405,14 +405,29 @@ Sandpack 在浏览器 iframe 里没有 Node,那套确实跑不了)。\n 1. **首页没有任何实心强调块。** 五张卡本身是链接,不是按钮;一行「接着上次」\n 是描边 + 一条强调色左边线。所以 `/`、`/plans`、`/plans/[id]` 三页都在\n `cedesContinue()` 里 —— 侧栏那颗〔继续〕在这三页让位给页面自己的动作。\n-2. **进度盘读 `content/track-manifest.ts`,不读 `content/nav.ts`。**\n+2. **五个分类用圆形仪表盘,不用横条。** 五张卡并排时,横条读的是「长度」——\n+ 五条长度不同的线要互相比较才看得出差别;环读的是「转了多少」,一眼能对比,\n+ 而且它把「几 / 几」放进了自己中间,不再单独占一行。\n+ 实现上三件事别改:起点转到 12 点方向(`rot","blob_url":"https://github.com/renrenmimi/DrillLab/blob/36105245c2f613120923b0dac61584e8d945410b/CLAUDE.md"},{"filename":"components/home-tracks.tsx","previous_filename":null,"status":"modified","additions":64,"deletions":23,"changes":87,"patch":"@@ -37,46 +37,87 @@ function useTrackState(track: Track) {\n return { done, next, fresh: done === 0 };\n }\n \n+/**\n+ * 圆形仪表盘。\n+ *\n+ * 【为什么是环而不是条】五张卡并排时,横条读的是「长度」——五条长度不同的\n+ * 线要互相比较才看得出差别。环读的是「转了多少」,一眼就能对比,\n+ * 而且它把「几 / 几」放在了自己中间,不用再单独占一行。\n+ *\n+ * 实现要点:\n+ * ① 起点转到 12 点方向(-90deg),不然从 3 点开始转,读起来别扭;\n+ * ② 进度靠 stroke-dashoffset,所以变化时能走 --t-prog 的过渡;\n+ * ③ 走完了换成 --ok —— 那是「完成」的语义色,不是强调色;\n+ * ④ 数字本身也在 DOM 里,所以读屏的人不依赖这个图形;ar","blob_url":"https://github.com/renrenmimi/DrillLab/blob/36105245c2f613120923b0dac61584e8d945410b/components%2Fhome-tracks.tsx"},{"filename":"content/track-manifest.ts","previous_filename":null,"status":"modified","additions":7,"deletions":0,"changes":7,"patch":"@@ -25,6 +25,8 @@ export interface Track {\n id: string;\n zh: string;\n en?: string;\n+ /** 分类:基础 / 前端 / 后端 / 全栈 */\n+ category: string;\n /** 一句话:这门课是干什么的 */\n blurbZh: string;\n blurbEn?: string;\n@@ -48,6 +50,7 @@ export const TRACKS: Track[] = [\n \"id\": \"foundations\",\n \"zh\": \"地基 · 项目与语言\",\n \"en\": \"Foundations · project and language\",\n+ \"category\": \"基础\",\n \"blurbZh\": \"在动 React 和 GraphQL 之前,先把","blob_url":"https://github.com/renrenmimi/DrillLab/blob/36105245c2f613120923b0dac61584e8d945410b/content%2Ftrack-manifest.ts"},{"filename":"scripts/nav-dump-route.txt","previous_filename":null,"status":"modified","additions":2,"deletions":0,"changes":2,"patch":"@@ -130,6 +130,8 @@ export function GET() {\n id: e.id,\n zh: e.shortTitle,\n en: e.shortTitleEn,\n+ // 分类(基础 / 前端 / 后端 / 全栈)—— 仪表盘上每张卡的眉题\n+ category: e.category,\n blurbZh: e.description,\n blurbEn: e.descriptionEn,\n // 平行支线的判据和 content/path.ts 完全一致:自己没有前置,","blob_url":"https://github.com/renrenmimi/DrillLab/blob/36105245c2f613120923b0dac61584e8d945410b/scripts%2Fnav-dump-route.txt"},{"filename":"scripts/track-manifest-template.txt","previous_filename":null,"status":"modified","additions":2,"deletions":0,"changes":2,"patch":"@@ -25,6 +25,8 @@ export interface Track {\n id: string;\n zh: string;\n en?: string;\n+ /** 分类:基础 / 前端 / 后端 / 全栈 */\n+ category: string;\n /** 一句话:这门课是干什么的 */\n blurbZh: string;\n blurbEn?: string;","blob_url":"https://github.com/renrenmimi/DrillLab/blob/36105245c2f613120923b0dac61584e8d945410b/scripts%2Ftrack-manifest-template.txt"},{"filename":"styles/dash.css","previous_filename":null,"status":"modified","additions":109,"deletions":25,"changes":134,"patch":"@@ -607,7 +607,7 @@\n white-space: nowrap;\n }\n \n-/* ---------- 五门课的进度盘 ---------- */\n+/* ---------- 五个分类的圆形仪表盘 ---------- */\n \n .trk-sec {\n margin-top: var(--sp-6);\n@@ -619,67 +619,154 @@\n padding: 0;\n display: grid;\n gap: var(--sp-2);\n- grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));\n+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n }\n \n .trk {\n margin: 0;\n }\n \n-/* 整张卡是一个链接 ——","blob_url":"https://github.com/renrenmimi/DrillLab/blob/36105245c2f613120923b0dac61584e8d945410b/styles%2Fdash.css"},{"filename":"styles/tokens.css","previous_filename":null,"status":"modified","additions":9,"deletions":0,"changes":9,"patch":"@@ -73,6 +73,12 @@\n --on-warn: #1a1305;\n --on-danger: #1c0806;\n \n+ /* 首页那几个圆形仪表盘的**环底**。它是有意义的图形(「总量在这儿」),\n+ WCAG 1.4.11 对这类要求 3:1 —— 实测 --rule 在 --raised 上只有 1.29、\n+ --rule-strong 1.71,画出来看不出是个环。这一档是 3.05:1。\n+ **不要用 opacity 去压它** —— 那会把这个值直接打回不达标。 */\n+ --dial-track: #5d6b62;\n+\n /* —— 语义色。四个,都只在真的表达状态时出现 —— */\n --ok: #63d69a;\n --ok-wash: #10201a;\n@@ -243,6 +249,9 @@\n --on-warn: #ffffff;\n -","blob_url":"https://github.com/renrenmimi/DrillLab/blob/36105245c2f613120923b0dac61584e8d945410b/styles%2Ftokens.css"}]},"3bb5b03a63957850ed07d3a399b7696e342c061b":{"sha":"3bb5b03a63957850ed07d3a399b7696e342c061b","stats":{"additions":1212,"deletions":343,"total":1555},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":105,"deletions":3,"changes":108,"patch":"@@ -117,6 +117,10 @@ GraphQL Federation 考试 / Spring Boot 控制器 / 前端面试复习 / Cab Boo\n | 计划零件必须懒加载 | 第一版直接 import 进外壳,实测 `/drill/[id]` 373 → 525、`/code/[id]` 387 → 539、课程页 470 → 625 kB。而那些零件**没跟计划时一个字都不渲染** —— 白下 160 KB。现在先用 `useProgress()` 看一眼有没有在跟计划,真跟才 `next/dynamic` 拉 plan-kit |\n | 148 个练习的 id 不许进 nav.ts | 合进去 nav 从 133 KB 长到 160 KB,webpack 分块随之改变,课程页多下 145 kB。现在在 `content/nav-exercises.ts`(生成物),只有懒加载的 plan chunk 读它 |\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/CLAUDE.md"},{"filename":"app/plans/choose/page.tsx","previous_filename":null,"status":"added","additions":19,"deletions":0,"changes":19,"patch":"@@ -0,0 +1,19 @@\n+// /plans/choose —— 薄壳。换一条引导计划。\n+//\n+// 【为什么单独一个路由】侧栏那个「换一条」以前指向 /plans,\n+// 而你可能已经站在 /plans 上 —— 点了没有任何反应。\n+// 一个只做「换」这件事的地址,让那个链接永远是一次真实的跳转,\n+// 而且浏览器返回键和页面上的「取消」都能回到原处。\n+import { PlanChooser } from \"@/components/plan-chooser\";\n+import { assertPlans } from \"@/content/plans-assert\";\n+\n+export const metadata = {\n+ title: \"换一条引导计划 / Change your guided plan\",\n+ description:\n+ \"在六条引导计划之间切换。换一条不会删除","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/app%2Fplans%2Fchoose%2Fpage.tsx"},{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":59,"deletions":21,"changes":80,"patch":"@@ -41,7 +41,7 @@ import { usePathname } from \"next/navigation\";\n import { useEffect, useRef, useState, type ReactNode } from \"react\";\n import { useLocale } from \"@/lib/locale\";\n import { modeOf } from \"@/lib/modes\";\n-import { sectionOf } from \"@/lib/side-nav\";\n+import { locationOf } from \"@/lib/side-nav\";\n import { useTheme } from \"@/lib/theme\";\n import { cedesContinue, ContinueButton } from \"./continue\";\n import { ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fapp-shell.tsx"},{"filename":"components/coding-list.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -188,7 +188,7 @@ export function CodingList({ query }: { query: CodingQuery }) {\n \n <div className=\"filters\">\n <span className=\"filter-label\">\n- <T en=\"Track\" zh=\"方向\" />\n+ <T en=\"Topic\" zh=\"方向\" />\n </span>\n {TRACKS.map((t) => (\n <Link","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fcoding-list.tsx"},{"filename":"components/continue.tsx","previous_filename":null,"status":"modified","additions":12,"deletions":1,"changes":13,"patch":"@@ -71,10 +71,21 @@ function foundationsStart(): ContinueTarget {\n */\n export function useContinue(): { ready: boolean; target: ContinueTarget } {\n const { ready, mostRecent } = useProgress();\n+ // 【没有访问记录时不能直接跳回「地基第一节」】打过勾但没有 recent 的档案\n+ // (老数据、或者别处导入的进度)会让这一行说「从地基开始 · 第 1 / 9 节」,\n+ // 而正下方那张卡同时写着「地基 4 / 9 · 接着读第 5 节」——\n+ // 同一屏上两个答案。所以退一步是「第一节没读过的」,和卡片同一份算法。\n+ const learn = useLearnTarget();\n if (!ready","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fcontinue.tsx"},{"filename":"components/drill-card.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -197,7 +197,7 @@ export function DrillEmptyIfNone({\n </span>\n <T\n zh=\"换个方向,或者把掌握状态改成「全部」。\"\n- en=\"Try another track, or set the mark filter back to all.\"\n+ en=\"Try another topic, or set the mark filter back to all.\"\n />\n </div>\n );","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fdrill-card.tsx"},{"filename":"components/drill-list.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -212,7 +212,7 @@ export function DrillList({ query }: { query: DrillQuery }) {\n \n <div className=\"filters\">\n <span className=\"filter-label\">\n- <T zh=\"方向\" en=\"Track\" />\n+ <T zh=\"方向\" en=\"Topic\" />\n </span>\n <Link\n className=\"filter-btn\"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fdrill-list.tsx"},{"filename":"components/drill-session.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -262,7 +262,7 @@ function SessionSetup({ emptyScope }: { emptyScope?: string }) {\n </div>\n \n <div className=\"minihead\">\n- <T zh=\"或者只抽一个方向\" en=\"Or a single track\" />\n+ <T zh=\"或者只抽一个方向\" en=\"Or a single topic\" />\n </div>\n <div className=\"filters\" style={{ borderBottom: 0, marginBottom: 14 }}>\n {DRILL_TRACK_ORDER.map((t) => {","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fdrill-session.tsx"},{"filename":"components/home-library.tsx","previous_filename":null,"status":"modified","additions":37,"deletions":110,"changes":147,"patch":"@@ -145,66 +145,6 @@ export function HomeLibrary() {\n \n return (\n <>\n- {/* ================================================================\n- 浏览全部材料。四个模式一个都没删,只是退到最后 ——\n- 它们回答的是「让我自己挑」,和上面那颗「继续」不是同一个问题,\n- 所以不该有同等的视觉重量。标题用 ui-sec-title 的 quiet 一档。\n- ================================================================ */}\n- <section className=\"ui-sec dash-sec\"","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fhome-library.tsx"},{"filename":"components/home-tracks.tsx","previous_filename":null,"status":"modified","additions":82,"deletions":19,"changes":101,"patch":"@@ -21,7 +21,9 @@\n \n import Link from \"next/link\";\n import { SURFACES, TRACKS, type Track } from \"@/content/track-manifest\";\n+import { useT } from \"@/lib/locale\";\n import { useProgress } from \"@/lib/progress\";\n+import { trackCopy } from \"@/lib/track-copy\";\n import { T } from \"./t\";\n \n /** 这条轨道走到哪了,下一节是哪一节 */\n@@ -47,10 +49,20 @@ function useTrackState(track: Track) {\n * 实现要点:\n * ① 起点转到 12 点方向(-90deg),不然从 3 点开始转,读起来别","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fhome-tracks.tsx"},{"filename":"components/learning-path.tsx","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -125,7 +125,7 @@ export function LearningPath() {\n <span className=\"road-band-note\">\n <T\n zh=\"不依赖主线,任何时候都能开始 —— 下面的「你在这」是这条线自己的进度\"\n- en=\"No prerequisites, start any time — the marker below tracks this line on its own\"\n+ en=\"No prerequisites, start any time — the marker below follows this line on its own\"\n ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Flearning-path.tsx"},{"filename":"components/plan-chooser.tsx","previous_filename":null,"status":"added","additions":180,"deletions":0,"changes":180,"patch":"@@ -0,0 +1,180 @@\n+\"use client\";\n+\n+// /plans/choose —— 换一条引导计划。\n+//\n+// 【为什么它是一个独立的页面,而不是把六张卡摊在 /plans 上】\n+// 上一版 `/plans` 同时渲染三个东西:三选一、六张卡的全表、当前计划的仪表盘。\n+// 一屏里有三个 h1,而且已经在跟计划的人还得先滚过一整套「选计划」的界面\n+// 才看得到自己的计划。侧栏那个「换一条」更糟 —— 它指向 `/plans`,\n+// 而你可能已经站在 `/plans` 上,**点了等于没反应**。\n+//\n+// 现在分工是:\n+// /plans 没跟计划 → 三选一;跟着计划 → 我的计划(仪表盘 + 继续)\n+// /plans/choose 只做一件事:换。当前这条是哪条、能换成哪些、以及取消。\n+//\n+// 侧栏那个「换一条」也指到这里,所以","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fplan-chooser.tsx"},{"filename":"components/plan-dash.tsx","previous_filename":null,"status":"modified","additions":13,"deletions":11,"changes":24,"patch":"@@ -89,9 +89,11 @@ export function PlanDash() {\n <T zh=\"你的计划\" en=\"Your plan\" />\n </div>\n \n- <h1 className=\"dash2-plan display\" id=\"dash2-h\">\n+ {/* 【h2 不是 h1】这一块现在只出现在 /plans 上,而那一页的 h1 是\n+ 「我的引导计划」。一页只能有一个 h1。 */}\n+ <h2 className=\"dash2-plan display\" id=\"dash2-h\">\n <T zh={plan.zh} en={plan.en} />\n- </h1>\n+ </h2>\n \n <p className=\"dash2-s","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fplan-dash.tsx"},{"filename":"components/plan-detail.tsx","previous_filename":null,"status":"modified","additions":7,"deletions":3,"changes":10,"patch":"@@ -518,8 +518,8 @@ export function PlanDetail({ plan }: { plan: ResolvedPlan }) {\n \n {isActive && (\n <>\n- <Link className=\"ui-quiet pl-head-alt\" href=\"/plans\">\n- <T zh=\"换一条计划\" en=\"Change plan\" />\n+ <Link className=\"ui-quiet pl-head-alt\" href=\"/plans/choose\">\n+ <T zh=\"换一条引导计划\" en=\"Change guided plan\" />\n </Link>\n","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fplan-detail.tsx"},{"filename":"components/plan-kit.tsx","previous_filename":null,"status":"modified","additions":15,"deletions":3,"changes":18,"patch":"@@ -17,6 +17,7 @@\n // 那一页是单独一个路由,自己去读 content/plans。\n \n import Link from \"next/link\";\n+import { usePathname } from \"next/navigation\";\n import { useMemo, type ReactNode } from \"react\";\n import { useT } from \"@/lib/locale\";\n import type { ModeId } from \"@/lib/modes\";\n@@ -271,6 +272,7 @@ export function PlanSideContinue({ onNavigate }: { onNavigate: () => void }) {\n export function PlanSideBlock({ onNavigate }: { onNavi","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fplan-kit.tsx"},{"filename":"components/plan-list.tsx","previous_filename":null,"status":"modified","additions":82,"deletions":49,"changes":131,"patch":"@@ -1,70 +1,105 @@\n \"use client\";\n \n-// /plans —— 六条计划,加上「你正在跟哪条走」。\n+// /plans —— 两种状态,**任何时候只有一个 h1**。\n //\n-// 这一页要回答的问题只有一个:**我现在想达成什么目标?**\n-// 所以它不解释产品架构,也不重复四个模式那一套 —— 那些在首页下半和 /guide 里。\n+// 【上一版为什么乱】它把三样东西同时摊在一屏上:三选一、六张卡的全表、\n+// 当前计划的仪表盘。于是一页里有两到三个 h1,而已经在跟计划的人还得先\n+// 滚过一整套「选计划」的界面,才看得到自己的计划。\n+//\n+// 现在:\n+//\n+// 没跟计划 h1「你想为什么做好准备?」→ 三选一\n+// 下面一条安静的「看全部六条」,全表的标题是 h2\n+// 跟着计划 h1「我的引导计划」→ 仪表盘 +","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fplan-list.tsx"},{"filename":"components/plan-picker.tsx","previous_filename":null,"status":"modified","additions":11,"deletions":3,"changes":14,"patch":"@@ -59,7 +59,15 @@ function scale(p: LitePlan | undefined) {\n };\n }\n \n-export function PlanPicker() {\n+/**\n+ * 三个决定。\n+ *\n+ * `level` 决定这一段的标题是 h1 还是 h2 —— **一页只能有一个 h1**:\n+ * /plans(没跟计划时) 这一段就是整页的主内容 → h1\n+ * /plans/choose 页面自己的 h1 是「换一条引导计划」 → 这里降成 h2\n+ */\n+export function PlanPicker({ level = \"h1\" }: { level?: \"h1\" | \"h2\" }) {\n+ const Heading = level;\n const { setActivePlan } = useProgress();\n /","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/components%2Fplan-picker.tsx"},{"filename":"docs/polish.md","previous_filename":null,"status":"added","additions":125,"deletions":0,"changes":125,"patch":"@@ -0,0 +1,125 @@\n+# 这一轮的七件事\n+\n+分支 `feat/dial-dashboard`。保留深色石墨 + 柠檬绿、圆形仪表盘、侧栏那套导航、\n+共用的对齐轴,以及全部内容 / 计划 / 进度 / 路由。改的是七处说不清楚的地方。\n+\n+截图都是 1440×900 和 390×844,英文界面,深色主题(默认那一档)。\n+\n+| 看什么 | 桌面 | 390px |\n+| --- | --- | --- |\n+| 今天(全新) | ![](polish/today-1440.jpg) | ![](polish/today-390.jpg) |\n+| 今天(有进度) | ![](polish/today-progress-1440.jpg) | ![](polish/today-progress-390.jpg) |\n+| 我的引导计划 | ![](polish/my-plan-1440.jpg) | ![","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish.md"},{"filename":"docs/polish/change-plan-1440.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Fchange-plan-1440.jpg"},{"filename":"docs/polish/change-plan-390.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Fchange-plan-390.jpg"},{"filename":"docs/polish/drawer-390.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Fdrawer-390.jpg"},{"filename":"docs/polish/my-plan-1440.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Fmy-plan-1440.jpg"},{"filename":"docs/polish/my-plan-390.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Fmy-plan-390.jpg"},{"filename":"docs/polish/today-1440.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Ftoday-1440.jpg"},{"filename":"docs/polish/today-390.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Ftoday-390.jpg"},{"filename":"docs/polish/today-progress-1440.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Ftoday-progress-1440.jpg"},{"filename":"docs/polish/today-progress-390.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Ftoday-progress-390.jpg"},{"filename":"docs/polish/track-cards-1440.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Ftrack-cards-1440.jpg"},{"filename":"docs/polish/track-cards-390.jpg","previous_filename":null,"status":"added","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/docs%2Fpolish%2Ftrack-cards-390.jpg"},{"filename":"lib/side-nav.ts","previous_filename":null,"status":"modified","additions":60,"deletions":4,"changes":64,"patch":"@@ -102,15 +102,71 @@ export function activeSideHref(path: string): string | undefined {\n /**\n * 顶栏那条「我在哪」。\n *\n- * 只给**区段名**,不给页面标题 —— 页面标题就在下面一行的 h1 上,\n- * 顶栏再写一遍是重复。区段名是页面自己不说的那一半。\n+ * 一级页面只给一个区段名(今天 / 我的计划 / 学课程……);**深一层的页面给\n+ * 一条两段的面包屑**(学课程 / React 考试)。理由是顶栏是 sticky 的:\n+ * 正文里那条面包屑滚两屏就没了,而「我现在在哪门课里」是随时要知道的。\n+ *\n+ * 【它为什么不写页面标题】页面标题就在下面一行的 h1 上,顶栏再写一遍是重复。\n+ * 第二段说的是**这一页自己不说的那一半** —— 课文的 h1 是那一节的名字,\n+ * 不是课程名。","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/lib%2Fside-nav.ts"},{"filename":"lib/track-copy.ts","previous_filename":null,"status":"added","additions":41,"deletions":0,"changes":41,"patch":"@@ -0,0 +1,41 @@\n+// 首页那五张卡上的一句话简介 —— **手写的**,不是把课程简介截断。\n+//\n+// 【为什么不直接用 track.blurbZh】那段话是给课程总览页写的:三到五行,\n+// 解释这门课覆盖什么、为什么这么排。上一版在卡片上用 `-webkit-line-clamp: 2`\n+// 把它截成两行,于是五张卡上有五个半截句子(「……再加两道书面」)。\n+// **CSS 不该替你编辑文案** —— 截断只是把「太长了」这件事藏起来。\n+//\n+// 所以这里每条自己写一遍,规矩是**最多两句、每句短**:\n+// 第一句说「这一类是什么」,第二句说「练到什么程度」。\n+//\n+// 没写进这张表的分类会退回 track.blurbZh —— 加一门新考试仍然只要三步\n+// (见 CLAUDE.md「加一门新考试」),不会因为漏了这里就渲染出空白。\n+\n+export type Tra","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/lib%2Ftrack-copy.ts"},{"filename":"styles/dash.css","previous_filename":null,"status":"modified","additions":89,"deletions":18,"changes":107,"patch":"@@ -613,6 +613,13 @@\n margin-top: var(--sp-6);\n }\n \n+/* 「分类」和「引导计划」的分工,一行。它在小标题下面、卡片上面 ——\n+ 人扫到五张卡之前就读到这句。 */\n+.trk-sec-note {\n+ max-width: 68ch;\n+ margin: calc(var(--sp-1) * -1) 0 var(--sp-2);\n+}\n+\n .trk-grid {\n list-style: none;\n margin: 0;\n@@ -634,6 +641,13 @@\n grid-column: 1 / -1;\n }\n \n+/* 它用的是**同一套内部网格**(仪表盘 + 标题块 + 底部那一行),\n+ 只是一行 1200px 宽的说明读起来要来回扫,所以给文字收一个阅读宽度。 */\n+.trk[data-wide] .trk-blurb,\n+.t","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/styles%2Fdash.css"},{"filename":"styles/nav.css","previous_filename":null,"status":"modified","additions":32,"deletions":76,"changes":108,"patch":"@@ -61,8 +61,8 @@\n }\n \n /* 组标题、模式眉题、计划眉题**共用这一档** —— 侧栏里所有的小标题长一个样。\n- 缩进只给自己没有父级内边距的那两个;.ctx-head-mode 和 .ctx-sec-title\n- 由它们的父容器(.ctx-head / .ctx-sec-head)负责缩进,\n+ 缩进只给自己没有父级内边距的那两个;.ctx-sec-title 由它的父容器\n+ (.ctx-sec-head)负责缩进,\n 两处都给会叠成 36px,实测踩到过。 */\n .snav-group-title,\n .side-plan-eyebrow {\n@@ -71,7 +71,6 @@\n \n .snav-group-title,\n .side-plan-eyebrow,\n-.ctx-head-mode,\n .ctx-sec-title {\n font-family: var(","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/styles%2Fnav.css"},{"filename":"styles/plans.css","previous_filename":null,"status":"modified","additions":153,"deletions":9,"changes":162,"patch":"@@ -1525,15 +1525,6 @@\n max-width: 28ch;\n }\n \n-/* 「自由选择练习方式」那一节的引子。和 .dash-goal-lede 同一档,\n- 但它在 h2 下面,所以字号小一号。 */\n-.dash-sec-lede {\n- font-size: var(--fs-sm);\n- line-height: 1.7;\n- color: var(--ink-2);\n- max-width: 60ch;\n- margin: -6px 0 16px;\n-}\n \n .dash-goal-lede {\n font-size: var(--fs-body);\n@@ -1764,3 +1755,156 @@\n border-top: 1px solid var(--rule);\n scroll-margin-top: calc(var(--topbar-h) + var(--","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/styles%2Fplans.css"},{"filename":"styles/shell.css","previous_filename":null,"status":"modified","additions":85,"deletions":8,"changes":93,"patch":"@@ -56,15 +56,37 @@\n text-decoration: none;\n }\n \n-/* 「我在哪」。只给区段名,页面标题在下面那行 h1 上。 */\n+/* 「我在哪」。一级页面一段,深页面两段。页面标题不在这里 —— 它在 h1 上。\n+ 整条和顶栏的其余控件共用 .topbar 的 align-items: center,\n+ 所以品牌标记、这条路径、右边那排工具落在同一条水平中线上。 */\n .topbar-loc {\n min-width: 0;\n display: flex;\n align-items: center;\n- gap: var(--sp-1);\n+ gap: 7px;\n overflow: hidden;\n }\n \n+/* 上一级:可以点回去。它比当前那一段淡一档,两段因此分得开。 */\n+.topbar-loc-up {\n+ flex: 0 0 aut","blob_url":"https://github.com/renrenmimi/DrillLab/blob/3bb5b03a63957850ed07d3a399b7696e342c061b/styles%2Fshell.css"}]},"e5d430d4012c2fea6bf011a5c481d8ff53534b38":{"sha":"e5d430d4012c2fea6bf011a5c481d8ff53534b38","stats":{"additions":115,"deletions":3,"total":118},"files":[{"filename":"CLAUDE.md","previous_filename":null,"status":"modified","additions":45,"deletions":0,"changes":45,"patch":"@@ -524,6 +524,51 @@ AgentLab):它们的首页就是章节表,从来不问你打算学多久。\n 第一个 `a` 是品牌,而它在那个宽度是 `display: none`,对它 `focus()` 什么都不会\n 发生,焦点留在 body 上。\n \n+### 【重要】抽屉打开时必须锁住背后那一页\n+\n+用户报的:手机上打开侧栏之后,**后面的页面照样能滚**,实测背景移了 88px;\n+关掉抽屉回到原处,位置对不上,看着像跳了一下。\n+蒙层(`.drawer-scrim`)只拦得住点击,拦不住滚动。\n+\n+锁法是把 `body` 变成 `position: fixed` 并把 `top` 设成负的滚动量,\n+**不是 `overflow: hidden`** —— 后者在 iOS Safari 上对 body 不生效,\n+而那正是这个站最可能被打开的地方。\n+\n+四件配套的事,缺一个就出新毛病:\n+\n+1. **解锁后","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e5d430d4012c2fea6bf011a5c481d8ff53534b38/CLAUDE.md"},{"filename":"components/app-shell.tsx","previous_filename":null,"status":"modified","additions":70,"deletions":3,"changes":73,"patch":"@@ -84,10 +84,75 @@ export function AppShell({ children }: { children: ReactNode }) {\n return () => mq.removeEventListener(\"change\", sync);\n }, []);\n \n+ /* 关抽屉时要不要把页面滚回原来的位置。\n+ 用户自己关(Esc / 蒙层 / 汉堡)→ 要,他还在这一页;\n+ 点导航走掉 → 不要,新页面本来就该从顶上开始。 */\n+ const restoreScroll = useRef(true);\n+ const leaveForNav = () => {\n+ restoreScroll.current = false;\n+ setDrawer(false);\n+ };\n+\n useEffect(() => {\n+ // ","blob_url":"https://github.com/renrenmimi/DrillLab/blob/e5d430d4012c2fea6bf011a5c481d8ff53534b38/components%2Fapp-shell.tsx"}]}},"tree":{"6cc6ba658e798d9c34c960060c43d0a6c13e01d6":{"sha":"6cc6ba658e798d9c34c960060c43d0a6c13e01d6","truncated":false,"tree":[{"path":".gitignore","mode":"100644","type":"blob","sha":"4d253c1c30541d2ca63d572775d7f576d346fc4a","size":117}]},"32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796":{"sha":"32e8d31b4c6c1538c5f6d7bf0022b69d55bbd796","truncated":false,"tree":[{"path":".eslintrc.json","mode":"100644","type":"blob","sha":"bffb357a7122523ec94045523758c4b825b448ef","size":40},{"path":".gitignore","mode":"100644","type":"blob","sha":"4d253c1c30541d2ca63d572775d7f576d346fc4a","size":117},{"path":".nvmrc","mode":"100644","type":"blob","sha":"2bd5a0a98a36cc08ada88b804d3be047e6aa5b8a","size":3},{"path":"content","mode":"040000","type":"tree","sha":"f5d98606ed0b262662211eda06f01f923429e2b5"},{"path":"content/arena.ts","mode":"100644","type":"blob","sha":"31e16208a16cf1fa83e349d1e4f133b04af1b7ee","size":7474},{"path":"content/coding.ts","mode":"100644","type":"blob","sha":"74878949af4c31f71e7b912a156c43914d0aac41","size":182584},{"path":"content/drills.ts","mode":"100644","type":"blob","sha":"758b6a7d6c521d52879757b7184d50e88e5ec264","size":7239},{"path":"content/exam-list.ts","mode":"100644","type":"blob","sha":"3c6974cb7d718931bc50120ff16529dec0094d52","size":1064},{"path":"content/exams","mode":"040000","type":"tree","sha":"528ebba3ba17b00ba9031024d2dea9415b519855"},{"path":"content/exams/cab-booking.tsx","mode":"100644","type":"blob","sha":"3e19bddd9f7fcd3fcde5ab25a4d3b430578510ab","size":260041},{"path":"content/exams/fed-part1.tsx","mode":"100644","type":"blob","sha":"cb835767febbf87f98192d94dabdba4d964b1ba6","size":183634},{"path":"content/exams/fed-part2.tsx","mode":"100644","type":"blob","sha":"ae0d7852225280d7ba21b7ccb8d036c89296e7db","size":157874},{"path":"content/exams/fed-part3.tsx","mode":"100644","type":"blob","sha":"3e22091b8e65ba71021f7803cff5adc096b9eb82","size":143177},{"path":"content/exams/fed-part4.tsx","mode":"100644","type":"blob","sha":"176d4ca21264405687f3ac0426c4c35ee995342c","size":79271},{"path":"content/exams/foundations.tsx","mode":"100644","type":"blob","sha":"392d9343ce607b7439572429909adb0db8884349","size":191015},{"path":"content/exams/graphql-federation.tsx","mode":"100644","type":"blob","sha":"d885fe3775399801d4c61134397b1f7013bdebea","size":55462},{"path":"content/exams/interview.tsx","mode":"100644","type":"blob","sha":"d4ff86c3100cb8b382d6e03e2de1dadf049ac403","size":4138},{"path":"content/exams/iv-backend.tsx","mode":"100644","type":"blob","sha":"29d92b47c2495c90870ae6db7e7e2027328359da","size":107935},{"path":"content/exams/iv-basics.tsx","mode":"100644","type":"blob","sha":"aacbd5834d6ce4077b2263427efa9de5c0b6d920","size":71925},{"path":"content/exams/iv-coding.tsx","mode":"100644","type":"blob","sha":"970e50917900df092411ba9f2511be31fde4ccf7","size":146706},{"path":"content/exams/iv-hand.tsx","mode":"100644","type":"blob","sha":"89476db17fbc37df1870e74eb703445d0c6e23ee","size":63628},{"path":"content/exams/iv-js1.tsx","mode":"100644","type":"blob","sha":"36040b44eb07cad9a685f44dba9c0f3533e80b8c","size":124673},{"path":"content/exams/iv-js2.tsx","mode":"100644","type":"blob","sha":"f9dc24adfc52d508f9a98bd3e47b769666ed74e5","size":111221},{"path":"content/exams/iv-react1.tsx","mode":"100644","type":"blob","sha":"9429e45d9c1541f1c20213b8dbfa9899d8412603","size":109410},{"path":"content/exams/iv-react2.tsx","mode":"100644","type":"blob","sha":"dca4060a64676651353161440afe3c389417b0ee","size":123143},{"path":"content/exams/iv-ts.tsx","mode":"100644","type":"blob","sha":"a413f3469f3710c09cde5f999a378af9a9bfadb2","size":51730},{"path":"content/exams/react-part1.tsx","mode":"100644","type":"blob","sha":"ce009db43967289bbe64eff31f2ebe083c729788","size":59081},{"path":"content/exams/react-part2.tsx","mode":"100644","type":"blob","sha":"9588abc69691afaf73867c37737a6fd5f7e984de","size":79932},{"path":"content/exams/react-part3.tsx","mode":"100644","type":"blob","sha":"4c2769116f6334020e1b752d5f0c9af101b97388","size":147442},{"path":"content/exams/react-part4.tsx","mode":"100644","type":"blob","sha":"72801b71e3a6b4d8231d764d324fd26d2789e37c","size":114165},{"path":"content/exams/react-part5.tsx","mode":"100644","type":"blob","sha":"898f35657cf432713f2da9d4f39e04e93b1be967","size":206106},{"path":"content/exams/react.tsx","mode":"100644","type":"blob","sha":"f89477301e5ac0a73e5399d2481d8e9d8970f3e0","size":35829},{"path":"content/helpers.tsx","mode":"100644","type":"blob","sha":"2d244f847138450ed4297c9d32ec921a33b0ab24","size":1655},{"path":"content/nav.ts","mode":"100644","type":"blob","sha":"11a4a5101491d42de5a651eba1dd38d742d19668","size":97547},{"path":"content/registry.ts","mode":"100644","type":"blob","sha":"454dce5185a0a4303d9e5350fe2700352b0313e5","size":4491},{"path":"content/search-index.ts","mode":"100644","type":"blob","sha":"b1d3cda97cb7656e591874769fdbc8720094a475","size":183712},{"path":"content/source-files.ts","mode":"100644","type":"blob","sha":"4492249ef84cb4697437e5083b3badac1d1dadee","size":76085},{"path":"content/types.ts","mode":"100644","type":"blob","sha":"51bc3452e8961ef5e86221a202bba7d17dd4bef6","size":15129},{"path":"lib","mode":"040000","type":"tree","sha":"ac14c0c564139615a2ee314bd24e6105b15daa20"},{"path":"lib/exercise-labels.ts","mode":"100644","type":"blob","sha":"2a3152718069068ed6c9072ce9aec10857e22bfc","size":1854},{"path":"lib/highlight.ts","mode":"100644","type":"blob","sha":"d9fd4d14f3bd6769f22b9e44d5cf5ec936f5c594","size":11658},{"path":"lib/locale.tsx","mode":"100644","type":"blob","sha":"1ca10c7bb55a3863d6ba07bc26645529b900c0a0","size":3688},{"path":"lib/progress.tsx","mode":"100644","type":"blob","sha":"a0493261cb62b1578a13ca3caffa578de7ef39b2","size":14171},{"path":"lib/theme.tsx","mode":"100644","type":"blob","sha":"1a4291da9201889adee2d0239757bf28506ab0ba","size":2239},{"path":"lib/use-active-heading.ts","mode":"100644","type":"blob","sha":"467cbbcad2610183b6c3ce3e4f46cee0c86596cd","size":1870},{"path":"next.config.mjs","mode":"100644","type":"blob","sha":"2503b934cb5608348a04c2f72db76cb881b882ee","size":465},{"path":"package-lock.json","mode":"100644","type":"blob","sha":"5c1915c310f14dd83789441d41f49365046b4584","size":217794},{"path":"package.json","mode":"100644","type":"blob","sha":"e2b49a9518926b3858618771806e7ab565e8a151","size":683},{"path":"scripts","mode":"040000","type":"tree","sha":"466fab2d73b3a7bc172be3860c9b7813f02646a1"},{"path":"scripts/gen-nav.mjs","mode":"100644","type":"blob","sha":"2e873b4aecdc0c79284533aeec698b38b8976199","size":2819},{"path":"scripts/gen-source-files.mjs","mode":"100644","type":"blob","sha":"99c9beba88d5162350d08ea3a00ed28948390574","size":6332},{"path":"styles","mode":"040000","type":"tree","sha":"7df28ea6bf5b753d3c92f865b6863b488aea8e3d"},{"path":"styles/arena.css","mode":"100644","type":"blob","sha":"0e3d9ed3fd5c1730e07f2661d353f92c47270e53","size":9127},{"path":"styles/base.css","mode":"100644","type":"blob","sha":"36dd46948fbbfafa9b16a55f294a76c38f02e8d1","size":6707},{"path":"styles/code.css","mode":"100644","type":"blob","sha":"873df4d484eed1b59cbba9bcf26c1cf661e783df","size":9513},{"path":"styles/coding.css","mode":"100644","type":"blob","sha":"50f9e0bbf34b3051f53a93e76566d298c253d320","size":7411},{"path":"styles/drill.css","mode":"100644","type":"blob","sha":"d81598a513456fd4d491f52227d3caa5e516278d","size":15464},{"path":"styles/exercise.css","mode":"100644","type":"blob","sha":"48a67216b754aed5932e3007e12d218e8b3b64ed","size":17190},{"path":"styles/lesson.css","mode":"100644","type":"blob","sha":"aa0b67439e9a2fd6b03b0b5f1879edd6954adf8a","size":24457},{"path":"styles/shell.css","mode":"100644","type":"blob","sha":"655060f9fa313c84109d5e202aa872cf59440167","size":34393},{"path":"styles/tokens.css","mode":"100644","type":"blob","sha":"0cae4331904455cc8fba907a1d3bc10daf80d68d","size":5084},{"path":"styles/viz.css","mode":"100644","type":"blob","sha":"fde8b27b399c4e67744e9615976cd96492512bba","size":4464},{"path":"tsconfig.json","mode":"100644","type":"blob","sha":"bde17a6e0e047489b711befe4660189f63127d9d","size":563}]},"09b11bdaf58e8494af9015c85796d1e1444e5dd4":{"sha":"09b11bdaf58e8494af9015c85796d1e1444e5dd4","truncated":false,"tree":[{"path":".claude","mode":"040000","type":"tree","sha":"e293a97eb2671b24ec0225f353b444aadf6d6dc3"},{"path":".claude/launch.json","mode":"100644","type":"blob","sha":"29fafb92b7eb278bcef2f4af17c23905bff78290","size":201},{"path":".eslintrc.json","mode":"100644","type":"blob","sha":"bffb357a7122523ec94045523758c4b825b448ef","size":40},{"path":".gitignore","mode":"100644","type":"blob","sha":"4d253c1c30541d2ca63d572775d7f576d346fc4a","size":117},{"path":".nvmrc","mode":"100644","type":"blob","sha":"2bd5a0a98a36cc08ada88b804d3be047e6aa5b8a","size":3},{"path":"CLAUDE.md","mode":"100644","type":"blob","sha":"1edbe27c33ba97e5efee52f3860400eff3af7f81","size":46597},{"path":"README.md","mode":"100644","type":"blob","sha":"c6faa77aac1d2615e63c2800212197f833b8f4f9","size":6429},{"path":"app","mode":"040000","type":"tree","sha":"ac506948c3467c4a0f411c88eaf4c4f407beef07"},{"path":"app/arena","mode":"040000","type":"tree","sha":"e322690f7ede363a785510ff7df4fe2c964dec4a"},{"path":"app/arena/[id]","mode":"040000","type":"tree","sha":"6e85d2ba7ee0e8e1d67c27ebed8f3a76de44095c"},{"path":"app/arena/[id]/page.tsx","mode":"100644","type":"blob","sha":"d99874a87634b64cd38159a37b0b3ddf9237a03b","size":931},{"path":"app/arena/[id]/review","mode":"040000","type":"tree","sha":"f62641f1a7817943173849c9c5bd36b832e2fc28"},{"path":"app/arena/[id]/review/page.tsx","mode":"100644","type":"blob","sha":"6eb09da17a77b9541d2604a3f9c6736f98b81cb6","size":810},{"path":"app/arena/[id]/run","mode":"040000","type":"tree","sha":"be528ba0422d25c139924a8f67b4668ce1bb3c58"},{"path":"app/arena/[id]/run/page.tsx","mode":"100644","type":"blob","sha":"220f8705cbde373bb76968d58ea2a5c147bb0672","size":931},{"path":"app/arena/page.tsx","mode":"100644","type":"blob","sha":"3726ee4e68ba7044223590df8f146b2d875a9814","size":306},{"path":"app/code","mode":"040000","type":"tree","sha":"64616c1c87df7f194346871cef35857773bba123"},{"path":"app/code/[id]","mode":"040000","type":"tree","sha":"83e818f795d8b16d2dafc4b8db824f38a83eef35"},{"path":"app/code/[id]/page.tsx","mode":"100644","type":"blob","sha":"7d95e3bcdc2b73df504c3fc4f512f68d48bdd04b","size":909},{"path":"app/code/page.tsx","mode":"100644","type":"blob","sha":"03a6a019481b97156cec15af5ee6985daad937bf","size":519},{"path":"app/drill","mode":"040000","type":"tree","sha":"b190f0e0772286650a353e7dff18981f3a3b33f7"},{"path":"app/drill/[id]","mode":"040000","type":"tree","sha":"a8dfc8878139b8c4bc4c75a4b9b096880387ffd5"},{"path":"app/drill/[id]/page.tsx","mode":"100644","type":"blob","sha":"694085e52f92b9a8f69d75f868e51d322f1603ee","size":915},{"path":"app/drill/page.tsx","mode":"100644","type":"blob","sha":"bd4a6bad2e9229e4607901aafee9f01715be9ac1","size":510},{"path":"app/drill/session","mode":"040000","type":"tree","sha":"36830cce8e31084548acc9e2f6a3f439e6a013a8"},{"path":"app/drill/session/page.tsx","mode":"100644","type":"blob","sha":"0d0e94bdf1f667a00b6d5826b4a8a5f9be026947","size":751},{"path":"app/exams","mode":"040000","type":"tree","sha":"9596d1ec65ec8ac869ad78ad4b26c9c6cedb34fc"},{"path":"app/exams/[examId]","mode":"040000","type":"tree","sha":"0433bae579828a2a71f2ffa3dd2aaad5d9ae1a7f"},{"path":"app/exams/[examId]/[lessonId]","mode":"040000","type":"tree","sha":"94ea8cac4d37283491e2269dd86b9447197e8b3f"},{"path":"app/exams/[examId]/[lessonId]/page.tsx","mode":"100644","type":"blob","sha":"a2a626e923d3234c7ac525bf212803e6044fe153","size":1083},{"path":"app/exams/[examId]/page.tsx","mode":"100644","type":"blob","sha":"ebe2de7871be642c7c7bf7fdc19a8391a8aa9939","size":838},{"path":"app/globals.css","mode":"100644","type":"blob","sha":"9f363104d50a6b0ef630fe8ecddd55732a5cd66a","size":517},{"path":"app/guide","mode":"040000","type":"tree","sha":"721cd53c1cda9c873a08feb5b99e82c6b983817b"},{"path":"app/guide/page.tsx","mode":"100644","type":"blob","sha":"7fd27409955c6fb1a67fe00ad2d4fc30abc9c27d","size":328},{"path":"app/icon.svg","mode":"100644","type":"blob","sha":"d18166689a1c996f735922e571c4269fa88e483a","size":690},{"path":"app/layout.tsx","mode":"100644","type":"blob","sha":"be4ba95387d98193f2da1069c2602eff6dd6ac88","size":2239},{"path":"app/mock","mode":"040000","type":"tree","sha":"93ae49f9781456b350168708cc3f7c567f82ecc3"},{"path":"app/mock/[examId]","mode":"040000","type":"tree","sha":"022f19e0f50ae0a6415a9bcec01e778ce84c9438"},{"path":"app/mock/[examId]/[mockId]","mode":"040000","type":"tree","sha":"3069a9103981b988339bc5d89c2625c8117b3fff"},{"path":"app/mock/[examId]/[mockId]/page.tsx","mode":"100644","type":"blob","sha":"94aa8919a4513049b3ad631a89c65b0d5eb15657","size":910},{"path":"app/mock/page.tsx","mode":"100644","type":"blob","sha":"5c26769b4a75252f27d6cb50c96338d0f2a103e5","size":255},{"path":"app/page.tsx","mode":"100644","type":"blob","sha":"82ca338f0f5922a26c457e454d54b74de951e5be","size":97},{"path":"app/path","mode":"040000","type":"tree","sha":"84b1d4e3e3d491778946b92713667ecbe0d1b87f"},{"path":"app/path/page.tsx","mode":"100644","type":"blob","sha":"0d55b46e496df9fedf7e06ac8cb33cf9c0f1582b","size":273},{"path":"app/practice","mode":"040000","type":"tree","sha":"f8d22a390854aaf61cb6bd64257c21d9525b6763"},{"path":"app/practice/page.tsx","mode":"100644","type":"blob","sha":"80044a93e3cd6f29e93e76b713bc3fe2f3f5020a","size":395},{"path":"app/reference","mode":"040000","type":"tree","sha":"b39ed181aed22a481b06bf8de3e242f5639f0bae"},{"path":"app/reference/page.tsx","mode":"100644","type":"blob","sha":"3f2cf4381229583f2a190e737f84a3ddf2e624af","size":317},{"path":"components","mode":"040000","type":"tree","sha":"101408faf0a9166badf3238ccae66552a1773114"},{"path":"components/app-shell.tsx","mode":"100644","type":"blob","sha":"c589bc27441d24d0d2a3a08e9c30cd85d9bf55b4","size":16642},{"path":"components/arena-bits.tsx","mode":"100644","type":"blob","sha":"d3f434d3b74f70a0afc6fa85236769a36380dd4e","size":9278},{"path":"components/arena-brief.tsx","mode":"100644","type":"blob","sha":"203da5de93eba9f84dd6c6da03846b50e4696f4c","size":6269},{"path":"components/arena-checkoff.tsx","mode":"100644","type":"blob","sha":"dce5998d6835293696100bc0c214ceeb0a197287","size":12809},{"path":"components/arena-clock.tsx","mode":"100644","type":"blob","sha":"22ce51a328c0c2b41736d2459532e91b30a18215","size":8545},{"path":"components/arena-list.tsx","mode":"100644","type":"blob","sha":"a80cdf6219b6ef914cfdfe02f72a7cb4cd723d54","size":9509},{"path":"components/arena-review.tsx","mode":"100644","type":"blob","sha":"189ea5f0674dc83c3fbfd5b7813bbdf15f1f4e7a","size":6361},{"path":"components/arena-run.tsx","mode":"100644","type":"blob","sha":"8170975901c8b1a7424242c17584bc5b5cc42062","size":7068},{"path":"components/arena-start.tsx","mode":"100644","type":"blob","sha":"f58f0d3de645208d681c2541a0126666ac661837","size":6401},{"path":"components/code.tsx","mode":"100644","type":"blob","sha":"4ad807e5d785946eb5c47dbc8800554830e35b9d","size":13256},{"path":"components/coding-detail.tsx","mode":"100644","type":"blob","sha":"23788f72b6a0a2c30ceaa6e0c0c03c972496ae4a","size":16491},{"path":"components/coding-list.tsx","mode":"100644","type":"blob","sha":"b668dc7304425b2889722dfd84aa85d84bf955ad","size":12576},{"path":"components/coding-progress.tsx","mode":"100644","type":"blob","sha":"8125254c9110174737d59e641673f3b8811a3d57","size":3033},{"path":"components/coding-sandpack.tsx","mode":"100644","type":"blob","sha":"8c49b0988468ab3c141d455809efbfa221d52180","size":4846},{"path":"components/coding-workspace.tsx","mode":"100644","type":"blob","sha":"de4a50a7147ca70a4527f48b1698e2a81d3548f5","size":2260},{"path":"components/continue.tsx","mode":"100644","type":"blob","sha":"e7f48929315526c89bce6b2d6d2b61d66455ca8e","size":10653},{"path":"components/data-flow.tsx","mode":"100644","type":"blob","sha":"28d3b52f985fbcfc4ca14cebff04d8f856006fac","size":4579},{"path":"components/drill-answer.tsx","mode":"100644","type":"blob","sha":"1e34833e3116adf562975544fde4567910642653","size":1197},{"path":"components/drill-card.tsx","mode":"100644","type":"blob","sha":"205fed4e02a06fd8a2f0e4d3be65dff5ae9814bd","size":8595},{"path":"components/drill-detail.tsx","mode":"100644","type":"blob","sha":"1cb0b1f68323a0925847287f70dfd01653c4a160","size":4510},{"path":"components/drill-list.tsx","mode":"100644","type":"blob","sha":"bcb3cd38fed607deaa589e11453aea974fc68057","size":14909},{"path":"components/drill-marks.tsx","mode":"100644","type":"blob","sha":"26ab4c96d59006d9fc7b499a6d9a6100a142c775","size":4960},{"path":"components/drill-query.tsx","mode":"100644","type":"blob","sha":"775323df214bb8d80343be8a1f477b5a22ace835","size":3076},{"path":"components/drill-session-page.tsx","mode":"100644","type":"blob","sha":"1743c31bcd824db7a0c1a9914321cd44a862ee8f","size":2396},{"path":"components/drill-session.tsx","mode":"100644","type":"blob","sha":"626ad41db1e28919380999a4aa6a04e2403dfb8d","size":27114},{"path":"components/exam-overview.tsx","mode":"100644","type":"blob","sha":"ac3875de8991e9122442f95ef2d1fc7e6cc65465","size":12605},{"path":"components/exercise.tsx","mode":"100644","type":"blob","sha":"e320ec56bfcc015cf8a1ebac56948e55b4d7b41d","size":34938},{"path":"components/guide.tsx","mode":"100644","type":"blob","sha":"8267fffcc9515953b6c4e9fd716dc9c95acd951e","size":21252},{"path":"components/hint-panel.tsx","mode":"100644","type":"blob","sha":"6e21729f21ad8bca458b652df932310632bda218","size":4348},{"path":"components/home.tsx","mode":"100644","type":"blob","sha":"0d311ae9c345363ebc49970ad6a3837078684d24","size":17363},{"path":"components/ladder.tsx","mode":"100644","type":"blob","sha":"5a6cf79b8327a894e5fdc093deaacf1686d3bcac","size":7178},{"path":"components/learning-path.tsx","mode":"100644","type":"blob","sha":"b27912bd39ac2b9f2db65bd1e98d5816824e70f3","size":11182},{"path":"components/lesson-body.tsx","mode":"100644","type":"blob","sha":"4cbfd006fc14b6d74d45a3043c4fc0133f5488fd","size":13080},{"path":"components/lesson-islands.tsx","mode":"100644","type":"blob","sha":"9c6f74ff6ab57aacae71f7b7f5a4efeb205cb27d","size":3954},{"path":"components/lesson-kit.tsx","mode":"100644","type":"blob","sha":"bb8c3470c1fb9d0f27fe63c6baea5a75d8a17093","size":23182},{"path":"components/local-setup.tsx","mode":"100644","type":"blob","sha":"b1487f796e9ca11c09641edf992d7e103ace6e81","size":11097},{"path":"components/mock-detail.tsx","mode":"100644","type":"blob","sha":"fb47d84f4ba69863497f1d86eb16b0e21c13a0cf","size":9320},{"path":"components/mock-score.tsx","mode":"100644","type":"blob","sha":"bd458bc610d638b9baf968487525e353d771744f","size":2190},{"path":"components/mock-workspace.tsx","mode":"100644","type":"blob","sha":"4bbf61c5b4ac68050ca82fcb00a18c988ab37a1d","size":7275},{"path":"components/practice-focus.tsx","mode":"100644","type":"blob","sha":"404cb7099f46edb9fdbfe0aae38b81bcad435eed","size":2764},{"path":"components/practice-page.tsx","mode":"100644","type":"blob","sha":"8bb054388177a78892f6088c0cfd0ebaf9b5e5a2","size":13715},{"path":"components/practice-progress.tsx","mode":"100644","type":"blob","sha":"67c52927535a752d0df10ad1316e7aa209c6495f","size":1948},{"path":"components/recent.tsx","mode":"100644","type":"blob","sha":"d5e0387f467bca5cb82cda9ca49d895acf9846a1","size":2261},{"path":"components/reference.tsx","mode":"100644","type":"blob","sha":"d520c4d76bbef98b8636b1036fbf43bcadc99bcd","size":69511},{"path":"components/sandbox.tsx","mode":"100644","type":"blob","sha":"34a23b72527a26c652b7bd35369b32ab702ba26c","size":7581},{"path":"components/search.tsx","mode":"100644","type":"blob","sha":"903b39f718efc2b97d9a94f72b0571d4b08f8cba","size":15304},{"path":"components/sidebars.tsx","mode":"100644","type":"blob","sha":"94a0f21df58c8ff981d22d76957ef7bf472e318a","size":36773},{"path":"components/t.tsx","mode":"100644","type":"blob","sha":"2a205830fc802c49914a4683d8ef4cc5e13a4cb0","size":2626},{"path":"content","mode":"040000","type":"tree","sha":"eaf8c77842acafdd1d10279edcfd9490ecf84a92"},{"path":"content/arena.ts","mode":"100644","type":"blob","sha":"466a7062ddd44b536fce1430ade17435969ffb05","size":10230},{"path":"content/coding.ts","mode":"100644","type":"blob","sha":"9d9191f2ee497ec28eaea82ba84baa0a586f7d92","size":196390},{"path":"content/drills.ts","mode":"100644","type":"blob","sha":"758b6a7d6c521d52879757b7184d50e88e5ec264","size":7239},{"path":"content/exam-list.ts","mode":"100644","type":"blob","sha":"3c6974cb7d718931bc50120ff16529dec0094d52","size":1064},{"path":"content/exams","mode":"040000","type":"tree","sha":"7ff9985bd845b3c76e24394426c1fcb3f93dae18"},{"path":"content/exams/cab-booking.tsx","mode":"100644","type":"blob","sha":"34f872ee045a3c925524cc1dd268ab56266cd376","size":351886},{"path":"content/exams/fed-part1.tsx","mode":"100644","type":"blob","sha":"8629017946d945c02776c742bf82f641eccd6186","size":244343},{"path":"content/exams/fed-part2.tsx","mode":"100644","type":"blob","sha":"e1790f202911a81477a36c9559c9892b8bec1d22","size":222804},{"path":"content/exams/fed-part3.tsx","mode":"100644","type":"blob","sha":"9e2bb353c83a9973744dc6bb10cb111bf4a91ec3","size":199481},{"path":"content/exams/fed-part4.tsx","mode":"100644","type":"blob","sha":"143af3781c0a06cb5eb816eebec7f005d28f27d3","size":128242},{"path":"content/exams/foundations.tsx","mode":"100644","type":"blob","sha":"d3166537308ef21f5df241e22ba4ee1c9c8dd570","size":259162},{"path":"content/exams/graphql-federation.tsx","mode":"100644","type":"blob","sha":"75ca0dae45e8137c58df1c840578af96673c9cb0","size":66746},{"path":"content/exams/interview.tsx","mode":"100644","type":"blob","sha":"9df22e1ed5da38190439373afb64dcebe19f36a0","size":5173},{"path":"content/exams/iv-backend.tsx","mode":"100644","type":"blob","sha":"8c2fff4c5c4ebc53a7482f7c95f68dc782651482","size":119953},{"path":"content/exams/iv-basics.tsx","mode":"100644","type":"blob","sha":"06c232f2d1d1c87ff83ad49760d023a057d95c9c","size":81967},{"path":"content/exams/iv-coding.tsx","mode":"100644","type":"blob","sha":"e6bfd7f7718d59ced64adbc5f94ecb56f2386105","size":222058},{"path":"content/exams/iv-hand.tsx","mode":"100644","type":"blob","sha":"9613af573f7ba1503c11cb77e69681b23ba3c183","size":98644},{"path":"content/exams/iv-js1.tsx","mode":"100644","type":"blob","sha":"3e82e8a1a5fbeedba5d1b6d277a97134960c71a4","size":145907},{"path":"content/exams/iv-js2.tsx","mode":"100644","type":"blob","sha":"39066ff487978493b19166156056b9edc013660a","size":130568},{"path":"content/exams/iv-react1.tsx","mode":"100644","type":"blob","sha":"2983ab2596721e98fe90329c3c881d6cc9a7eefb","size":120761},{"path":"content/exams/iv-react2.tsx","mode":"100644","type":"blob","sha":"e267c3d99dd55e21294a30fe033eb87d17b5daf8","size":139229},{"path":"content/exams/iv-ts.tsx","mode":"100644","type":"blob","sha":"074d26eef11494f5cfe89b166502956ac2e5567c","size":67987},{"path":"content/exams/react-part1.tsx","mode":"100644","type":"blob","sha":"b2f20d557d7a8ed996e1a83f37d1049b7502057d","size":81962},{"path":"content/exams/react-part2.tsx","mode":"100644","type":"blob","sha":"c3ca2b64b7fc6f9cf8697c499d8b6c0eab587eab","size":111237},{"path":"content/exams/react-part3.tsx","mode":"100644","type":"blob","sha":"3375fee57d2d49f1774663984d0ac09f5b08de67","size":201664},{"path":"content/exams/react-part4.tsx","mode":"100644","type":"blob","sha":"dcd031c5096f1bbf54c3a9e455682bbff5230b5f","size":165084},{"path":"content/exams/react-part5.tsx","mode":"100644","type":"blob","sha":"8f87e83946c40f15fb0876dc11bf7cc975a41984","size":326857},{"path":"content/exams/react.tsx","mode":"100644","type":"blob","sha":"6b2db4a348af1ba278f36a3e9ecc41ccc25c0e4b","size":47632},{"path":"content/helpers.tsx","mode":"100644","type":"blob","sha":"a78dae0313fedee9934014d4037e1d6d8aff98fd","size":2093},{"path":"content/nav-exercises.ts","mode":"100644","type":"blob","sha":"d1b954807175d37b67bad0a30d188e7c1d46b877","size":38891},{"path":"content/nav.ts","mode":"100644","type":"blob","sha":"621df54b4d7184b17cc20325a10d4e867c885a82","size":134329},{"path":"content/path.ts","mode":"100644","type":"blob","sha":"19e10261e9bd0feba99e99aaae66c4f73918aab8","size":6146},{"path":"content/plans-assert.ts","mode":"100644","type":"blob","sha":"e6e4eb50e04e435e63a33f70f13bb1e3cb34c383","size":3205},{"path":"content/plans.ts","mode":"100644","type":"blob","sha":"4e709a72a8168d8cac651b84a9f9506fe1802878","size":46386},{"path":"content/registry.ts","mode":"100644","type":"blob","sha":"454dce5185a0a4303d9e5350fe2700352b0313e5","size":4491},{"path":"content/search-index.ts","mode":"100644","type":"blob","sha":"b1d3cda97cb7656e591874769fdbc8720094a475","size":183712},{"path":"content/source-files.ts","mode":"100644","type":"blob","sha":"4492249ef84cb4697437e5083b3badac1d1dadee","size":76085},{"path":"content/types.ts","mode":"100644","type":"blob","sha":"7f3693e678e30dcb1b13fa46d452b7324b9d7feb","size":20601},{"path":"docs","mode":"040000","type":"tree","sha":"d290fcf836e9b8868ba7253cf2745ee7e58c7ed0"},{"path":"docs/drill.jpg","mode":"100644","type":"blob","sha":"1cb1e8e8061c2bd326ecb601ab9ac640e2946f02","size":96959},{"path":"docs/home.jpg","mode":"100644","type":"blob","sha":"655ea5a09b7d45ab75d33846979428c4ce6749a9","size":68836},{"path":"docs/ia-audit-round3.md","mode":"100644","type":"blob","sha":"5fdba6b17ff04c22b4777769b484cd3922823ff2","size":13383},{"path":"docs/ia-nav","mode":"040000","type":"tree","sha":"34ba5cdc31c904dfd3ba0126c02ce61a3ef42296"},{"path":"docs/ia-nav/assess-after.jpg","mode":"100644","type":"blob","sha":"449f1e625cff37b57d6984c5d2e8ff1b9928e4c9","size":134931},{"path":"docs/ia-nav/assess-before.jpg","mode":"100644","type":"blob","sha":"a71470575e00d0feafeef587cc92a014405fb3e5","size":127631},{"path":"docs/ia-nav/home-after.jpg","mode":"100644","type":"blob","sha":"3d91e9056d9425318551000ed0941d849a0689d0","size":66514},{"path":"docs/ia-nav/home-before.jpg","mode":"100644","type":"blob","sha":"c615c84c0100b1db538d3fa48e60dcfb573458d4","size":96613},{"path":"docs/ia-nav/home-dark-after.jpg","mode":"100644","type":"blob","sha":"02f6ea22497f567f6b9435f2c47fd0e0d06fde1b","size":66404},{"path":"docs/ia-nav/home-dark-before.jpg","mode":"100644","type":"blob","sha":"0e67279e70a5ffa172ad5257f310fe65d831d0ab","size":95416},{"path":"docs/ia-nav/lesson-after.jpg","mode":"100644","type":"blob","sha":"65184ac178efbb0c699b81b19c59393ebe738a8b","size":149021},{"path":"docs/ia-nav/lesson-before.jpg","mode":"100644","type":"blob","sha":"5a690af807b8b7d0b4b6f670142fdcd0bd981f27","size":150616},{"path":"docs/ia-nav/lesson-foot-after.jpg","mode":"100644","type":"blob","sha":"c3971ae05ecbb79776dc001550e1db78ae21c79d","size":133914},{"path":"docs/ia-nav/lesson-foot-before.jpg","mode":"100644","type":"blob","sha":"b49bc0c9190158bb02ff824e028b24aa5663b519","size":127257},{"path":"docs/ia-nav/mobile-drawer-after.jpg","mode":"100644","type":"blob","sha":"cfca93e17ef0af4198e3afcacb1669de1f4dbc77","size":107266},{"path":"docs/ia-nav/mobile-drawer-before.jpg","mode":"100644","type":"blob","sha":"0a3ce7e27725cf6d6198480c0cb76f4bd0aebbe0","size":104327},{"path":"docs/ia-nav/mobile-home-after.jpg","mode":"100644","type":"blob","sha":"5be7b4a40b99901331ede38d4f580180232c1fff","size":81281},{"path":"docs/ia-nav/mobile-home-before.jpg","mode":"100644","type":"blob","sha":"212ea175eefe1114ee818b9c0753ff20e85e0637","size":108708},{"path":"docs/ia-nav/mobile-lesson-after.jpg","mode":"100644","type":"blob","sha":"4689971308cb8d9dad8ed2b0853b65ed604d851d","size":127948},{"path":"docs/ia-nav/mobile-lesson-before.jpg","mode":"100644","type":"blob","sha":"883910dff8134ab12b5913b281ef51f5a78e342b","size":123907},{"path":"docs/ia-nav/path-after.jpg","mode":"100644","type":"blob","sha":"6b2ebbd740be1d63e6146540fbe9339bcb5a72b4","size":124588},{"path":"docs/ia-nav/path-before.jpg","mode":"100644","type":"blob","sha":"0191c37133c73f1d6b3c322d3f37931514aaa52a","size":110623},{"path":"docs/ia-nav/practice-code-after.jpg","mode":"100644","type":"blob","sha":"4442354519520fff93b89ff1ad2459cbd2e10c27","size":118895},{"path":"docs/ia-nav/practice-code-before.jpg","mode":"100644","type":"blob","sha":"d037406dc293d654b95c4ce2206305b03ae92ec4","size":123148},{"path":"docs/ia-nav/review-after.jpg","mode":"100644","type":"blob","sha":"deff0cb61284cc857b662d570e24aa6e5958bb30","size":114676},{"path":"docs/ia-nav/review-before.jpg","mode":"100644","type":"blob","sha":"acd82b66f7ac80df3dcce653985e1f65c9ce10e9","size":117205},{"path":"docs/ia-nav/tablet-lesson-after.jpg","mode":"100644","type":"blob","sha":"a68a81f8ed08b490d48a2ace780dc1c5c08e72de","size":105790},{"path":"docs/ia-nav/tablet-lesson-before.jpg","mode":"100644","type":"blob","sha":"80835d82d035f1410184911726510ef0d54484ec","size":104233},{"path":"docs/intent-nav.md","mode":"100644","type":"blob","sha":"4e8c7dc13e61b53f640b07fecae4cafdf2eacb47","size":8254},{"path":"docs/sandpack-evaluation.md","mode":"100644","type":"blob","sha":"97bd36d5708482727386132c9e43c7617f506f04","size":4116},{"path":"lib","mode":"040000","type":"tree","sha":"77d6ff90edecde6c158c8274f7ebe077b5096eda"},{"path":"lib/coding-labels.ts","mode":"100644","type":"blob","sha":"3734ea5ea1667dda8fe300d7b93ea0c8dc170fbc","size":1341},{"path":"lib/exercise-labels.ts","mode":"100644","type":"blob","sha":"45ff2706a7052dd8ffdbbdabfaf622c67a3e13d7","size":2307},{"path":"lib/highlight.ts","mode":"100644","type":"blob","sha":"d9fd4d14f3bd6769f22b9e44d5cf5ec936f5c594","size":11658},{"path":"lib/list-query.ts","mode":"100644","type":"blob","sha":"032a3c2a9da3ac094f3594454455d375841da3b7","size":2578},{"path":"lib/locale.tsx","mode":"100644","type":"blob","sha":"5c0ddd2fe0a5a2b5625eaa18d0d41c481f2dbf83","size":3976},{"path":"lib/modes.ts","mode":"100644","type":"blob","sha":"d8bae30ecc926d6161e4dd55c95be38567d36300","size":3989},{"path":"lib/progress.tsx","mode":"100644","type":"blob","sha":"4c6c85b243792b2f222d922ec29c3e118ced9950","size":19462},{"path":"lib/theme.tsx","mode":"100644","type":"blob","sha":"1a4291da9201889adee2d0239757bf28506ab0ba","size":2239},{"path":"lib/use-active-heading.ts","mode":"100644","type":"blob","sha":"467cbbcad2610183b6c3ce3e4f46cee0c86596cd","size":1870},{"path":"next.config.mjs","mode":"100644","type":"blob","sha":"2503b934cb5608348a04c2f72db76cb881b882ee","size":465},{"path":"package-lock.json","mode":"100644","type":"blob","sha":"5c1915c310f14dd83789441d41f49365046b4584","size":217794},{"path":"package.json","mode":"100644","type":"blob","sha":"bc604fb7e2dc41989b86a8a1f847810aabd7b783","size":738},{"path":"scripts","mode":"040000","type":"tree","sha":"f634b6c4cfc633f8b990650c4ae0c6875cd3fd43"},{"path":"scripts/audit-code-lines.mjs","mode":"100644","type":"blob","sha":"5ed6c1ea3fdc28f2bcdf126af8cd137aa626edb0","size":17454},{"path":"scripts/dev.sh","mode":"100755","type":"blob","sha":"6b7d5f07bb7eb4a34e8a7ef005de8f655135a87a","size":2099},{"path":"scripts/gen-nav.mjs","mode":"100644","type":"blob","sha":"21c3f48ae9761b4b3398957067473eea6e6d9685","size":3071},{"path":"scripts/gen-source-files.mjs","mode":"100644","type":"blob","sha":"99c9beba88d5162350d08ea3a00ed28948390574","size":6332},{"path":"scripts/nav-dump-route.txt","mode":"100644","type":"blob","sha":"f1fe95fd607f236039cebe160fda674f351dd8bd","size":6459},{"path":"scripts/nav-exercises-template.txt","mode":"100644","type":"blob","sha":"c06d25b5fcc7836bb8e18d5dd580397245949c04","size":1074},{"path":"scripts/nav-template.txt","mode":"100644","type":"blob","sha":"1c69ca23d966a93c6b9e063a4b6830597bcdc880","size":6497},{"path":"scripts/search-index-template.txt","mode":"100644","type":"blob","sha":"8cf23f28cb34ebcb827760f40f66711fe84c529d","size":1645},{"path":"scripts/source-files-template.txt","mode":"100644","type":"blob","sha":"3fa93f80e8dc70ab2155e178f70c5f5e2fd20ef0","size":1699},{"path":"styles","mode":"040000","type":"tree","sha":"df2c1e834156e119eaf671989df4567f6ec1a82d"},{"path":"styles/arena.css","mode":"100644","type":"blob","sha":"0e3d9ed3fd5c1730e07f2661d353f92c47270e53","size":9127},{"path":"styles/base.css","mode":"100644","type":"blob","sha":"36dd46948fbbfafa9b16a55f294a76c38f02e8d1","size":6707},{"path":"styles/code.css","mode":"100644","type":"blob","sha":"873df4d484eed1b59cbba9bcf26c1cf661e783df","size":9513},{"path":"styles/coding.css","mode":"100644","type":"blob","sha":"50f9e0bbf34b3051f53a93e76566d298c253d320","size":7411},{"path":"styles/drill.css","mode":"100644","type":"blob","sha":"fa3f1065f35e9195fcf1b8225ce3117ac88955b0","size":16621},{"path":"styles/exercise.css","mode":"100644","type":"blob","sha":"48a67216b754aed5932e3007e12d218e8b3b64ed","size":17190},{"path":"styles/lesson.css","mode":"100644","type":"blob","sha":"934b53c161d33085c4e102557694c2f753ce19f7","size":22581},{"path":"styles/nav.css","mode":"100644","type":"blob","sha":"6ea71ea9d0187612ca7fce696842bf258768914d","size":28061},{"path":"styles/shell.css","mode":"100644","type":"blob","sha":"9288d0e74576c1de6ebe4a7156604fdfa6a05566","size":22788},{"path":"styles/tokens.css","mode":"100644","type":"blob","sha":"8120dfc41acb0f54e9cf8bdccc21fb6a56ac7073","size":5099},{"path":"styles/viz.css","mode":"100644","type":"blob","sha":"fde8b27b399c4e67744e9615976cd96492512bba","size":4464},{"path":"tsconfig.json","mode":"100644","type":"blob","sha":"bde17a6e0e047489b711befe4660189f63127d9d","size":563}]},"1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76":{"sha":"1f2cad6c80b3e7561f84cf51c5e4cc6d26827f76","truncated":false,"tree":[{"path":".claude","mode":"040000","type":"tree","sha":"e293a97eb2671b24ec0225f353b444aadf6d6dc3"},{"path":".claude/launch.json","mode":"100644","type":"blob","sha":"29fafb92b7eb278bcef2f4af17c23905bff78290","size":201},{"path":".eslintrc.json","mode":"100644","type":"blob","sha":"bffb357a7122523ec94045523758c4b825b448ef","size":40},{"path":".gitignore","mode":"100644","type":"blob","sha":"4d253c1c30541d2ca63d572775d7f576d346fc4a","size":117},{"path":".nvmrc","mode":"100644","type":"blob","sha":"2bd5a0a98a36cc08ada88b804d3be047e6aa5b8a","size":3},{"path":"CLAUDE.md","mode":"100644","type":"blob","sha":"d9b43af6dcfd5761564f306420c240a8e0e3d50e","size":60403},{"path":"README.md","mode":"100644","type":"blob","sha":"c923b750297f692c2aa080ee2195fa68ebf83c61","size":8312},{"path":"app","mode":"040000","type":"tree","sha":"2891c132c48392b44560405dc97ca3d7cfba7ed8"},{"path":"app/arena","mode":"040000","type":"tree","sha":"e322690f7ede363a785510ff7df4fe2c964dec4a"},{"path":"app/arena/[id]","mode":"040000","type":"tree","sha":"6e85d2ba7ee0e8e1d67c27ebed8f3a76de44095c"},{"path":"app/arena/[id]/page.tsx","mode":"100644","type":"blob","sha":"d99874a87634b64cd38159a37b0b3ddf9237a03b","size":931},{"path":"app/arena/[id]/review","mode":"040000","type":"tree","sha":"f62641f1a7817943173849c9c5bd36b832e2fc28"},{"path":"app/arena/[id]/review/page.tsx","mode":"100644","type":"blob","sha":"6eb09da17a77b9541d2604a3f9c6736f98b81cb6","size":810},{"path":"app/arena/[id]/run","mode":"040000","type":"tree","sha":"be528ba0422d25c139924a8f67b4668ce1bb3c58"},{"path":"app/arena/[id]/run/page.tsx","mode":"100644","type":"blob","sha":"220f8705cbde373bb76968d58ea2a5c147bb0672","size":931},{"path":"app/arena/page.tsx","mode":"100644","type":"blob","sha":"3726ee4e68ba7044223590df8f146b2d875a9814","size":306},{"path":"app/code","mode":"040000","type":"tree","sha":"64616c1c87df7f194346871cef35857773bba123"},{"path":"app/code/[id]","mode":"040000","type":"tree","sha":"83e818f795d8b16d2dafc4b8db824f38a83eef35"},{"path":"app/code/[id]/page.tsx","mode":"100644","type":"blob","sha":"7d95e3bcdc2b73df504c3fc4f512f68d48bdd04b","size":909},{"path":"app/code/page.tsx","mode":"100644","type":"blob","sha":"03a6a019481b97156cec15af5ee6985daad937bf","size":519},{"path":"app/drill","mode":"040000","type":"tree","sha":"b190f0e0772286650a353e7dff18981f3a3b33f7"},{"path":"app/drill/[id]","mode":"040000","type":"tree","sha":"a8dfc8878139b8c4bc4c75a4b9b096880387ffd5"},{"path":"app/drill/[id]/page.tsx","mode":"100644","type":"blob","sha":"694085e52f92b9a8f69d75f868e51d322f1603ee","size":915},{"path":"app/drill/page.tsx","mode":"100644","type":"blob","sha":"bd4a6bad2e9229e4607901aafee9f01715be9ac1","size":510},{"path":"app/drill/session","mode":"040000","type":"tree","sha":"36830cce8e31084548acc9e2f6a3f439e6a013a8"},{"path":"app/drill/session/page.tsx","mode":"100644","type":"blob","sha":"0d0e94bdf1f667a00b6d5826b4a8a5f9be026947","size":751},{"path":"app/exams","mode":"040000","type":"tree","sha":"9596d1ec65ec8ac869ad78ad4b26c9c6cedb34fc"},{"path":"app/exams/[examId]","mode":"040000","type":"tree","sha":"0433bae579828a2a71f2ffa3dd2aaad5d9ae1a7f"},{"path":"app/exams/[examId]/[lessonId]","mode":"040000","type":"tree","sha":"94ea8cac4d37283491e2269dd86b9447197e8b3f"},{"path":"app/exams/[examId]/[lessonId]/page.tsx","mode":"100644","type":"blob","sha":"a2a626e923d3234c7ac525bf212803e6044fe153","size":1083},{"path":"app/exams/[examId]/page.tsx","mode":"100644","type":"blob","sha":"ebe2de7871be642c7c7bf7fdc19a8391a8aa9939","size":838},{"path":"app/globals.css","mode":"100644","type":"blob","sha":"3be3ee716de77008719bd3c5ee7865826f820f6e","size":1168},{"path":"app/guide","mode":"040000","type":"tree","sha":"721cd53c1cda9c873a08feb5b99e82c6b983817b"},{"path":"app/guide/page.tsx","mode":"100644","type":"blob","sha":"7fd27409955c6fb1a67fe00ad2d4fc30abc9c27d","size":328},{"path":"app/icon.svg","mode":"100644","type":"blob","sha":"d18166689a1c996f735922e571c4269fa88e483a","size":690},{"path":"app/layout.tsx","mode":"100644","type":"blob","sha":"f7abe08bae131ed6f3e4af9a7f2734a50fe5918c","size":2238},{"path":"app/mock","mode":"040000","type":"tree","sha":"93ae49f9781456b350168708cc3f7c567f82ecc3"},{"path":"app/mock/[examId]","mode":"040000","type":"tree","sha":"022f19e0f50ae0a6415a9bcec01e778ce84c9438"},{"path":"app/mock/[examId]/[mockId]","mode":"040000","type":"tree","sha":"3069a9103981b988339bc5d89c2625c8117b3fff"},{"path":"app/mock/[examId]/[mockId]/page.tsx","mode":"100644","type":"blob","sha":"94aa8919a4513049b3ad631a89c65b0d5eb15657","size":910},{"path":"app/mock/page.tsx","mode":"100644","type":"blob","sha":"5c26769b4a75252f27d6cb50c96338d0f2a103e5","size":255},{"path":"app/page.tsx","mode":"100644","type":"blob","sha":"82ca338f0f5922a26c457e454d54b74de951e5be","size":97},{"path":"app/path","mode":"040000","type":"tree","sha":"84b1d4e3e3d491778946b92713667ecbe0d1b87f"},{"path":"app/path/page.tsx","mode":"100644","type":"blob","sha":"0d55b46e496df9fedf7e06ac8cb33cf9c0f1582b","size":273},{"path":"app/plans","mode":"040000","type":"tree","sha":"310bb1831f0c8c833012ab1ff21d9c737c386bfc"},{"path":"app/plans/[planId]","mode":"040000","type":"tree","sha":"4359241a68b1d4e2afd7dc71b56545a3f3fc763e"},{"path":"app/plans/[planId]/page.tsx","mode":"100644","type":"blob","sha":"462adebf98b8ff89915f57859583448bfeb0b66f","size":933},{"path":"app/plans/page.tsx","mode":"100644","type":"blob","sha":"5327da7ef99c48ca5b12ded5714262430683ff43","size":1042},{"path":"app/practice","mode":"040000","type":"tree","sha":"f8d22a390854aaf61cb6bd64257c21d9525b6763"},{"path":"app/practice/page.tsx","mode":"100644","type":"blob","sha":"80044a93e3cd6f29e93e76b713bc3fe2f3f5020a","size":395},{"path":"app/reference","mode":"040000","type":"tree","sha":"b39ed181aed22a481b06bf8de3e242f5639f0bae"},{"path":"app/reference/page.tsx","mode":"100644","type":"blob","sha":"3f2cf4381229583f2a190e737f84a3ddf2e624af","size":317},{"path":"components","mode":"040000","type":"tree","sha":"609bc8eb96994d6aeeb9abe8706fd0a0fa081f76"},{"path":"components/app-shell.tsx","mode":"100644","type":"blob","sha":"5028a06cd192b45b9eba99dfd0984cbbc3e6f4f0","size":15763},{"path":"components/arena-bits.tsx","mode":"100644","type":"blob","sha":"d3f434d3b74f70a0afc6fa85236769a36380dd4e","size":9278},{"path":"components/arena-brief.tsx","mode":"100644","type":"blob","sha":"6eb3642b45e2019adb1701dc407f6fd41b6c3b8a","size":6426},{"path":"components/arena-checkoff.tsx","mode":"100644","type":"blob","sha":"dce5998d6835293696100bc0c214ceeb0a197287","size":12809},{"path":"components/arena-clock.tsx","mode":"100644","type":"blob","sha":"22ce51a328c0c2b41736d2459532e91b30a18215","size":8545},{"path":"components/arena-list.tsx","mode":"100644","type":"blob","sha":"0f1200d3319510cf5b68aec56701299e9672a649","size":11952},{"path":"components/arena-review.tsx","mode":"100644","type":"blob","sha":"34233ee0aba8d72aa38023c7097955c4954aeb1f","size":6698},{"path":"components/arena-run.tsx","mode":"100644","type":"blob","sha":"d2d750bfa162a29c8b396cfcda9dcd779f266379","size":7439},{"path":"components/arena-start.tsx","mode":"100644","type":"blob","sha":"f58f0d3de645208d681c2541a0126666ac661837","size":6401},{"path":"components/code.tsx","mode":"100644","type":"blob","sha":"4ad807e5d785946eb5c47dbc8800554830e35b9d","size":13256},{"path":"components/coding-detail.tsx","mode":"100644","type":"blob","sha":"fd396c6cc45ecf1dafae5d9d8e9cc582d4ea823b","size":16915},{"path":"components/coding-list.tsx","mode":"100644","type":"blob","sha":"51a0c731c78a06f14091ba46a5d852d37670674f","size":15232},{"path":"components/coding-progress.tsx","mode":"100644","type":"blob","sha":"e94882b68eca13e1d11dec8934772cd1869213d1","size":3128},{"path":"components/coding-sandpack.tsx","mode":"100644","type":"blob","sha":"8c49b0988468ab3c141d455809efbfa221d52180","size":4846},{"path":"components/coding-workspace.tsx","mode":"100644","type":"blob","sha":"de4a50a7147ca70a4527f48b1698e2a81d3548f5","size":2260},{"path":"components/continue.tsx","mode":"100644","type":"blob","sha":"69460067e671c8551e6e9c26e93225a111876123","size":12846},{"path":"components/data-flow.tsx","mode":"100644","type":"blob","sha":"28d3b52f985fbcfc4ca14cebff04d8f856006fac","size":4579},{"path":"components/drill-answer.tsx","mode":"100644","type":"blob","sha":"1e34833e3116adf562975544fde4567910642653","size":1197},{"path":"components/drill-card.tsx","mode":"100644","type":"blob","sha":"dfbc0a1f679c13d5020ba63a2296317d9bb6d5a3","size":8981},{"path":"components/drill-detail.tsx","mode":"100644","type":"blob","sha":"b871184d9af4761f2e2e8a0473ebd3b5afdedf2f","size":4953},{"path":"components/drill-list.tsx","mode":"100644","type":"blob","sha":"f32e510b00e8f77304f8e62254ceae8360070ca1","size":16492},{"path":"components/drill-marks.tsx","mode":"100644","type":"blob","sha":"2afff54d156f80ba9e5f0ce9d462959963c839c8","size":4897},{"path":"components/drill-query.tsx","mode":"100644","type":"blob","sha":"775323df214bb8d80343be8a1f477b5a22ace835","size":3076},{"path":"components/drill-session-page.tsx","mode":"100644","type":"blob","sha":"1743c31bcd824db7a0c1a9914321cd44a862ee8f","size":2396},{"path":"components/drill-session.tsx","mode":"100644","type":"blob","sha":"c026f1b6346e1c909493a3c3e38b4ff88bc03f7f","size":27104},{"path":"components/exam-overview.tsx","mode":"100644","type":"blob","sha":"cfcc56992cc1cb91e29830341bc79e4be5119281","size":12599},{"path":"components/exercise.tsx","mode":"100644","type":"blob","sha":"e320ec56bfcc015cf8a1ebac56948e55b4d7b41d","size":34938},{"path":"components/guide.tsx","mode":"100644","type":"blob","sha":"61d0eaa82861444441cfd0adbfa12a9493664709","size":21246},{"path":"components/hint-panel.tsx","mode":"100644","type":"blob","sha":"6e21729f21ad8bca458b652df932310632bda218","size":4348},{"path":"components/home-library.tsx","mode":"100644","type":"blob","sha":"855535d39832482a9c6ae0914646113071a169f2","size":16285},{"path":"components/home.tsx","mode":"100644","type":"blob","sha":"7ead13710458fac79453a16b7de2ce629f5dd61e","size":3841},{"path":"components/ladder.tsx","mode":"100644","type":"blob","sha":"5a6cf79b8327a894e5fdc093deaacf1686d3bcac","size":7178},{"path":"components/learning-path.tsx","mode":"100644","type":"blob","sha":"d61c95e99d0765f445f5e4fd25239568cd8ad95c","size":11420},{"path":"components/lesson-body.tsx","mode":"100644","type":"blob","sha":"57d9a46fb94a426274cd03bdce51809cf6ee0956","size":15446},{"path":"components/lesson-islands.tsx","mode":"100644","type":"blob","sha":"9c6f74ff6ab57aacae71f7b7f5a4efeb205cb27d","size":3954},{"path":"components/lesson-kit.tsx","mode":"100644","type":"blob","sha":"43b8b979950316d49326adef24f8fc381625353a","size":23776},{"path":"components/lesson-plan.tsx","mode":"100644","type":"blob","sha":"e4cc701cc728e439298d17bcee0606f262c60335","size":3877},{"path":"components/local-setup.tsx","mode":"100644","type":"blob","sha":"b1487f796e9ca11c09641edf992d7e103ace6e81","size":11097},{"path":"components/mock-detail.tsx","mode":"100644","type":"blob","sha":"95912fc536f5836d4fb7d5ab9691824e0779075d","size":9488},{"path":"components/mock-score.tsx","mode":"100644","type":"blob","sha":"bd458bc610d638b9baf968487525e353d771744f","size":2190},{"path":"components/mock-workspace.tsx","mode":"100644","type":"blob","sha":"c67d28192eb6d1e6344dac5402004c3a45eda102","size":7269},{"path":"components/plan-cards.tsx","mode":"100644","type":"blob","sha":"71cfdda4332379f142107a453ffd1b58424cb9da","size":6593},{"path":"components/plan-dash.tsx","mode":"100644","type":"blob","sha":"a99fe3eb7d376738c432efe8958b48f5e6137c2a","size":8854},{"path":"components/plan-detail.tsx","mode":"100644","type":"blob","sha":"6fc11179fb88ce83f08bcb7861750d8536b20ddf","size":23665},{"path":"components/plan-kit.tsx","mode":"100644","type":"blob","sha":"074572200c831484425ae8d88d3f57e5bfa73f8d","size":19968},{"path":"components/plan-list.tsx","mode":"100644","type":"blob","sha":"b6d7ecd236c52258229f182ee9997e0a4cab6e43","size":3597},{"path":"components/plan-mark.tsx","mode":"100644","type":"blob","sha":"1d265c0bc626b046c5444d06a2d00ae4c1562ce4","size":1384},{"path":"components/plan-picker.tsx","mode":"100644","type":"blob","sha":"875c8f7d2f8670d758f5990747b85755ed0c897e","size":9064},{"path":"components/plan-slots.tsx","mode":"100644","type":"blob","sha":"5f8f3ca9ed11791fe8ab9f287505f606756558ac","size":3215},{"path":"components/practice-focus.tsx","mode":"100644","type":"blob","sha":"404cb7099f46edb9fdbfe0aae38b81bcad435eed","size":2764},{"path":"components/practice-page.tsx","mode":"100644","type":"blob","sha":"84494417305ee6d39d1c3504296ab4bd480704d7","size":15271},{"path":"components/practice-progress.tsx","mode":"100644","type":"blob","sha":"681e89a2517e4a486b1f287aadbe2d0b23149050","size":2132},{"path":"components/recent.tsx","mode":"100644","type":"blob","sha":"d5e0387f467bca5cb82cda9ca49d895acf9846a1","size":2261},{"path":"components/reference.tsx","mode":"100644","type":"blob","sha":"0868e5129c87a0ff7d773f591f623cdd8ab20fcb","size":69505},{"path":"components/sandbox.tsx","mode":"100644","type":"blob","sha":"34a23b72527a26c652b7bd35369b32ab702ba26c","size":7581},{"path":"components/search.tsx","mode":"100644","type":"blob","sha":"f54012a3242127dd1552ffe26f58e033ad36d0cf","size":16876},{"path":"components/side-nav.tsx","mode":"100644","type":"blob","sha":"32bbc34951c5f600ac8e9ca7d566a2c971fa1909","size":3629},{"path":"components/sidebars.tsx","mode":"100644","type":"blob","sha":"1feb8d07322afe94143037507cf540f9b388326f","size":37629},{"path":"components/t.tsx","mode":"100644","type":"blob","sha":"2a205830fc802c49914a4683d8ef4cc5e13a4cb0","size":2626},{"path":"content","mode":"040000","type":"tree","sha":"c0b0edfec9d1f221c1d08c4332317c925bc9d29f"},{"path":"content/arena.ts","mode":"100644","type":"blob","sha":"466a7062ddd44b536fce1430ade17435969ffb05","size":10230},{"path":"content/coding.ts","mode":"100644","type":"blob","sha":"9d9191f2ee497ec28eaea82ba84baa0a586f7d92","size":196390},{"path":"content/drills.ts","mode":"100644","type":"blob","sha":"758b6a7d6c521d52879757b7184d50e88e5ec264","size":7239},{"path":"content/exam-list.ts","mode":"100644","type":"blob","sha":"3c6974cb7d718931bc50120ff16529dec0094d52","size":1064},{"path":"content/exams","mode":"040000","type":"tree","sha":"7ff9985bd845b3c76e24394426c1fcb3f93dae18"},{"path":"content/exams/cab-booking.tsx","mode":"100644","type":"blob","sha":"34f872ee045a3c925524cc1dd268ab56266cd376","size":351886},{"path":"content/exams/fed-part1.tsx","mode":"100644","type":"blob","sha":"8629017946d945c02776c742bf82f641eccd6186","size":244343},{"path":"content/exams/fed-part2.tsx","mode":"100644","type":"blob","sha":"e1790f202911a81477a36c9559c9892b8bec1d22","size":222804},{"path":"content/exams/fed-part3.tsx","mode":"100644","type":"blob","sha":"9e2bb353c83a9973744dc6bb10cb111bf4a91ec3","size":199481},{"path":"content/exams/fed-part4.tsx","mode":"100644","type":"blob","sha":"143af3781c0a06cb5eb816eebec7f005d28f27d3","size":128242},{"path":"content/exams/foundations.tsx","mode":"100644","type":"blob","sha":"d3166537308ef21f5df241e22ba4ee1c9c8dd570","size":259162},{"path":"content/exams/graphql-federation.tsx","mode":"100644","type":"blob","sha":"75ca0dae45e8137c58df1c840578af96673c9cb0","size":66746},{"path":"content/exams/interview.tsx","mode":"100644","type":"blob","sha":"9df22e1ed5da38190439373afb64dcebe19f36a0","size":5173},{"path":"content/exams/iv-backend.tsx","mode":"100644","type":"blob","sha":"8c2fff4c5c4ebc53a7482f7c95f68dc782651482","size":119953},{"path":"content/exams/iv-basics.tsx","mode":"100644","type":"blob","sha":"06c232f2d1d1c87ff83ad49760d023a057d95c9c","size":81967},{"path":"content/exams/iv-coding.tsx","mode":"100644","type":"blob","sha":"e6bfd7f7718d59ced64adbc5f94ecb56f2386105","size":222058},{"path":"content/exams/iv-hand.tsx","mode":"100644","type":"blob","sha":"9613af573f7ba1503c11cb77e69681b23ba3c183","size":98644},{"path":"content/exams/iv-js1.tsx","mode":"100644","type":"blob","sha":"3e82e8a1a5fbeedba5d1b6d277a97134960c71a4","size":145907},{"path":"content/exams/iv-js2.tsx","mode":"100644","type":"blob","sha":"39066ff487978493b19166156056b9edc013660a","size":130568},{"path":"content/exams/iv-react1.tsx","mode":"100644","type":"blob","sha":"2983ab2596721e98fe90329c3c881d6cc9a7eefb","size":120761},{"path":"content/exams/iv-react2.tsx","mode":"100644","type":"blob","sha":"e267c3d99dd55e21294a30fe033eb87d17b5daf8","size":139229},{"path":"content/exams/iv-ts.tsx","mode":"100644","type":"blob","sha":"074d26eef11494f5cfe89b166502956ac2e5567c","size":67987},{"path":"content/exams/react-part1.tsx","mode":"100644","type":"blob","sha":"b2f20d557d7a8ed996e1a83f37d1049b7502057d","size":81962},{"path":"content/exams/react-part2.tsx","mode":"100644","type":"blob","sha":"c3ca2b64b7fc6f9cf8697c499d8b6c0eab587eab","size":111237},{"path":"content/exams/react-part3.tsx","mode":"100644","type":"blob","sha":"3375fee57d2d49f1774663984d0ac09f5b08de67","size":201664},{"path":"content/exams/react-part4.tsx","mode":"100644","type":"blob","sha":"dcd031c5096f1bbf54c3a9e455682bbff5230b5f","size":165084},{"path":"content/exams/react-part5.tsx","mode":"100644","type":"blob","sha":"8f87e83946c40f15fb0876dc11bf7cc975a41984","size":326857},{"path":"content/exams/react.tsx","mode":"100644","type":"blob","sha":"6b2db4a348af1ba278f36a3e9ecc41ccc25c0e4b","size":47632},{"path":"content/helpers.tsx","mode":"100644","type":"blob","sha":"a78dae0313fedee9934014d4037e1d6d8aff98fd","size":2093},{"path":"content/nav-exercises.ts","mode":"100644","type":"blob","sha":"d1b954807175d37b67bad0a30d188e7c1d46b877","size":38891},{"path":"content/nav.ts","mode":"100644","type":"blob","sha":"621df54b4d7184b17cc20325a10d4e867c885a82","size":134329},{"path":"content/path.ts","mode":"100644","type":"blob","sha":"19e10261e9bd0feba99e99aaae66c4f73918aab8","size":6146},{"path":"content/plan-manifest.ts","mode":"100644","type":"blob","sha":"d4477a893be99e9456722a05fd7a24c43fbe6b2a","size":99570},{"path":"content/plans-assert.ts","mode":"100644","type":"blob","sha":"e6e4eb50e04e435e63a33f70f13bb1e3cb34c383","size":3205},{"path":"content/plans.ts","mode":"100644","type":"blob","sha":"0134319f0ad085a75988f913e0406a507b9774dc","size":46603},{"path":"content/registry.ts","mode":"100644","type":"blob","sha":"454dce5185a0a4303d9e5350fe2700352b0313e5","size":4491},{"path":"content/search-index.ts","mode":"100644","type":"blob","sha":"b1d3cda97cb7656e591874769fdbc8720094a475","size":183712},{"path":"content/source-files.ts","mode":"100644","type":"blob","sha":"4492249ef84cb4697437e5083b3badac1d1dadee","size":76085},{"path":"content/types.ts","mode":"100644","type":"blob","sha":"7f3693e678e30dcb1b13fa46d452b7324b9d7feb","size":20601},{"path":"docs","mode":"040000","type":"tree","sha":"c3fb02115817a5771f0c42705eba38fa216d06e3"},{"path":"docs/drill.jpg","mode":"100644","type":"blob","sha":"1cb1e8e8061c2bd326ecb601ab9ac640e2946f02","size":96959},{"path":"docs/guided-plans.md","mode":"100644","type":"blob","sha":"7bd509eb4fb191cfa91db9d8d729e0d0cf42e0f2","size":15749},{"path":"docs/home.jpg","mode":"100644","type":"blob","sha":"655ea5a09b7d45ab75d33846979428c4ce6749a9","size":68836},{"path":"docs/ia-audit-round3.md","mode":"100644","type":"blob","sha":"5fdba6b17ff04c22b4777769b484cd3922823ff2","size":13383},{"path":"docs/ia-nav","mode":"040000","type":"tree","sha":"34ba5cdc31c904dfd3ba0126c02ce61a3ef42296"},{"path":"docs/ia-nav/assess-after.jpg","mode":"100644","type":"blob","sha":"449f1e625cff37b57d6984c5d2e8ff1b9928e4c9","size":134931},{"path":"docs/ia-nav/assess-before.jpg","mode":"100644","type":"blob","sha":"a71470575e00d0feafeef587cc92a014405fb3e5","size":127631},{"path":"docs/ia-nav/home-after.jpg","mode":"100644","type":"blob","sha":"3d91e9056d9425318551000ed0941d849a0689d0","size":66514},{"path":"docs/ia-nav/home-before.jpg","mode":"100644","type":"blob","sha":"c615c84c0100b1db538d3fa48e60dcfb573458d4","size":96613},{"path":"docs/ia-nav/home-dark-after.jpg","mode":"100644","type":"blob","sha":"02f6ea22497f567f6b9435f2c47fd0e0d06fde1b","size":66404},{"path":"docs/ia-nav/home-dark-before.jpg","mode":"100644","type":"blob","sha":"0e67279e70a5ffa172ad5257f310fe65d831d0ab","size":95416},{"path":"docs/ia-nav/lesson-after.jpg","mode":"100644","type":"blob","sha":"65184ac178efbb0c699b81b19c59393ebe738a8b","size":149021},{"path":"docs/ia-nav/lesson-before.jpg","mode":"100644","type":"blob","sha":"5a690af807b8b7d0b4b6f670142fdcd0bd981f27","size":150616},{"path":"docs/ia-nav/lesson-foot-after.jpg","mode":"100644","type":"blob","sha":"c3971ae05ecbb79776dc001550e1db78ae21c79d","size":133914},{"path":"docs/ia-nav/lesson-foot-before.jpg","mode":"100644","type":"blob","sha":"b49bc0c9190158bb02ff824e028b24aa5663b519","size":127257},{"path":"docs/ia-nav/mobile-drawer-after.jpg","mode":"100644","type":"blob","sha":"cfca93e17ef0af4198e3afcacb1669de1f4dbc77","size":107266},{"path":"docs/ia-nav/mobile-drawer-before.jpg","mode":"100644","type":"blob","sha":"0a3ce7e27725cf6d6198480c0cb76f4bd0aebbe0","size":104327},{"path":"docs/ia-nav/mobile-home-after.jpg","mode":"100644","type":"blob","sha":"5be7b4a40b99901331ede38d4f580180232c1fff","size":81281},{"path":"docs/ia-nav/mobile-home-before.jpg","mode":"100644","type":"blob","sha":"212ea175eefe1114ee818b9c0753ff20e85e0637","size":108708},{"path":"docs/ia-nav/mobile-lesson-after.jpg","mode":"100644","type":"blob","sha":"4689971308cb8d9dad8ed2b0853b65ed604d851d","size":127948},{"path":"docs/ia-nav/mobile-lesson-before.jpg","mode":"100644","type":"blob","sha":"883910dff8134ab12b5913b281ef51f5a78e342b","size":123907},{"path":"docs/ia-nav/path-after.jpg","mode":"100644","type":"blob","sha":"6b2ebbd740be1d63e6146540fbe9339bcb5a72b4","size":124588},{"path":"docs/ia-nav/path-before.jpg","mode":"100644","type":"blob","sha":"0191c37133c73f1d6b3c322d3f37931514aaa52a","size":110623},{"path":"docs/ia-nav/practice-code-after.jpg","mode":"100644","type":"blob","sha":"4442354519520fff93b89ff1ad2459cbd2e10c27","size":118895},{"path":"docs/ia-nav/practice-code-before.jpg","mode":"100644","type":"blob","sha":"d037406dc293d654b95c4ce2206305b03ae92ec4","size":123148},{"path":"docs/ia-nav/review-after.jpg","mode":"100644","type":"blob","sha":"deff0cb61284cc857b662d570e24aa6e5958bb30","size":114676},{"path":"docs/ia-nav/review-before.jpg","mode":"100644","type":"blob","sha":"acd82b66f7ac80df3dcce653985e1f65c9ce10e9","size":117205},{"path":"docs/ia-nav/tablet-lesson-after.jpg","mode":"100644","type":"blob","sha":"a68a81f8ed08b490d48a2ace780dc1c5c08e72de","size":105790},{"path":"docs/ia-nav/tablet-lesson-before.jpg","mode":"100644","type":"blob","sha":"80835d82d035f1410184911726510ef0d54484ec","size":104233},{"path":"docs/intent-nav.md","mode":"100644","type":"blob","sha":"4e8c7dc13e61b53f640b07fecae4cafdf2eacb47","size":8254},{"path":"docs/plans","mode":"040000","type":"tree","sha":"c6cc2a65a546ce2911f2d5711141dae2d62520a6"},{"path":"docs/plans/arena-after.jpg","mode":"100644","type":"blob","sha":"3698284be0b18e84140eb4853ae1080c09b27894","size":137635},{"path":"docs/plans/arena-before.jpg","mode":"100644","type":"blob","sha":"c963e78a6dbeee8a54447f312f4f23e5aaacee82","size":130524},{"path":"docs/plans/coding-after.jpg","mode":"100644","type":"blob","sha":"ad54c535efde786122a56d977ea7aaac3b1dba1f","size":136149},{"path":"docs/plans/coding-before.jpg","mode":"100644","type":"blob","sha":"ebb26832cf37c48bc09000b86aa97d7cfa73a905","size":131825},{"path":"docs/plans/drill-one-after.jpg","mode":"100644","type":"blob","sha":"84f324bb16fde85e74da0e34e1d3ddf4d82c12ee","size":117875},{"path":"docs/plans/drill-one-before.jpg","mode":"100644","type":"blob","sha":"9b8587632a3ab8b2cac70230893212434dafadc7","size":111632},{"path":"docs/plans/home-fresh-after.jpg","mode":"100644","type":"blob","sha":"6c89ea355af53d16308b0801c2eab89541d1aa09","size":118611},{"path":"docs/plans/home-fresh-before.jpg","mode":"100644","type":"blob","sha":"eede12370691e9f81f70a0a5036a889ec59e67a0","size":75690},{"path":"docs/plans/home-noplan-after.jpg","mode":"100644","type":"blob","sha":"ef3335bd4d54b200e4c1d63467d07746b3b6b70c","size":120266},{"path":"docs/plans/home-noplan-before.jpg","mode":"100644","type":"blob","sha":"e19d1b77ff617e952c04ce6ba37ae106f69a7829","size":73703},{"path":"docs/plans/home-plan-after.jpg","mode":"100644","type":"blob","sha":"200b54f7950da7331b1259fd28a06c33670bdc44","size":70987},{"path":"docs/plans/home-plan-before.jpg","mode":"100644","type":"blob","sha":"e19d1b77ff617e952c04ce6ba37ae106f69a7829","size":73703},{"path":"docs/plans/lesson-after.jpg","mode":"100644","type":"blob","sha":"7abc4f6e95d86eb8d71b5848b8e353de21e60539","size":154715},{"path":"docs/plans/lesson-before.jpg","mode":"100644","type":"blob","sha":"64ac27b2b666e4e3cdca33304aca9850d7336e23","size":160009},{"path":"docs/plans/lesson-foot-after.jpg","mode":"100644","type":"blob","sha":"b70b1c28a9aa461e3b4e0076731db7f26f9ce098","size":139446},{"path":"docs/plans/lesson-foot-before.jpg","mode":"100644","type":"blob","sha":"757d900e397415f73275671a55c798e0cd0fadcc","size":136850},{"path":"docs/plans/mobile-drawer-after.jpg","mode":"100644","type":"blob","sha":"e26c1001dd2fbb8fb660ae84d6e106659ac63d98","size":94150},{"path":"docs/plans/mobile-drawer-before.jpg","mode":"100644","type":"blob","sha":"6911b05dc3cd81131b76d87f2361673480541c32","size":103567},{"path":"docs/plans/mobile-home-after.jpg","mode":"100644","type":"blob","sha":"1e6ed6167ea6c794df9858a8c3ff1139bb4e8562","size":87539},{"path":"docs/plans/mobile-home-before.jpg","mode":"100644","type":"blob","sha":"d3e645bd01abb3b607a23a9ca5b28db685ae4a6e","size":88930},{"path":"docs/plans/mobile-plan-after.jpg","mode":"100644","type":"blob","sha":"7dad5f95e0ca7c26530ea693b3cccaaa804c2950","size":119316},{"path":"docs/plans/mobile-plan-before.jpg","mode":"100644","type":"blob","sha":"8511c7bbfd059a3332e34c02ac76abdb5f18da52","size":25412},{"path":"docs/plans/plan-complete-after.jpg","mode":"100644","type":"blob","sha":"83139222dc9a5e8e89bbd2b352285fdfeda55e92","size":97769},{"path":"docs/plans/plan-complete-before.jpg","mode":"100644","type":"blob","sha":"117058ec868b47d9ef41651f13f25039e22e1602","size":19215},{"path":"docs/plans/plan-list-after.jpg","mode":"100644","type":"blob","sha":"b97bcfd48afa61cfaf950ccc2eb34704fe03ed5d","size":97880},{"path":"docs/plans/plan-list-before.jpg","mode":"100644","type":"blob","sha":"117058ec868b47d9ef41651f13f25039e22e1602","size":19215},{"path":"docs/plans/plan-react-after.jpg","mode":"100644","type":"blob","sha":"dd4cbd024e6d3d363017ff6034b14da1d8701834","size":105089},{"path":"docs/plans/plan-react-before.jpg","mode":"100644","type":"blob","sha":"5278ab29306e5b2c2d2621aebfeaac46184f43e1","size":19885},{"path":"docs/plans/plan-react-dark-after.jpg","mode":"100644","type":"blob","sha":"098521499b5edfaeb41a59eb4879a3ff35dcfd2b","size":104141},{"path":"docs/plans/plan-react-dark-before.jpg","mode":"100644","type":"blob","sha":"cca3857ca2b54e1b1c3898d9a978b09b79aa6ab1","size":20217},{"path":"docs/plans/review-after.jpg","mode":"100644","type":"blob","sha":"c7b7345f4a70f56c074b09ad6500c1027bfed2f2","size":124858},{"path":"docs/plans/review-before.jpg","mode":"100644","type":"blob","sha":"5d3fe5064c2b4703027566df14d72a786ea85ccd","size":115340},{"path":"docs/plans/tablet-plan-after.jpg","mode":"100644","type":"blob","sha":"df30ce3b69a42698fbff3941bb801df8fbf8023c","size":76447},{"path":"docs/plans/tablet-plan-before.jpg","mode":"100644","type":"blob","sha":"338effa36cbebd0d48cee0a671e6e601140d60f7","size":14041},{"path":"docs/sandpack-evaluation.md","mode":"100644","type":"blob","sha":"97bd36d5708482727386132c9e43c7617f506f04","size":4116},{"path":"docs/ui-v2-polish.md","mode":"100644","type":"blob","sha":"75fbbd09d1ac29b247026a49f6c4f0db7bafdc59","size":2951},{"path":"docs/ui-v2-polish","mode":"040000","type":"tree","sha":"62ec67bdc2fac6852c7fe610e129acdd80a8f251"},{"path":"docs/ui-v2-polish/active-home-after.jpg","mode":"100644","type":"blob","sha":"ee005dfbbc0a43a29ec93968aba261b4221fa970","size":56176},{"path":"docs/ui-v2-polish/active-home-before.jpg","mode":"100644","type":"blob","sha":"95b3f7a6b140aa62afd5fac8a459ff7b9336a71b","size":55402},{"path":"docs/ui-v2-polish/code-after.jpg","mode":"100644","type":"blob","sha":"e3dac8e858e71565a63390752c0c06d206396bbc","size":79706},{"path":"docs/ui-v2-polish/code-before.jpg","mode":"100644","type":"blob","sha":"ff4f63f9893ee094189c732f8ab9c7d794e10236","size":78184},{"path":"docs/ui-v2-polish/fresh-home-after.jpg","mode":"100644","type":"blob","sha":"c2d27f10d22343613dc18b719a13a1fd31f984b6","size":66599},{"path":"docs/ui-v2-polish/fresh-home-before.jpg","mode":"100644","type":"blob","sha":"0b999f9a7706d8e1b63caa441e1edf8a3bb0cc61","size":66730},{"path":"docs/ui-v2-polish/lesson-after.jpg","mode":"100644","type":"blob","sha":"a6846296a36b82e33c52ddd94cd00e43b06269fb","size":90956},{"path":"docs/ui-v2-polish/lesson-before.jpg","mode":"100644","type":"blob","sha":"3dd803b8480cfc78e1e800b307069f85c3797d54","size":88304},{"path":"docs/ui-v2-polish/m-home-after.jpg","mode":"100644","type":"blob","sha":"295b9d756ef8ebde9c13daee1bc0efc8cf1521d2","size":178826},{"path":"docs/ui-v2-polish/m-home-before.jpg","mode":"100644","type":"blob","sha":"2120651ae6c9ce6858d5bbf15a7c67b41d6077cd","size":186070},{"path":"docs/ui-v2-polish/plan-react-after.jpg","mode":"100644","type":"blob","sha":"737a63bebb17e558ffb4bd06568c393bc0491948","size":70629},{"path":"docs/ui-v2-polish/plan-react-before.jpg","mode":"100644","type":"blob","sha":"964ef64677e4723c869c9d72c1c21576c93f27e0","size":70481},{"path":"docs/ui-v2-polish/practice-after.jpg","mode":"100644","type":"blob","sha":"7339e8d04c02b6ad4515c7064bc11799c4296ac4","size":79690},{"path":"docs/ui-v2-polish/practice-before.jpg","mode":"100644","type":"blob","sha":"bf80076fd98039330953a69316edbfbe57d3da6e","size":77951},{"path":"docs/ui-v2-polish/review-after.jpg","mode":"100644","type":"blob","sha":"dd87fd5292f605d8f2c6e868c061a87818ce909c","size":71655},{"path":"docs/ui-v2-polish/review-before.jpg","mode":"100644","type":"blob","sha":"22d6e05c842256375655b6ad89c60b09d9a2769e","size":70419},{"path":"docs/ui-v2-screens.md","mode":"100644","type":"blob","sha":"42245a5d7debdb2bd91cfd25d1c735dc227a67b8","size":2503},{"path":"docs/ui-v2.md","mode":"100644","type":"blob","sha":"4abf43a5a5e63613690c87b422e630ed757ce32d","size":10523},{"path":"docs/ui-v2","mode":"040000","type":"tree","sha":"662cb1fcbc273e9a4f50f239ac3331b809ea7dd6"},{"path":"docs/ui-v2/active-home-after.jpg","mode":"100644","type":"blob","sha":"95b3f7a6b140aa62afd5fac8a459ff7b9336a71b","size":55402},{"path":"docs/ui-v2/active-home-before.jpg","mode":"100644","type":"blob","sha":"ed88353a92e19f9a61e4db0f8e7e4cc35de177f1","size":53110},{"path":"docs/ui-v2/arena-after.jpg","mode":"100644","type":"blob","sha":"008f708260d48f536b41f5d791b2eabf0172e8f7","size":83708},{"path":"docs/ui-v2/arena-before.jpg","mode":"100644","type":"blob","sha":"73ba756248d9ebfb3fa7f1ae9cd8a75d5e66b8ba","size":89762},{"path":"docs/ui-v2/code-after.jpg","mode":"100644","type":"blob","sha":"ff4f63f9893ee094189c732f8ab9c7d794e10236","size":78184},{"path":"docs/ui-v2/code-before.jpg","mode":"100644","type":"blob","sha":"bc6d2aa4e940c2392f128a58f7d4ee1080a6b56a","size":88874},{"path":"docs/ui-v2/fresh-home-after.jpg","mode":"100644","type":"blob","sha":"0b999f9a7706d8e1b63caa441e1edf8a3bb0cc61","size":66730},{"path":"docs/ui-v2/fresh-home-before.jpg","mode":"100644","type":"blob","sha":"0431db78c453a438d38720796ef6055f6645e6f6","size":75751},{"path":"docs/ui-v2/lesson-after.jpg","mode":"100644","type":"blob","sha":"3dd803b8480cfc78e1e800b307069f85c3797d54","size":88304},{"path":"docs/ui-v2/lesson-before.jpg","mode":"100644","type":"blob","sha":"89acad6f945207754afb137f5e6bbda1ec1e94e2","size":102613},{"path":"docs/ui-v2/m-home-after.jpg","mode":"100644","type":"blob","sha":"2120651ae6c9ce6858d5bbf15a7c67b41d6077cd","size":186070},{"path":"docs/ui-v2/m-home-before.jpg","mode":"100644","type":"blob","sha":"145abee647c1b67c3ea00bd4c297c5807c0d9fba","size":160284},{"path":"docs/ui-v2/m-lesson-drawer-after.jpg","mode":"100644","type":"blob","sha":"36fbcfcdce0bf287a84fd25fc753ba81554286f6","size":112063},{"path":"docs/ui-v2/m-lesson-drawer-before.jpg","mode":"100644","type":"blob","sha":"409097995db1bcbfe5aff78f06ed14d5c278633b","size":182735},{"path":"docs/ui-v2/m-plan-after.jpg","mode":"100644","type":"blob","sha":"6cfc085d6619d7df0be4d4a07ee3e679730f3f07","size":188476},{"path":"docs/ui-v2/m-plan-before.jpg","mode":"100644","type":"blob","sha":"97dc1d6d0ee4755a7eb9443bfe6beeaee5cfc20d","size":207580},{"path":"docs/ui-v2/plan-list-after.jpg","mode":"100644","type":"blob","sha":"b7d75a3ca3ccc067ccf2eecfc90732e147b44090","size":76283},{"path":"docs/ui-v2/plan-list-before.jpg","mode":"100644","type":"blob","sha":"0ed0ed62f17159ada5a93bed20389559346a8b99","size":70063},{"path":"docs/ui-v2/plan-react-after.jpg","mode":"100644","type":"blob","sha":"964ef64677e4723c869c9d72c1c21576c93f27e0","size":70481},{"path":"docs/ui-v2/plan-react-before.jpg","mode":"100644","type":"blob","sha":"698a984b2e2c39dd7a352c4aba887ae32df7d8e5","size":64035},{"path":"docs/ui-v2/practice-after.jpg","mode":"100644","type":"blob","sha":"bf80076fd98039330953a69316edbfbe57d3da6e","size":77951},{"path":"docs/ui-v2/practice-before.jpg","mode":"100644","type":"blob","sha":"d56ba01eb8f29b067b028359a2e22462e03ae714","size":90902},{"path":"docs/ui-v2/review-after.jpg","mode":"100644","type":"blob","sha":"22d6e05c842256375655b6ad89c60b09d9a2769e","size":70419},{"path":"docs/ui-v2/review-before.jpg","mode":"100644","type":"blob","sha":"088fcf8b8b7b8bd8ac842bdc05becfaafd71c450","size":86052},{"path":"lib","mode":"040000","type":"tree","sha":"f2a61da2fdfb595d96e84149d9c994d857a18642"},{"path":"lib/coding-labels.ts","mode":"100644","type":"blob","sha":"3734ea5ea1667dda8fe300d7b93ea0c8dc170fbc","size":1341},{"path":"lib/exercise-labels.ts","mode":"100644","type":"blob","sha":"45ff2706a7052dd8ffdbbdabfaf622c67a3e13d7","size":2307},{"path":"lib/highlight.ts","mode":"100644","type":"blob","sha":"d9fd4d14f3bd6769f22b9e44d5cf5ec936f5c594","size":11658},{"path":"lib/list-query.ts","mode":"100644","type":"blob","sha":"032a3c2a9da3ac094f3594454455d375841da3b7","size":2578},{"path":"lib/locale.tsx","mode":"100644","type":"blob","sha":"5c0ddd2fe0a5a2b5625eaa18d0d41c481f2dbf83","size":3976},{"path":"lib/modes.ts","mode":"100644","type":"blob","sha":"d8bae30ecc926d6161e4dd55c95be38567d36300","size":3989},{"path":"lib/plan-lite.ts","mode":"100644","type":"blob","sha":"9d5b2bfe50da16d2393e8015ec10990f93cf84b6","size":4179},{"path":"lib/plan-progress.ts","mode":"100644","type":"blob","sha":"11fc90a27c0c9bfbd67ef3aafa6f74237f5b2ed2","size":11039},{"path":"lib/plan-search.ts","mode":"100644","type":"blob","sha":"7bcc8c99cfa0edd5bae79a9242fd3774f07bb07b","size":3004},{"path":"lib/plan-types.ts","mode":"100644","type":"blob","sha":"d1d621afdb240279a6effaddcbba5308d8c27754","size":1500},{"path":"lib/progress.tsx","mode":"100644","type":"blob","sha":"781a7fc9d7dc7aa6ce74f121512451a801198bb3","size":22842},{"path":"lib/side-nav.ts","mode":"100644","type":"blob","sha":"e6c6b6200c43d4504f422e3d3a46cbed4a214b1d","size":4290},{"path":"lib/theme.tsx","mode":"100644","type":"blob","sha":"7dea6daf27e349d9133d94f259eb50990b6b8489","size":2376},{"path":"lib/use-active-heading.ts","mode":"100644","type":"blob","sha":"467cbbcad2610183b6c3ce3e4f46cee0c86596cd","size":1870},{"path":"next.config.mjs","mode":"100644","type":"blob","sha":"2503b934cb5608348a04c2f72db76cb881b882ee","size":465},{"path":"package-lock.json","mode":"100644","type":"blob","sha":"5c1915c310f14dd83789441d41f49365046b4584","size":217794},{"path":"package.json","mode":"100644","type":"blob","sha":"bc604fb7e2dc41989b86a8a1f847810aabd7b783","size":738},{"path":"scripts","mode":"040000","type":"tree","sha":"5ad7d70c84db664c85ebedf315562f98c5a51d4d"},{"path":"scripts/audit-code-lines.mjs","mode":"100644","type":"blob","sha":"5ed6c1ea3fdc28f2bcdf126af8cd137aa626edb0","size":17454},{"path":"scripts/dev.sh","mode":"100755","type":"blob","sha":"6b7d5f07bb7eb4a34e8a7ef005de8f655135a87a","size":2099},{"path":"scripts/gen-nav.mjs","mode":"100644","type":"blob","sha":"85abfe59b91eb447d7ea349c62e1b7b1bc49b3b6","size":3586},{"path":"scripts/gen-source-files.mjs","mode":"100644","type":"blob","sha":"99c9beba88d5162350d08ea3a00ed28948390574","size":6332},{"path":"scripts/nav-dump-route.txt","mode":"100644","type":"blob","sha":"62d9ea60798f59ccf506c75ae8fc6be075f74902","size":8791},{"path":"scripts/nav-exercises-template.txt","mode":"100644","type":"blob","sha":"c06d25b5fcc7836bb8e18d5dd580397245949c04","size":1074},{"path":"scripts/nav-template.txt","mode":"100644","type":"blob","sha":"1c69ca23d966a93c6b9e063a4b6830597bcdc880","size":6497},{"path":"scripts/plan-manifest-template.txt","mode":"100644","type":"blob","sha":"bdd1f383175596deb3b993c855f2540085e2c0e0","size":3263},{"path":"scripts/search-index-template.txt","mode":"100644","type":"blob","sha":"8cf23f28cb34ebcb827760f40f66711fe84c529d","size":1645},{"path":"scripts/source-files-template.txt","mode":"100644","type":"blob","sha":"3fa93f80e8dc70ab2155e178f70c5f5e2fd20ef0","size":1699},{"path":"styles","mode":"040000","type":"tree","sha":"761da61c9ad5f024a3127b939a1f909708ff697c"},{"path":"styles/arena.css","mode":"100644","type":"blob","sha":"0bc4f7f258d99aaf7608fff98d55879922c3eca6","size":8973},{"path":"styles/base.css","mode":"100644","type":"blob","sha":"e7236fed25ea27b78e2923c8e0dbcabeca454eda","size":6807},{"path":"styles/code.css","mode":"100644","type":"blob","sha":"1c1f37efbaa77bb0a265d45100c31d9f298b6ec4","size":9870},{"path":"styles/coding.css","mode":"100644","type":"blob","sha":"d361c679f32e1f708a723141ef62cfe1eca7f78a","size":8085},{"path":"styles/dash.css","mode":"100644","type":"blob","sha":"3f1d210ba92e6d3ed93096adfa4dbacc34d8c3d3","size":10598},{"path":"styles/drill.css","mode":"100644","type":"blob","sha":"cf195571940555f32e38a5bcaa488e82afb90601","size":16601},{"path":"styles/exercise.css","mode":"100644","type":"blob","sha":"e97e565f37641018c023a2fcdd364271c1f00815","size":15828},{"path":"styles/layout.css","mode":"100644","type":"blob","sha":"a46dee42f76b851a195086afcc145f0a3c7d2207","size":9106},{"path":"styles/lesson.css","mode":"100644","type":"blob","sha":"80f78ec6d5da858bb27cf50a28ee2f3629f169ed","size":27163},{"path":"styles/nav.css","mode":"100644","type":"blob","sha":"59d969aaab22f82eed32744ed03c9774d2971496","size":34719},{"path":"styles/plans.css","mode":"100644","type":"blob","sha":"b7a849ff8ade3f5f6cf15374eacb7e0fef1b5b41","size":33397},{"path":"styles/shell.css","mode":"100644","type":"blob","sha":"62ce9562256cab0160c12387fb0d14c549cf2bcb","size":24639},{"path":"styles/tokens.css","mode":"100644","type":"blob","sha":"a317e010c2344303186e37942cc9ae7930a8ebdc","size":9324},{"path":"styles/viz.css","mode":"100644","type":"blob","sha":"3966876393a6a46161614a608495407b5c616ab9","size":4779},{"path":"tsconfig.json","mode":"100644","type":"blob","sha":"bde17a6e0e047489b711befe4660189f63127d9d","size":563}]},"e5d430d4012c2fea6bf011a5c481d8ff53534b38":{"sha":"e5d430d4012c2fea6bf011a5c481d8ff53534b38","truncated":false,"tree":[{"path":".claude","mode":"040000","type":"tree","sha":"e293a97eb2671b24ec0225f353b444aadf6d6dc3"},{"path":".claude/launch.json","mode":"100644","type":"blob","sha":"29fafb92b7eb278bcef2f4af17c23905bff78290","size":201},{"path":".eslintrc.json","mode":"100644","type":"blob","sha":"bffb357a7122523ec94045523758c4b825b448ef","size":40},{"path":".gitignore","mode":"100644","type":"blob","sha":"4d253c1c30541d2ca63d572775d7f576d346fc4a","size":117},{"path":".nvmrc","mode":"100644","type":"blob","sha":"2bd5a0a98a36cc08ada88b804d3be047e6aa5b8a","size":3},{"path":"CLAUDE.md","mode":"100644","type":"blob","sha":"284b135614a9c2a2b918aee3e61d78b9164fe0bc","size":85631},{"path":"README.md","mode":"100644","type":"blob","sha":"6fc691716e9fe1d034f3715dbb6cda79684f9dbe","size":8323},{"path":"app","mode":"040000","type":"tree","sha":"1e188157f78d13a699d659c42e3d8b8e64957725"},{"path":"app/arena","mode":"040000","type":"tree","sha":"e322690f7ede363a785510ff7df4fe2c964dec4a"},{"path":"app/arena/[id]","mode":"040000","type":"tree","sha":"6e85d2ba7ee0e8e1d67c27ebed8f3a76de44095c"},{"path":"app/arena/[id]/page.tsx","mode":"100644","type":"blob","sha":"d99874a87634b64cd38159a37b0b3ddf9237a03b","size":931},{"path":"app/arena/[id]/review","mode":"040000","type":"tree","sha":"f62641f1a7817943173849c9c5bd36b832e2fc28"},{"path":"app/arena/[id]/review/page.tsx","mode":"100644","type":"blob","sha":"6eb09da17a77b9541d2604a3f9c6736f98b81cb6","size":810},{"path":"app/arena/[id]/run","mode":"040000","type":"tree","sha":"be528ba0422d25c139924a8f67b4668ce1bb3c58"},{"path":"app/arena/[id]/run/page.tsx","mode":"100644","type":"blob","sha":"220f8705cbde373bb76968d58ea2a5c147bb0672","size":931},{"path":"app/arena/page.tsx","mode":"100644","type":"blob","sha":"3726ee4e68ba7044223590df8f146b2d875a9814","size":306},{"path":"app/code","mode":"040000","type":"tree","sha":"64616c1c87df7f194346871cef35857773bba123"},{"path":"app/code/[id]","mode":"040000","type":"tree","sha":"83e818f795d8b16d2dafc4b8db824f38a83eef35"},{"path":"app/code/[id]/page.tsx","mode":"100644","type":"blob","sha":"7d95e3bcdc2b73df504c3fc4f512f68d48bdd04b","size":909},{"path":"app/code/page.tsx","mode":"100644","type":"blob","sha":"03a6a019481b97156cec15af5ee6985daad937bf","size":519},{"path":"app/drill","mode":"040000","type":"tree","sha":"b190f0e0772286650a353e7dff18981f3a3b33f7"},{"path":"app/drill/[id]","mode":"040000","type":"tree","sha":"a8dfc8878139b8c4bc4c75a4b9b096880387ffd5"},{"path":"app/drill/[id]/page.tsx","mode":"100644","type":"blob","sha":"694085e52f92b9a8f69d75f868e51d322f1603ee","size":915},{"path":"app/drill/page.tsx","mode":"100644","type":"blob","sha":"bd4a6bad2e9229e4607901aafee9f01715be9ac1","size":510},{"path":"app/drill/session","mode":"040000","type":"tree","sha":"36830cce8e31084548acc9e2f6a3f439e6a013a8"},{"path":"app/drill/session/page.tsx","mode":"100644","type":"blob","sha":"0d0e94bdf1f667a00b6d5826b4a8a5f9be026947","size":751},{"path":"app/exams","mode":"040000","type":"tree","sha":"9596d1ec65ec8ac869ad78ad4b26c9c6cedb34fc"},{"path":"app/exams/[examId]","mode":"040000","type":"tree","sha":"0433bae579828a2a71f2ffa3dd2aaad5d9ae1a7f"},{"path":"app/exams/[examId]/[lessonId]","mode":"040000","type":"tree","sha":"94ea8cac4d37283491e2269dd86b9447197e8b3f"},{"path":"app/exams/[examId]/[lessonId]/page.tsx","mode":"100644","type":"blob","sha":"a2a626e923d3234c7ac525bf212803e6044fe153","size":1083},{"path":"app/exams/[examId]/page.tsx","mode":"100644","type":"blob","sha":"ebe2de7871be642c7c7bf7fdc19a8391a8aa9939","size":838},{"path":"app/globals.css","mode":"100644","type":"blob","sha":"3be3ee716de77008719bd3c5ee7865826f820f6e","size":1168},{"path":"app/guide","mode":"040000","type":"tree","sha":"721cd53c1cda9c873a08feb5b99e82c6b983817b"},{"path":"app/guide/page.tsx","mode":"100644","type":"blob","sha":"7fd27409955c6fb1a67fe00ad2d4fc30abc9c27d","size":328},{"path":"app/icon.svg","mode":"100644","type":"blob","sha":"d18166689a1c996f735922e571c4269fa88e483a","size":690},{"path":"app/layout.tsx","mode":"100644","type":"blob","sha":"f7abe08bae131ed6f3e4af9a7f2734a50fe5918c","size":2238},{"path":"app/mock","mode":"040000","type":"tree","sha":"93ae49f9781456b350168708cc3f7c567f82ecc3"},{"path":"app/mock/[examId]","mode":"040000","type":"tree","sha":"022f19e0f50ae0a6415a9bcec01e778ce84c9438"},{"path":"app/mock/[examId]/[mockId]","mode":"040000","type":"tree","sha":"3069a9103981b988339bc5d89c2625c8117b3fff"},{"path":"app/mock/[examId]/[mockId]/page.tsx","mode":"100644","type":"blob","sha":"94aa8919a4513049b3ad631a89c65b0d5eb15657","size":910},{"path":"app/mock/page.tsx","mode":"100644","type":"blob","sha":"5c26769b4a75252f27d6cb50c96338d0f2a103e5","size":255},{"path":"app/page.tsx","mode":"100644","type":"blob","sha":"82ca338f0f5922a26c457e454d54b74de951e5be","size":97},{"path":"app/path","mode":"040000","type":"tree","sha":"84b1d4e3e3d491778946b92713667ecbe0d1b87f"},{"path":"app/path/page.tsx","mode":"100644","type":"blob","sha":"0d55b46e496df9fedf7e06ac8cb33cf9c0f1582b","size":273},{"path":"app/plans","mode":"040000","type":"tree","sha":"920998b3bdb1dfcb9e65d83992990de858e8d0e7"},{"path":"app/plans/[planId]","mode":"040000","type":"tree","sha":"4359241a68b1d4e2afd7dc71b56545a3f3fc763e"},{"path":"app/plans/[planId]/page.tsx","mode":"100644","type":"blob","sha":"462adebf98b8ff89915f57859583448bfeb0b66f","size":933},{"path":"app/plans/choose","mode":"040000","type":"tree","sha":"c47bdc018be9c228dcedbd81cca1b58a53ce5fc2"},{"path":"app/plans/choose/page.tsx","mode":"100644","type":"blob","sha":"9f9211e941eba71412da298ec19ae87883b2813f","size":874},{"path":"app/plans/page.tsx","mode":"100644","type":"blob","sha":"5327da7ef99c48ca5b12ded5714262430683ff43","size":1042},{"path":"app/practice","mode":"040000","type":"tree","sha":"f8d22a390854aaf61cb6bd64257c21d9525b6763"},{"path":"app/practice/page.tsx","mode":"100644","type":"blob","sha":"80044a93e3cd6f29e93e76b713bc3fe2f3f5020a","size":395},{"path":"app/reference","mode":"040000","type":"tree","sha":"b39ed181aed22a481b06bf8de3e242f5639f0bae"},{"path":"app/reference/page.tsx","mode":"100644","type":"blob","sha":"3f2cf4381229583f2a190e737f84a3ddf2e624af","size":317},{"path":"components","mode":"040000","type":"tree","sha":"6c6af6376dab53087ad054dd7ef30a139d71c2ae"},{"path":"components/app-shell.tsx","mode":"100644","type":"blob","sha":"ff6143add8f97752b23d68f00b1560a922759061","size":16203},{"path":"components/arena-bits.tsx","mode":"100644","type":"blob","sha":"d3f434d3b74f70a0afc6fa85236769a36380dd4e","size":9278},{"path":"components/arena-brief.tsx","mode":"100644","type":"blob","sha":"20d3153ed4f4631e3440379ef583de10f5840d8d","size":6408},{"path":"components/arena-checkoff.tsx","mode":"100644","type":"blob","sha":"dce5998d6835293696100bc0c214ceeb0a197287","size":12809},{"path":"components/arena-clock.tsx","mode":"100644","type":"blob","sha":"dd02d9bdeced5d4f9fea2a31d78a0306e10f5fec","size":8856},{"path":"components/arena-list.tsx","mode":"100644","type":"blob","sha":"0f1200d3319510cf5b68aec56701299e9672a649","size":11952},{"path":"components/arena-review.tsx","mode":"100644","type":"blob","sha":"68e2e1caf1f969783504dc90772e479abdf75d9a","size":6717},{"path":"components/arena-run.tsx","mode":"100644","type":"blob","sha":"f71b5e9369dc139ff932d90e567dcdaac155773e","size":7421},{"path":"components/arena-start.tsx","mode":"100644","type":"blob","sha":"55977c7addf0ac31516409d0119d490cc36bb9b3","size":6609},{"path":"components/code.tsx","mode":"100644","type":"blob","sha":"4ad807e5d785946eb5c47dbc8800554830e35b9d","size":13256},{"path":"components/coding-detail.tsx","mode":"100644","type":"blob","sha":"80fab97d71c21bfeb63b34c109ff0ba345bc7252","size":16769},{"path":"components/coding-list.tsx","mode":"100644","type":"blob","sha":"bea7898bd37dd515648959b21c83b1cb3f77da64","size":15232},{"path":"components/coding-progress.tsx","mode":"100644","type":"blob","sha":"e94882b68eca13e1d11dec8934772cd1869213d1","size":3128},{"path":"components/coding-sandpack.tsx","mode":"100644","type":"blob","sha":"8c49b0988468ab3c141d455809efbfa221d52180","size":4846},{"path":"components/coding-workspace.tsx","mode":"100644","type":"blob","sha":"7e212a1a5d166f4fb4137fb2740583477b158ae1","size":2917},{"path":"components/continue.tsx","mode":"100644","type":"blob","sha":"44c624fc2c0df24fc9c12436bcf04ef11ebe1104","size":15645},{"path":"components/data-flow.tsx","mode":"100644","type":"blob","sha":"d98f3f55d02fc91d5dfbbfee97344fba60558e20","size":4986},{"path":"components/drill-answer.tsx","mode":"100644","type":"blob","sha":"1e34833e3116adf562975544fde4567910642653","size":1197},{"path":"components/drill-card.tsx","mode":"100644","type":"blob","sha":"06ecbedb3aa7bfd5cc98b57f9c3d0b5cf568158f","size":8981},{"path":"components/drill-detail.tsx","mode":"100644","type":"blob","sha":"2f8ce629820a643b9d0bac3b3ac04dba6b1351a8","size":4965},{"path":"components/drill-list.tsx","mode":"100644","type":"blob","sha":"08766bd8b39c913db89e7ed2c6acf10c85f40c70","size":16492},{"path":"components/drill-marks.tsx","mode":"100644","type":"blob","sha":"2afff54d156f80ba9e5f0ce9d462959963c839c8","size":4897},{"path":"components/drill-query.tsx","mode":"100644","type":"blob","sha":"775323df214bb8d80343be8a1f477b5a22ace835","size":3076},{"path":"components/drill-session-page.tsx","mode":"100644","type":"blob","sha":"1743c31bcd824db7a0c1a9914321cd44a862ee8f","size":2396},{"path":"components/drill-session.tsx","mode":"100644","type":"blob","sha":"b016be5360dafb3a2293d75f3f8beb37ede527df","size":27142},{"path":"components/exam-overview.tsx","mode":"100644","type":"blob","sha":"01dd54423ed726d086544688ddf4c400ea3c2443","size":12644},{"path":"components/exercise.tsx","mode":"100644","type":"blob","sha":"607cb8826e63ec9a5002d47b4c6f2ae6c43f32e0","size":34928},{"path":"components/guide.tsx","mode":"100644","type":"blob","sha":"875f6e13c65b39fcc962fe3689a5999f0fdc011e","size":21582},{"path":"components/hint-panel.tsx","mode":"100644","type":"blob","sha":"978ba6b247e5b248c31ab066ab0a26bf84faa688","size":4489},{"path":"components/home-library.tsx","mode":"100644","type":"blob","sha":"560fb1db91171f5e5b021bd1440553f7acb22438","size":13089},{"path":"components/home-tracks.tsx","mode":"100644","type":"blob","sha":"3698d39fd986ef6f6205646ed1457f4481b2e6d9","size":11239},{"path":"components/home.tsx","mode":"100644","type":"blob","sha":"525c32175520ad513574b6d26cc5c3612babc80d","size":2419},{"path":"components/ladder.tsx","mode":"100644","type":"blob","sha":"5a6cf79b8327a894e5fdc093deaacf1686d3bcac","size":7178},{"path":"components/learning-path.tsx","mode":"100644","type":"blob","sha":"2be02db97a3db4dee640154fc6d57783ba040ae3","size":11421},{"path":"components/lesson-body.tsx","mode":"100644","type":"blob","sha":"bc08d3725a6d1977c7bbde32b579ca44a90f751f","size":15526},{"path":"components/lesson-islands.tsx","mode":"100644","type":"blob","sha":"dfeadc8cf28b21e0219c8d07efd4afacf9ae98fe","size":4322},{"path":"components/lesson-kit.tsx","mode":"100644","type":"blob","sha":"db5ea977724311288200c4eeee9492b556e32993","size":24058},{"path":"components/lesson-plan.tsx","mode":"100644","type":"blob","sha":"a0418ad41309ce8cb95a4d4b9e2e18496d7a7bab","size":3893},{"path":"components/local-setup.tsx","mode":"100644","type":"blob","sha":"b1487f796e9ca11c09641edf992d7e103ace6e81","size":11097},{"path":"components/mock-detail.tsx","mode":"100644","type":"blob","sha":"1f2d5f3a06712316541f97bd4bcaa864f5c56aae","size":9353},{"path":"components/mock-score.tsx","mode":"100644","type":"blob","sha":"07cdeeb65031d00f67ae9e5db736747fdc2c509b","size":1917},{"path":"components/mock-workspace.tsx","mode":"100644","type":"blob","sha":"987674bffa14af8c68230dafa531cfdd01d85fb8","size":7436},{"path":"components/plan-cards.tsx","mode":"100644","type":"blob","sha":"71cfdda4332379f142107a453ffd1b58424cb9da","size":6593},{"path":"components/plan-chooser.tsx","mode":"100644","type":"blob","sha":"fd478fad6448e5768cada4d3eda06780c1398924","size":6436},{"path":"components/plan-dash.tsx","mode":"100644","type":"blob","sha":"bf581ee2f078e9bed2c0a3f0dcfa77ff0f1c687e","size":10116},{"path":"components/plan-detail.tsx","mode":"100644","type":"blob","sha":"12505a2b2433ee141aea0d7d7f7e60f76c939916","size":25737},{"path":"components/plan-kit.tsx","mode":"100644","type":"blob","sha":"cdc8d6b0d544a7919f5bd6e532770c6e21fec9cf","size":23824},{"path":"components/plan-list.tsx","mode":"100644","type":"blob","sha":"bfa42d932f49a69154680a651ce488e640cdbd20","size":5287},{"path":"components/plan-mark.tsx","mode":"100644","type":"blob","sha":"1d265c0bc626b046c5444d06a2d00ae4c1562ce4","size":1384},{"path":"components/plan-picker.tsx","mode":"100644","type":"blob","sha":"f092288ee140a5a3a66549e383d7bfe46825ae42","size":9560},{"path":"components/plan-slots.tsx","mode":"100644","type":"blob","sha":"5f8f3ca9ed11791fe8ab9f287505f606756558ac","size":3215},{"path":"components/practice-focus.tsx","mode":"100644","type":"blob","sha":"404cb7099f46edb9fdbfe0aae38b81bcad435eed","size":2764},{"path":"components/practice-page.tsx","mode":"100644","type":"blob","sha":"84494417305ee6d39d1c3504296ab4bd480704d7","size":15271},{"path":"components/practice-progress.tsx","mode":"100644","type":"blob","sha":"681e89a2517e4a486b1f287aadbe2d0b23149050","size":2132},{"path":"components/recent.tsx","mode":"100644","type":"blob","sha":"d5e0387f467bca5cb82cda9ca49d895acf9846a1","size":2261},{"path":"components/reference.tsx","mode":"100644","type":"blob","sha":"9e1262ecb142054544b3d815c2b7da0d69e3e1b6","size":69463},{"path":"components/sandbox.tsx","mode":"100644","type":"blob","sha":"d642fe6b0e395d1d0831098cd9960c0bb76601de","size":7543},{"path":"components/search.tsx","mode":"100644","type":"blob","sha":"b6fe7e0c2bc91c0a35beb23cbf8f3a0894721adb","size":17018},{"path":"components/side-nav.tsx","mode":"100644","type":"blob","sha":"32bbc34951c5f600ac8e9ca7d566a2c971fa1909","size":3629},{"path":"components/sidebars.tsx","mode":"100644","type":"blob","sha":"1feb8d07322afe94143037507cf540f9b388326f","size":37629},{"path":"components/t.tsx","mode":"100644","type":"blob","sha":"2a205830fc802c49914a4683d8ef4cc5e13a4cb0","size":2626},{"path":"content","mode":"040000","type":"tree","sha":"a762e09e18dfddf9bd2de99815ce48fbbbc3ded3"},{"path":"content/arena.ts","mode":"100644","type":"blob","sha":"466a7062ddd44b536fce1430ade17435969ffb05","size":10230},{"path":"content/coding.ts","mode":"100644","type":"blob","sha":"9d9191f2ee497ec28eaea82ba84baa0a586f7d92","size":196390},{"path":"content/drills.ts","mode":"100644","type":"blob","sha":"758b6a7d6c521d52879757b7184d50e88e5ec264","size":7239},{"path":"content/exam-list.ts","mode":"100644","type":"blob","sha":"3c6974cb7d718931bc50120ff16529dec0094d52","size":1064},{"path":"content/exams","mode":"040000","type":"tree","sha":"7ff9985bd845b3c76e24394426c1fcb3f93dae18"},{"path":"content/exams/cab-booking.tsx","mode":"100644","type":"blob","sha":"34f872ee045a3c925524cc1dd268ab56266cd376","size":351886},{"path":"content/exams/fed-part1.tsx","mode":"100644","type":"blob","sha":"8629017946d945c02776c742bf82f641eccd6186","size":244343},{"path":"content/exams/fed-part2.tsx","mode":"100644","type":"blob","sha":"e1790f202911a81477a36c9559c9892b8bec1d22","size":222804},{"path":"content/exams/fed-part3.tsx","mode":"100644","type":"blob","sha":"9e2bb353c83a9973744dc6bb10cb111bf4a91ec3","size":199481},{"path":"content/exams/fed-part4.tsx","mode":"100644","type":"blob","sha":"143af3781c0a06cb5eb816eebec7f005d28f27d3","size":128242},{"path":"content/exams/foundations.tsx","mode":"100644","type":"blob","sha":"d3166537308ef21f5df241e22ba4ee1c9c8dd570","size":259162},{"path":"content/exams/graphql-federation.tsx","mode":"100644","type":"blob","sha":"75ca0dae45e8137c58df1c840578af96673c9cb0","size":66746},{"path":"content/exams/interview.tsx","mode":"100644","type":"blob","sha":"9df22e1ed5da38190439373afb64dcebe19f36a0","size":5173},{"path":"content/exams/iv-backend.tsx","mode":"100644","type":"blob","sha":"8c2fff4c5c4ebc53a7482f7c95f68dc782651482","size":119953},{"path":"content/exams/iv-basics.tsx","mode":"100644","type":"blob","sha":"06c232f2d1d1c87ff83ad49760d023a057d95c9c","size":81967},{"path":"content/exams/iv-coding.tsx","mode":"100644","type":"blob","sha":"e6bfd7f7718d59ced64adbc5f94ecb56f2386105","size":222058},{"path":"content/exams/iv-hand.tsx","mode":"100644","type":"blob","sha":"9613af573f7ba1503c11cb77e69681b23ba3c183","size":98644},{"path":"content/exams/iv-js1.tsx","mode":"100644","type":"blob","sha":"3e82e8a1a5fbeedba5d1b6d277a97134960c71a4","size":145907},{"path":"content/exams/iv-js2.tsx","mode":"100644","type":"blob","sha":"39066ff487978493b19166156056b9edc013660a","size":130568},{"path":"content/exams/iv-react1.tsx","mode":"100644","type":"blob","sha":"2983ab2596721e98fe90329c3c881d6cc9a7eefb","size":120761},{"path":"content/exams/iv-react2.tsx","mode":"100644","type":"blob","sha":"e267c3d99dd55e21294a30fe033eb87d17b5daf8","size":139229},{"path":"content/exams/iv-ts.tsx","mode":"100644","type":"blob","sha":"074d26eef11494f5cfe89b166502956ac2e5567c","size":67987},{"path":"content/exams/react-part1.tsx","mode":"100644","type":"blob","sha":"b2f20d557d7a8ed996e1a83f37d1049b7502057d","size":81962},{"path":"content/exams/react-part2.tsx","mode":"100644","type":"blob","sha":"c3ca2b64b7fc6f9cf8697c499d8b6c0eab587eab","size":111237},{"path":"content/exams/react-part3.tsx","mode":"100644","type":"blob","sha":"3375fee57d2d49f1774663984d0ac09f5b08de67","size":201664},{"path":"content/exams/react-part4.tsx","mode":"100644","type":"blob","sha":"dcd031c5096f1bbf54c3a9e455682bbff5230b5f","size":165084},{"path":"content/exams/react-part5.tsx","mode":"100644","type":"blob","sha":"8f87e83946c40f15fb0876dc11bf7cc975a41984","size":326857},{"path":"content/exams/react.tsx","mode":"100644","type":"blob","sha":"6b2db4a348af1ba278f36a3e9ecc41ccc25c0e4b","size":47632},{"path":"content/helpers.tsx","mode":"100644","type":"blob","sha":"a78dae0313fedee9934014d4037e1d6d8aff98fd","size":2093},{"path":"content/nav-exercises.ts","mode":"100644","type":"blob","sha":"d1b954807175d37b67bad0a30d188e7c1d46b877","size":38891},{"path":"content/nav.ts","mode":"100644","type":"blob","sha":"621df54b4d7184b17cc20325a10d4e867c885a82","size":134329},{"path":"content/path.ts","mode":"100644","type":"blob","sha":"19e10261e9bd0feba99e99aaae66c4f73918aab8","size":6146},{"path":"content/plan-manifest.ts","mode":"100644","type":"blob","sha":"d4477a893be99e9456722a05fd7a24c43fbe6b2a","size":99570},{"path":"content/plans-assert.ts","mode":"100644","type":"blob","sha":"e6e4eb50e04e435e63a33f70f13bb1e3cb34c383","size":3205},{"path":"content/plans.ts","mode":"100644","type":"blob","sha":"0134319f0ad085a75988f913e0406a507b9774dc","size":46603},{"path":"content/registry.ts","mode":"100644","type":"blob","sha":"454dce5185a0a4303d9e5350fe2700352b0313e5","size":4491},{"path":"content/search-index.ts","mode":"100644","type":"blob","sha":"b1d3cda97cb7656e591874769fdbc8720094a475","size":183712},{"path":"content/source-files.ts","mode":"100644","type":"blob","sha":"4492249ef84cb4697437e5083b3badac1d1dadee","size":76085},{"path":"content/track-manifest.ts","mode":"100644","type":"blob","sha":"1669ece6294810a42f0d80dfd1f5581116c782e5","size":23142},{"path":"content/types.ts","mode":"100644","type":"blob","sha":"7f3693e678e30dcb1b13fa46d452b7324b9d7feb","size":20601},{"path":"docs","mode":"040000","type":"tree","sha":"33701356d9029f5b16dcee07a7e858644099524d"},{"path":"docs/drill.jpg","mode":"100644","type":"blob","sha":"1cb1e8e8061c2bd326ecb601ab9ac640e2946f02","size":96959},{"path":"docs/guided-plans.md","mode":"100644","type":"blob","sha":"7bd509eb4fb191cfa91db9d8d729e0d0cf42e0f2","size":15749},{"path":"docs/home.jpg","mode":"100644","type":"blob","sha":"655ea5a09b7d45ab75d33846979428c4ce6749a9","size":68836},{"path":"docs/ia-audit-round3.md","mode":"100644","type":"blob","sha":"5fdba6b17ff04c22b4777769b484cd3922823ff2","size":13383},{"path":"docs/ia-nav","mode":"040000","type":"tree","sha":"34ba5cdc31c904dfd3ba0126c02ce61a3ef42296"},{"path":"docs/ia-nav/assess-after.jpg","mode":"100644","type":"blob","sha":"449f1e625cff37b57d6984c5d2e8ff1b9928e4c9","size":134931},{"path":"docs/ia-nav/assess-before.jpg","mode":"100644","type":"blob","sha":"a71470575e00d0feafeef587cc92a014405fb3e5","size":127631},{"path":"docs/ia-nav/home-after.jpg","mode":"100644","type":"blob","sha":"3d91e9056d9425318551000ed0941d849a0689d0","size":66514},{"path":"docs/ia-nav/home-before.jpg","mode":"100644","type":"blob","sha":"c615c84c0100b1db538d3fa48e60dcfb573458d4","size":96613},{"path":"docs/ia-nav/home-dark-after.jpg","mode":"100644","type":"blob","sha":"02f6ea22497f567f6b9435f2c47fd0e0d06fde1b","size":66404},{"path":"docs/ia-nav/home-dark-before.jpg","mode":"100644","type":"blob","sha":"0e67279e70a5ffa172ad5257f310fe65d831d0ab","size":95416},{"path":"docs/ia-nav/lesson-after.jpg","mode":"100644","type":"blob","sha":"65184ac178efbb0c699b81b19c59393ebe738a8b","size":149021},{"path":"docs/ia-nav/lesson-before.jpg","mode":"100644","type":"blob","sha":"5a690af807b8b7d0b4b6f670142fdcd0bd981f27","size":150616},{"path":"docs/ia-nav/lesson-foot-after.jpg","mode":"100644","type":"blob","sha":"c3971ae05ecbb79776dc001550e1db78ae21c79d","size":133914},{"path":"docs/ia-nav/lesson-foot-before.jpg","mode":"100644","type":"blob","sha":"b49bc0c9190158bb02ff824e028b24aa5663b519","size":127257},{"path":"docs/ia-nav/mobile-drawer-after.jpg","mode":"100644","type":"blob","sha":"cfca93e17ef0af4198e3afcacb1669de1f4dbc77","size":107266},{"path":"docs/ia-nav/mobile-drawer-before.jpg","mode":"100644","type":"blob","sha":"0a3ce7e27725cf6d6198480c0cb76f4bd0aebbe0","size":104327},{"path":"docs/ia-nav/mobile-home-after.jpg","mode":"100644","type":"blob","sha":"5be7b4a40b99901331ede38d4f580180232c1fff","size":81281},{"path":"docs/ia-nav/mobile-home-before.jpg","mode":"100644","type":"blob","sha":"212ea175eefe1114ee818b9c0753ff20e85e0637","size":108708},{"path":"docs/ia-nav/mobile-lesson-after.jpg","mode":"100644","type":"blob","sha":"4689971308cb8d9dad8ed2b0853b65ed604d851d","size":127948},{"path":"docs/ia-nav/mobile-lesson-before.jpg","mode":"100644","type":"blob","sha":"883910dff8134ab12b5913b281ef51f5a78e342b","size":123907},{"path":"docs/ia-nav/path-after.jpg","mode":"100644","type":"blob","sha":"6b2ebbd740be1d63e6146540fbe9339bcb5a72b4","size":124588},{"path":"docs/ia-nav/path-before.jpg","mode":"100644","type":"blob","sha":"0191c37133c73f1d6b3c322d3f37931514aaa52a","size":110623},{"path":"docs/ia-nav/practice-code-after.jpg","mode":"100644","type":"blob","sha":"4442354519520fff93b89ff1ad2459cbd2e10c27","size":118895},{"path":"docs/ia-nav/practice-code-before.jpg","mode":"100644","type":"blob","sha":"d037406dc293d654b95c4ce2206305b03ae92ec4","size":123148},{"path":"docs/ia-nav/review-after.jpg","mode":"100644","type":"blob","sha":"deff0cb61284cc857b662d570e24aa6e5958bb30","size":114676},{"path":"docs/ia-nav/review-before.jpg","mode":"100644","type":"blob","sha":"acd82b66f7ac80df3dcce653985e1f65c9ce10e9","size":117205},{"path":"docs/ia-nav/tablet-lesson-after.jpg","mode":"100644","type":"blob","sha":"a68a81f8ed08b490d48a2ace780dc1c5c08e72de","size":105790},{"path":"docs/ia-nav/tablet-lesson-before.jpg","mode":"100644","type":"blob","sha":"80835d82d035f1410184911726510ef0d54484ec","size":104233},{"path":"docs/intent-nav.md","mode":"100644","type":"blob","sha":"4e8c7dc13e61b53f640b07fecae4cafdf2eacb47","size":8254},{"path":"docs/plans","mode":"040000","type":"tree","sha":"c6cc2a65a546ce2911f2d5711141dae2d62520a6"},{"path":"docs/plans/arena-after.jpg","mode":"100644","type":"blob","sha":"3698284be0b18e84140eb4853ae1080c09b27894","size":137635},{"path":"docs/plans/arena-before.jpg","mode":"100644","type":"blob","sha":"c963e78a6dbeee8a54447f312f4f23e5aaacee82","size":130524},{"path":"docs/plans/coding-after.jpg","mode":"100644","type":"blob","sha":"ad54c535efde786122a56d977ea7aaac3b1dba1f","size":136149},{"path":"docs/plans/coding-before.jpg","mode":"100644","type":"blob","sha":"ebb26832cf37c48bc09000b86aa97d7cfa73a905","size":131825},{"path":"docs/plans/drill-one-after.jpg","mode":"100644","type":"blob","sha":"84f324bb16fde85e74da0e34e1d3ddf4d82c12ee","size":117875},{"path":"docs/plans/drill-one-before.jpg","mode":"100644","type":"blob","sha":"9b8587632a3ab8b2cac70230893212434dafadc7","size":111632},{"path":"docs/plans/home-fresh-after.jpg","mode":"100644","type":"blob","sha":"6c89ea355af53d16308b0801c2eab89541d1aa09","size":118611},{"path":"docs/plans/home-fresh-before.jpg","mode":"100644","type":"blob","sha":"eede12370691e9f81f70a0a5036a889ec59e67a0","size":75690},{"path":"docs/plans/home-noplan-after.jpg","mode":"100644","type":"blob","sha":"ef3335bd4d54b200e4c1d63467d07746b3b6b70c","size":120266},{"path":"docs/plans/home-noplan-before.jpg","mode":"100644","type":"blob","sha":"e19d1b77ff617e952c04ce6ba37ae106f69a7829","size":73703},{"path":"docs/plans/home-plan-after.jpg","mode":"100644","type":"blob","sha":"200b54f7950da7331b1259fd28a06c33670bdc44","size":70987},{"path":"docs/plans/home-plan-before.jpg","mode":"100644","type":"blob","sha":"e19d1b77ff617e952c04ce6ba37ae106f69a7829","size":73703},{"path":"docs/plans/lesson-after.jpg","mode":"100644","type":"blob","sha":"7abc4f6e95d86eb8d71b5848b8e353de21e60539","size":154715},{"path":"docs/plans/lesson-before.jpg","mode":"100644","type":"blob","sha":"64ac27b2b666e4e3cdca33304aca9850d7336e23","size":160009},{"path":"docs/plans/lesson-foot-after.jpg","mode":"100644","type":"blob","sha":"b70b1c28a9aa461e3b4e0076731db7f26f9ce098","size":139446},{"path":"docs/plans/lesson-foot-before.jpg","mode":"100644","type":"blob","sha":"757d900e397415f73275671a55c798e0cd0fadcc","size":136850},{"path":"docs/plans/mobile-drawer-after.jpg","mode":"100644","type":"blob","sha":"e26c1001dd2fbb8fb660ae84d6e106659ac63d98","size":94150},{"path":"docs/plans/mobile-drawer-before.jpg","mode":"100644","type":"blob","sha":"6911b05dc3cd81131b76d87f2361673480541c32","size":103567},{"path":"docs/plans/mobile-home-after.jpg","mode":"100644","type":"blob","sha":"1e6ed6167ea6c794df9858a8c3ff1139bb4e8562","size":87539},{"path":"docs/plans/mobile-home-before.jpg","mode":"100644","type":"blob","sha":"d3e645bd01abb3b607a23a9ca5b28db685ae4a6e","size":88930},{"path":"docs/plans/mobile-plan-after.jpg","mode":"100644","type":"blob","sha":"7dad5f95e0ca7c26530ea693b3cccaaa804c2950","size":119316},{"path":"docs/plans/mobile-plan-before.jpg","mode":"100644","type":"blob","sha":"8511c7bbfd059a3332e34c02ac76abdb5f18da52","size":25412},{"path":"docs/plans/plan-complete-after.jpg","mode":"100644","type":"blob","sha":"83139222dc9a5e8e89bbd2b352285fdfeda55e92","size":97769},{"path":"docs/plans/plan-complete-before.jpg","mode":"100644","type":"blob","sha":"117058ec868b47d9ef41651f13f25039e22e1602","size":19215},{"path":"docs/plans/plan-list-after.jpg","mode":"100644","type":"blob","sha":"b97bcfd48afa61cfaf950ccc2eb34704fe03ed5d","size":97880},{"path":"docs/plans/plan-list-before.jpg","mode":"100644","type":"blob","sha":"117058ec868b47d9ef41651f13f25039e22e1602","size":19215},{"path":"docs/plans/plan-react-after.jpg","mode":"100644","type":"blob","sha":"dd4cbd024e6d3d363017ff6034b14da1d8701834","size":105089},{"path":"docs/plans/plan-react-before.jpg","mode":"100644","type":"blob","sha":"5278ab29306e5b2c2d2621aebfeaac46184f43e1","size":19885},{"path":"docs/plans/plan-react-dark-after.jpg","mode":"100644","type":"blob","sha":"098521499b5edfaeb41a59eb4879a3ff35dcfd2b","size":104141},{"path":"docs/plans/plan-react-dark-before.jpg","mode":"100644","type":"blob","sha":"cca3857ca2b54e1b1c3898d9a978b09b79aa6ab1","size":20217},{"path":"docs/plans/review-after.jpg","mode":"100644","type":"blob","sha":"c7b7345f4a70f56c074b09ad6500c1027bfed2f2","size":124858},{"path":"docs/plans/review-before.jpg","mode":"100644","type":"blob","sha":"5d3fe5064c2b4703027566df14d72a786ea85ccd","size":115340},{"path":"docs/plans/tablet-plan-after.jpg","mode":"100644","type":"blob","sha":"df30ce3b69a42698fbff3941bb801df8fbf8023c","size":76447},{"path":"docs/plans/tablet-plan-before.jpg","mode":"100644","type":"blob","sha":"338effa36cbebd0d48cee0a671e6e601140d60f7","size":14041},{"path":"docs/polish.md","mode":"100644","type":"blob","sha":"0f154c6372d83805e4414894c0206b6383b1ec77","size":6827},{"path":"docs/polish","mode":"040000","type":"tree","sha":"7f1ce82976d7e799c5dde7b353c936616b909ad0"},{"path":"docs/polish/change-plan-1440.jpg","mode":"100644","type":"blob","sha":"14a44d004caa4334036b14dc26079bec91909b1a","size":148259},{"path":"docs/polish/change-plan-390.jpg","mode":"100644","type":"blob","sha":"8521ade35001c317422d1e2411339bd08e4f70a6","size":69846},{"path":"docs/polish/drawer-390.jpg","mode":"100644","type":"blob","sha":"2193f9792afa7fedf24fbccad696c8f13435d70c","size":37997},{"path":"docs/polish/my-plan-1440.jpg","mode":"100644","type":"blob","sha":"98467e0c672472b4a4037c24762b571daafb11bf","size":118105},{"path":"docs/polish/my-plan-390.jpg","mode":"100644","type":"blob","sha":"812cc6db0042991d545d1975a3423ba1a4492c71","size":67662},{"path":"docs/polish/today-1440.jpg","mode":"100644","type":"blob","sha":"182d231d4894f7c6df9a5c5ec6ad3217a2a0186e","size":145062},{"path":"docs/polish/today-390.jpg","mode":"100644","type":"blob","sha":"32d8bce4e8bd2c08e908ef416155f45b01ba2f0e","size":64755},{"path":"docs/polish/today-progress-1440.jpg","mode":"100644","type":"blob","sha":"f1662af70ea04ee525368b450f5dc1dbfb404958","size":150071},{"path":"docs/polish/today-progress-390.jpg","mode":"100644","type":"blob","sha":"bf879b33d088e8320ea4cdb1c9e14c719c3c9610","size":68746},{"path":"docs/polish/track-cards-1440.jpg","mode":"100644","type":"blob","sha":"6f6033758cdc9c4c9d0be58f0be73e4e56473d0c","size":112324},{"path":"docs/polish/track-cards-390.jpg","mode":"100644","type":"blob","sha":"e64e3e942be1345f625206db869cda6f55ecb4c3","size":107865},{"path":"docs/sandpack-evaluation.md","mode":"100644","type":"blob","sha":"97bd36d5708482727386132c9e43c7617f506f04","size":4116},{"path":"docs/ui-v2-polish.md","mode":"100644","type":"blob","sha":"75fbbd09d1ac29b247026a49f6c4f0db7bafdc59","size":2951},{"path":"docs/ui-v2-polish","mode":"040000","type":"tree","sha":"ae039bdc8a4b176e30771395bb130aa5a3d84a26"},{"path":"docs/ui-v2-polish/active-home-after.jpg","mode":"100644","type":"blob","sha":"282bc5f88f049af5c25217841d8be2066a7fe453","size":55649},{"path":"docs/ui-v2-polish/active-home-before.jpg","mode":"100644","type":"blob","sha":"95b3f7a6b140aa62afd5fac8a459ff7b9336a71b","size":55402},{"path":"docs/ui-v2-polish/code-after.jpg","mode":"100644","type":"blob","sha":"13d152b2db63bdaecb9ed926ff2e6c7c2b8327f9","size":80601},{"path":"docs/ui-v2-polish/code-before.jpg","mode":"100644","type":"blob","sha":"ff4f63f9893ee094189c732f8ab9c7d794e10236","size":78184},{"path":"docs/ui-v2-polish/fresh-home-after.jpg","mode":"100644","type":"blob","sha":"a38fc7156e10c19c5b164afec42d24637d300aa8","size":65437},{"path":"docs/ui-v2-polish/fresh-home-before.jpg","mode":"100644","type":"blob","sha":"0b999f9a7706d8e1b63caa441e1edf8a3bb0cc61","size":66730},{"path":"docs/ui-v2-polish/lesson-after.jpg","mode":"100644","type":"blob","sha":"fe281da46698f6395da2715232ed0f0bc98b9dff","size":88845},{"path":"docs/ui-v2-polish/lesson-before.jpg","mode":"100644","type":"blob","sha":"3dd803b8480cfc78e1e800b307069f85c3797d54","size":88304},{"path":"docs/ui-v2-polish/m-home-after.jpg","mode":"100644","type":"blob","sha":"b243cd653cf767be9b43f9ba6e0a212c2ced5695","size":180026},{"path":"docs/ui-v2-polish/m-home-before.jpg","mode":"100644","type":"blob","sha":"2120651ae6c9ce6858d5bbf15a7c67b41d6077cd","size":186070},{"path":"docs/ui-v2-polish/plan-react-after.jpg","mode":"100644","type":"blob","sha":"8191b0ee586b66e8d002b9f69a705890325a753f","size":70175},{"path":"docs/ui-v2-polish/plan-react-before.jpg","mode":"100644","type":"blob","sha":"964ef64677e4723c869c9d72c1c21576c93f27e0","size":70481},{"path":"docs/ui-v2-polish/practice-after.jpg","mode":"100644","type":"blob","sha":"97f4200cd8e33f3352554359e4c42b59807388dc","size":80235},{"path":"docs/ui-v2-polish/practice-before.jpg","mode":"100644","type":"blob","sha":"bf80076fd98039330953a69316edbfbe57d3da6e","size":77951},{"path":"docs/ui-v2-polish/review-after.jpg","mode":"100644","type":"blob","sha":"58da5a1e72d29ffc9f8e609a31ce9ecacfe3474d","size":72024},{"path":"docs/ui-v2-polish/review-before.jpg","mode":"100644","type":"blob","sha":"22d6e05c842256375655b6ad89c60b09d9a2769e","size":70419},{"path":"docs/ui-v2-screens.md","mode":"100644","type":"blob","sha":"42245a5d7debdb2bd91cfd25d1c735dc227a67b8","size":2503},{"path":"docs/ui-v2.md","mode":"100644","type":"blob","sha":"63d525789fd27a990fbb8e420cb590487bdd707b","size":10514},{"path":"docs/ui-v2","mode":"040000","type":"tree","sha":"662cb1fcbc273e9a4f50f239ac3331b809ea7dd6"},{"path":"docs/ui-v2/active-home-after.jpg","mode":"100644","type":"blob","sha":"95b3f7a6b140aa62afd5fac8a459ff7b9336a71b","size":55402},{"path":"docs/ui-v2/active-home-before.jpg","mode":"100644","type":"blob","sha":"ed88353a92e19f9a61e4db0f8e7e4cc35de177f1","size":53110},{"path":"docs/ui-v2/arena-after.jpg","mode":"100644","type":"blob","sha":"008f708260d48f536b41f5d791b2eabf0172e8f7","size":83708},{"path":"docs/ui-v2/arena-before.jpg","mode":"100644","type":"blob","sha":"73ba756248d9ebfb3fa7f1ae9cd8a75d5e66b8ba","size":89762},{"path":"docs/ui-v2/code-after.jpg","mode":"100644","type":"blob","sha":"ff4f63f9893ee094189c732f8ab9c7d794e10236","size":78184},{"path":"docs/ui-v2/code-before.jpg","mode":"100644","type":"blob","sha":"bc6d2aa4e940c2392f128a58f7d4ee1080a6b56a","size":88874},{"path":"docs/ui-v2/fresh-home-after.jpg","mode":"100644","type":"blob","sha":"0b999f9a7706d8e1b63caa441e1edf8a3bb0cc61","size":66730},{"path":"docs/ui-v2/fresh-home-before.jpg","mode":"100644","type":"blob","sha":"0431db78c453a438d38720796ef6055f6645e6f6","size":75751},{"path":"docs/ui-v2/lesson-after.jpg","mode":"100644","type":"blob","sha":"3dd803b8480cfc78e1e800b307069f85c3797d54","size":88304},{"path":"docs/ui-v2/lesson-before.jpg","mode":"100644","type":"blob","sha":"89acad6f945207754afb137f5e6bbda1ec1e94e2","size":102613},{"path":"docs/ui-v2/m-home-after.jpg","mode":"100644","type":"blob","sha":"2120651ae6c9ce6858d5bbf15a7c67b41d6077cd","size":186070},{"path":"docs/ui-v2/m-home-before.jpg","mode":"100644","type":"blob","sha":"145abee647c1b67c3ea00bd4c297c5807c0d9fba","size":160284},{"path":"docs/ui-v2/m-lesson-drawer-after.jpg","mode":"100644","type":"blob","sha":"36fbcfcdce0bf287a84fd25fc753ba81554286f6","size":112063},{"path":"docs/ui-v2/m-lesson-drawer-before.jpg","mode":"100644","type":"blob","sha":"409097995db1bcbfe5aff78f06ed14d5c278633b","size":182735},{"path":"docs/ui-v2/m-plan-after.jpg","mode":"100644","type":"blob","sha":"6cfc085d6619d7df0be4d4a07ee3e679730f3f07","size":188476},{"path":"docs/ui-v2/m-plan-before.jpg","mode":"100644","type":"blob","sha":"97dc1d6d0ee4755a7eb9443bfe6beeaee5cfc20d","size":207580},{"path":"docs/ui-v2/plan-list-after.jpg","mode":"100644","type":"blob","sha":"b7d75a3ca3ccc067ccf2eecfc90732e147b44090","size":76283},{"path":"docs/ui-v2/plan-list-before.jpg","mode":"100644","type":"blob","sha":"0ed0ed62f17159ada5a93bed20389559346a8b99","size":70063},{"path":"docs/ui-v2/plan-react-after.jpg","mode":"100644","type":"blob","sha":"964ef64677e4723c869c9d72c1c21576c93f27e0","size":70481},{"path":"docs/ui-v2/plan-react-before.jpg","mode":"100644","type":"blob","sha":"698a984b2e2c39dd7a352c4aba887ae32df7d8e5","size":64035},{"path":"docs/ui-v2/practice-after.jpg","mode":"100644","type":"blob","sha":"bf80076fd98039330953a69316edbfbe57d3da6e","size":77951},{"path":"docs/ui-v2/practice-before.jpg","mode":"100644","type":"blob","sha":"d56ba01eb8f29b067b028359a2e22462e03ae714","size":90902},{"path":"docs/ui-v2/review-after.jpg","mode":"100644","type":"blob","sha":"22d6e05c842256375655b6ad89c60b09d9a2769e","size":70419},{"path":"docs/ui-v2/review-before.jpg","mode":"100644","type":"blob","sha":"088fcf8b8b7b8bd8ac842bdc05becfaafd71c450","size":86052},{"path":"lib","mode":"040000","type":"tree","sha":"03cf017678169a6791749c15a9a74d20352d8055"},{"path":"lib/coding-labels.ts","mode":"100644","type":"blob","sha":"3734ea5ea1667dda8fe300d7b93ea0c8dc170fbc","size":1341},{"path":"lib/exercise-labels.ts","mode":"100644","type":"blob","sha":"45ff2706a7052dd8ffdbbdabfaf622c67a3e13d7","size":2307},{"path":"lib/highlight.ts","mode":"100644","type":"blob","sha":"d9fd4d14f3bd6769f22b9e44d5cf5ec936f5c594","size":11658},{"path":"lib/list-query.ts","mode":"100644","type":"blob","sha":"032a3c2a9da3ac094f3594454455d375841da3b7","size":2578},{"path":"lib/locale.tsx","mode":"100644","type":"blob","sha":"5c0ddd2fe0a5a2b5625eaa18d0d41c481f2dbf83","size":3976},{"path":"lib/modes.ts","mode":"100644","type":"blob","sha":"d8bae30ecc926d6161e4dd55c95be38567d36300","size":3989},{"path":"lib/plan-lite.ts","mode":"100644","type":"blob","sha":"9d5b2bfe50da16d2393e8015ec10990f93cf84b6","size":4179},{"path":"lib/plan-progress.ts","mode":"100644","type":"blob","sha":"0819cc0224b42d25dce51173a73a5231d0dc4661","size":12662},{"path":"lib/plan-search.ts","mode":"100644","type":"blob","sha":"7bcc8c99cfa0edd5bae79a9242fd3774f07bb07b","size":3004},{"path":"lib/plan-types.ts","mode":"100644","type":"blob","sha":"d1d621afdb240279a6effaddcbba5308d8c27754","size":1500},{"path":"lib/progress.tsx","mode":"100644","type":"blob","sha":"e25a7a254c8fbe46ff8de3fd5e0e8044f7234460","size":25584},{"path":"lib/side-nav.ts","mode":"100644","type":"blob","sha":"0ff6462824bbb0e29a366f83fa8db997e9fe8e72","size":6613},{"path":"lib/theme.tsx","mode":"100644","type":"blob","sha":"7dea6daf27e349d9133d94f259eb50990b6b8489","size":2376},{"path":"lib/track-copy.ts","mode":"100644","type":"blob","sha":"b591ffdfd64c8d227985248d3849dc81cab8a137","size":2572},{"path":"lib/use-active-heading.ts","mode":"100644","type":"blob","sha":"467cbbcad2610183b6c3ce3e4f46cee0c86596cd","size":1870},{"path":"next.config.mjs","mode":"100644","type":"blob","sha":"2503b934cb5608348a04c2f72db76cb881b882ee","size":465},{"path":"package-lock.json","mode":"100644","type":"blob","sha":"5c1915c310f14dd83789441d41f49365046b4584","size":217794},{"path":"package.json","mode":"100644","type":"blob","sha":"bc604fb7e2dc41989b86a8a1f847810aabd7b783","size":738},{"path":"scripts","mode":"040000","type":"tree","sha":"a245204b4120d0f47cf28277d2a487459bee21dc"},{"path":"scripts/audit-code-lines.mjs","mode":"100644","type":"blob","sha":"5ed6c1ea3fdc28f2bcdf126af8cd137aa626edb0","size":17454},{"path":"scripts/dev.sh","mode":"100755","type":"blob","sha":"6b7d5f07bb7eb4a34e8a7ef005de8f655135a87a","size":2099},{"path":"scripts/gen-nav.mjs","mode":"100644","type":"blob","sha":"7feed9734e216670cca542ee40b1790eb19ff597","size":3901},{"path":"scripts/gen-source-files.mjs","mode":"100644","type":"blob","sha":"99c9beba88d5162350d08ea3a00ed28948390574","size":6332},{"path":"scripts/nav-dump-route.txt","mode":"100644","type":"blob","sha":"d226517526e883cdc67b78eaa2204dc8ba93f3fb","size":10255},{"path":"scripts/nav-exercises-template.txt","mode":"100644","type":"blob","sha":"c06d25b5fcc7836bb8e18d5dd580397245949c04","size":1074},{"path":"scripts/nav-template.txt","mode":"100644","type":"blob","sha":"1c69ca23d966a93c6b9e063a4b6830597bcdc880","size":6497},{"path":"scripts/plan-manifest-template.txt","mode":"100644","type":"blob","sha":"bdd1f383175596deb3b993c855f2540085e2c0e0","size":3263},{"path":"scripts/search-index-template.txt","mode":"100644","type":"blob","sha":"8cf23f28cb34ebcb827760f40f66711fe84c529d","size":1645},{"path":"scripts/source-files-template.txt","mode":"100644","type":"blob","sha":"3fa93f80e8dc70ab2155e178f70c5f5e2fd20ef0","size":1699},{"path":"scripts/track-manifest-template.txt","mode":"100644","type":"blob","sha":"d8969dc6a481b647b7ec713bc16397008cff6362","size":1750},{"path":"styles","mode":"040000","type":"tree","sha":"1fa06f8578af77f33e65dec686d89adfe1890df3"},{"path":"styles/arena.css","mode":"100644","type":"blob","sha":"76009ff6337d52a8a09ec2ac04a9fbc4b4495625","size":13573},{"path":"styles/base.css","mode":"100644","type":"blob","sha":"eccf310d9e1d3a80cf9b6ec3e4cb07ceb914c8d3","size":8650},{"path":"styles/code.css","mode":"100644","type":"blob","sha":"268481927ab0a52d913477bfc0f46e9fd0d0c010","size":14095},{"path":"styles/coding.css","mode":"100644","type":"blob","sha":"a334cb027cc3275fbd11b983ef811a8ecec11cf9","size":12490},{"path":"styles/dash.css","mode":"100644","type":"blob","sha":"d560cb6ee42b2c68d36084f51a3dbb8ca89e702e","size":22538},{"path":"styles/drill.css","mode":"100644","type":"blob","sha":"94b2763834eb3cfb634b9041eb0940895bd2674f","size":18053},{"path":"styles/exercise.css","mode":"100644","type":"blob","sha":"6437f1a862b608bcca2950f354a7ad6b96c608b3","size":24317},{"path":"styles/layout.css","mode":"100644","type":"blob","sha":"ff4f630dccc3d481ad8b1604dd28a3d203aec37c","size":9140},{"path":"styles/lesson.css","mode":"100644","type":"blob","sha":"870e792749ef10cf0f50f6e08d3e10841e46b484","size":32007},{"path":"styles/nav.css","mode":"100644","type":"blob","sha":"6ebeef30040282c991eee6149cb5adc6954e20c2","size":33797},{"path":"styles/plans.css","mode":"100644","type":"blob","sha":"457c91d66df1059dfb266b3fab91bcf9bc9f6d28","size":38444},{"path":"styles/shell.css","mode":"100644","type":"blob","sha":"59eba134f8ea01b00495db205398f98d6c55633e","size":32211},{"path":"styles/tokens.css","mode":"100644","type":"blob","sha":"0c933b508cb78adee2dcae333e22851af4c62daf","size":10958},{"path":"styles/viz.css","mode":"100644","type":"blob","sha":"ad3dbdc6b28ded38a9bd003df85f26179fae7f69","size":6258},{"path":"tsconfig.json","mode":"100644","type":"blob","sha":"bde17a6e0e047489b711befe4660189f63127d9d","size":563}]}},"tags":[]} diff --git a/fixtures/index.json b/fixtures/index.json index 5168300..1811b1d 100644 --- a/fixtures/index.json +++ b/fixtures/index.json @@ -2,9 +2,9 @@ "rtm-fixtures/one-commit": "one-commit", "rtm-fixtures/empty-repo": "empty-repo", "rtm-fixtures/big-history": "big-history", + "rtm-fixtures/sample-app": "sample-app", + "rtm-fixtures/tiny-app": "tiny-app", "rtm-fixtures/rate-limited": "__error:rate-limited", "rtm-fixtures/private-repo": "__error:private", - "rtm-fixtures/slow-repo": "__error:timeout", - "renrenmimi/renren-across-tabs": "renren-across-tabs", - "renrenmimi/drilllab": "drilllab" + "rtm-fixtures/slow-repo": "__error:timeout" } diff --git a/fixtures/renren-across-tabs/bundle.json b/fixtures/renren-across-tabs/bundle.json deleted file mode 100644 index 3e91290..0000000 --- a/fixtures/renren-across-tabs/bundle.json +++ /dev/null @@ -1 +0,0 @@ -{"repo":{"name":"renren-across-tabs","full_name":"renrenmimi/renren-across-tabs","owner":{"login":"renrenmimi"},"description":"A tiny browser world where Renren moves between tabs — no backend, just BroadcastChannel.","default_branch":"main","html_url":"https://github.com/renrenmimi/renren-across-tabs","created_at":"2026-08-19T05:40:25Z","pushed_at":"2026-08-19T06:15:03Z","language":"TypeScript","stargazers_count":0,"forks_count":0,"size":5283,"license":{"spdx_id":"NOASSERTION","name":"Other"},"topics":[],"archived":false,"fork":false},"commits":[{"sha":"6e3b18aacfc568f4680c64714ab4426ac56052b9","html_url":"https://github.com/renrenmimi/renren-across-tabs/commit/6e3b18aacfc568f4680c64714ab4426ac56052b9","commit":{"message":"Round Renren's cartoon face","author":{"name":"WEIREN FENG","date":"2026-08-19T06:15:02Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-19T06:15:02Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"9fe0a9690936dcc02f00f15b88b35f988fcc5526"}]},{"sha":"9fe0a9690936dcc02f00f15b88b35f988fcc5526","html_url":"https://github.com/renrenmimi/renren-across-tabs/commit/9fe0a9690936dcc02f00f15b88b35f988fcc5526","commit":{"message":"Make Renren a friendly cartoon guide","author":{"name":"WEIREN FENG","date":"2026-08-19T06:10:08Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-19T06:10:08Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"0f1ebeecf6607fd5d465db401fc866ca4cdd720b"}]},{"sha":"0f1ebeecf6607fd5d465db401fc866ca4cdd720b","html_url":"https://github.com/renrenmimi/renren-across-tabs/commit/0f1ebeecf6607fd5d465db401fc866ca4cdd720b","commit":{"message":"Refine Renren character proportions","author":{"name":"WEIREN FENG","date":"2026-08-19T05:46:10Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-19T05:46:10Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"e9558300657918150bd7d09708e887c6c5716498"}]},{"sha":"e9558300657918150bd7d09708e887c6c5716498","html_url":"https://github.com/renrenmimi/renren-across-tabs/commit/e9558300657918150bd7d09708e887c6c5716498","commit":{"message":"Add live demo link","author":{"name":"WEIREN FENG","date":"2026-08-19T05:41:49Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-19T05:41:49Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[{"sha":"3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1"}]},{"sha":"3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1","html_url":"https://github.com/renrenmimi/renren-across-tabs/commit/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1","commit":{"message":"Build Renren Across Tabs","author":{"name":"WEIREN FENG","date":"2026-08-19T05:40:20Z"},"committer":{"name":"WEIREN FENG","date":"2026-08-19T05:40:20Z"}},"author":{"login":"renrenmimi","avatar_url":null},"parents":[]}],"commitDetail":{"3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1":{"sha":"3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1","stats":{"additions":2540,"deletions":0,"total":2540},"files":[{"filename":".github/workflows/ci.yml","previous_filename":null,"status":"added","additions":22,"deletions":0,"changes":22,"patch":"@@ -0,0 +1,22 @@\n+name: CI\n+\n+on:\n+ push:\n+ branches: [main]\n+ pull_request:\n+\n+permissions:\n+ contents: read\n+\n+jobs:\n+ verify:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@v6\n+ - uses: actions/setup-node@v5\n+ with:\n+ node-version: 22\n+ cache: npm\n+ - run: npm ci\n+ - run: npm test\n+ - run: npm run build","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/.github%2Fworkflows%2Fci.yml"},{"filename":".github/workflows/deploy.yml","previous_filename":null,"status":"added","additions":43,"deletions":0,"changes":43,"patch":"@@ -0,0 +1,43 @@\n+name: Deploy to GitHub Pages\n+\n+on:\n+ push:\n+ branches: [main]\n+ workflow_dispatch:\n+\n+permissions:\n+ contents: read\n+ pages: write\n+ id-token: write\n+\n+concurrency:\n+ group: pages\n+ cancel-in-progress: true\n+\n+jobs:\n+ build:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@v6\n+ - uses: actions/setup-node@v5\n+ with:\n+ node-version: 22\n+ ","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/.github%2Fworkflows%2Fdeploy.yml"},{"filename":".gitignore","previous_filename":null,"status":"added","additions":6,"deletions":0,"changes":6,"patch":"@@ -0,0 +1,6 @@\n+node_modules\n+dist\n+.DS_Store\n+coverage\n+*.local\n+*.tsbuildinfo","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/.gitignore"},{"filename":"LICENSE","previous_filename":null,"status":"added","additions":24,"deletions":0,"changes":24,"patch":"@@ -0,0 +1,24 @@\n+MIT License\n+\n+Copyright (c) 2026 Weiren Feng\n+\n+Permission is hereby granted, free of charge, to any person obtaining a copy\n+of this software and associated documentation files (the \"Software\"), to deal\n+in the Software without restriction, including without limitation the rights\n+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n+copies of the Software, and to permit perso","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/LICENSE"},{"filename":"README.md","previous_filename":null,"status":"added","additions":43,"deletions":0,"changes":43,"patch":"@@ -0,0 +1,43 @@\n+# Renren Across Tabs\n+\n+Renren lives between browser tabs. Open a second room, watch both pages discover each other, then send him through a door. He leaves one tab and appears in the other — no account and no application server.\n+\n+## What it demonstrates\n+\n+- Cross-tab presence and messaging with `BroadcastChannel`\n+- Deterministic coordinator election from a changing peer set\n+- Versioned shared ","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/README.md"},{"filename":"index.html","previous_filename":null,"status":"added","additions":18,"deletions":0,"changes":18,"patch":"@@ -0,0 +1,18 @@\n+<!doctype html>\n+<html lang=\"en\">\n+ <head>\n+ <meta charset=\"UTF-8\" />\n+ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n+ <meta name=\"theme-color\" content=\"#0c1220\" />\n+ <meta\n+ name=\"description\"\n+ content=\"Renren lives across your browser tabs. Open another room, then send him through the door.\"\n+ />\n+ <title>Renren Across Tabs\n+ \n+ \n+ \n+ \n+ \n+ ('#app')\n+if (!app) throw new Error('App root is missing')\n+\n+const tabId = resolveTabId()\n+const room = roomForId(tabId)\n+const self: Peer = { id: tabId, room, visible: !document.hidd","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/src%2Fmain.ts"},{"filename":"src/rooms.ts","previous_filename":null,"status":"added","additions":19,"deletions":0,"changes":19,"patch":"@@ -0,0 +1,19 @@\n+import type { RoomPreset } from './types'\n+\n+export const ROOMS: readonly RoomPreset[] = [\n+ { name: 'The Sunroom', shortName: 'Sunroom', hue: 37, icon: '☀' },\n+ { name: 'The Blue Studio', shortName: 'Studio', hue: 206, icon: '✦' },\n+ { name: 'The Rooftop', shortName: 'Rooftop', hue: 275, icon: '☾' },\n+ { name: 'The Greenhouse', shortName: 'Greenhouse', hue: 145, icon: '⌁' },\n+ { name: 'The Rea","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/src%2Frooms.ts"},{"filename":"src/state.test.ts","previous_filename":null,"status":"added","additions":31,"deletions":0,"changes":31,"patch":"@@ -0,0 +1,31 @@\n+import { describe, expect, it } from 'vitest'\n+import { clamp, createInitialState, electCoordinator, isNewerState, reduceState } from './state'\n+\n+describe('Renren state', () => {\n+ it('elects the same coordinator regardless of discovery order', () => {\n+ expect(electCoordinator(['tab-z', 'tab-a', 'tab-m'])).toBe('tab-a')\n+ expect(electCoordinator(['tab-m', 'tab-z', 'tab-a'])).toBe('tab-a')\n+","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/src%2Fstate.test.ts"},{"filename":"src/state.ts","previous_filename":null,"status":"added","additions":82,"deletions":0,"changes":82,"patch":"@@ -0,0 +1,82 @@\n+import type { RenrenState, StateAction } from './types'\n+\n+const MAX_JOURNAL = 12\n+\n+export function clamp(value: number, min = 0, max = 100): number {\n+ return Math.min(max, Math.max(min, value))\n+}\n+\n+export function createInitialState(ownerId: string, ownerRoom: string, at = Date.now()): RenrenState {\n+ return {\n+ version: 1,\n+ ownerId,\n+ ownerRoom,\n+ mood: 72,\n+ energy: 84,\n+ ","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/src%2Fstate.ts"},{"filename":"src/style.css","previous_filename":null,"status":"added","additions":127,"deletions":0,"changes":127,"patch":"@@ -0,0 +1,127 @@\n+@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');\n+\n+:root {\n+ font-family: 'Manrope', system-ui, sans-serif;\n+ color: #edf3f8;\n+ background: #080d16;\n+ font-synthesis: none;\n+ text-rendering: optimizeLegibility;\n+ --ink: #080d16;\n+ --surface: #0f1725;\n+ --surface-2: #141e2f;\n+ --line: rgba(255, 255, 255, .1);","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/src%2Fstyle.css"},{"filename":"src/sync.ts","previous_filename":null,"status":"added","additions":189,"deletions":0,"changes":189,"patch":"@@ -0,0 +1,189 @@\n+import { createInitialState, electCoordinator, isNewerState, reduceState } from './state'\n+import type { Peer, RenrenState, StateAction, WireMessage } from './types'\n+\n+const CHANNEL = 'renren-across-tabs-v1'\n+const STORAGE_KEY = 'renren-across-tabs.state.v1'\n+const HEARTBEAT_MS = 900\n+const PEER_TIMEOUT_MS = 3_200\n+const RECOVERY_GRACE_MS = 1_900\n+\n+type Listener = (snapshot: SyncSnapshot) => void","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/src%2Fsync.ts"},{"filename":"src/types.ts","previous_filename":null,"status":"added","additions":43,"deletions":0,"changes":43,"patch":"@@ -0,0 +1,43 @@\n+export type RoomPreset = {\n+ name: string\n+ shortName: string\n+ hue: number\n+ icon: string\n+}\n+\n+export type Peer = {\n+ id: string\n+ room: RoomPreset\n+ visible: boolean\n+ lastSeen: number\n+}\n+\n+export type JournalEntry = {\n+ id: string\n+ at: number\n+ text: string\n+}\n+\n+export type RenrenState = {\n+ version: number\n+ ownerId: string\n+ ownerRoom: string\n+ mood: number\n+ energy: number\n","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/src%2Ftypes.ts"},{"filename":"src/vite-env.d.ts","previous_filename":null,"status":"added","additions":1,"deletions":0,"changes":1,"patch":"@@ -0,0 +1 @@\n+/// ","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/src%2Fvite-env.d.ts"},{"filename":"tsconfig.json","previous_filename":null,"status":"added","additions":19,"deletions":0,"changes":19,"patch":"@@ -0,0 +1,19 @@\n+{\n+ \"compilerOptions\": {\n+ \"target\": \"ES2023\",\n+ \"useDefineForClassFields\": true,\n+ \"module\": \"ESNext\",\n+ \"moduleResolution\": \"Bundler\",\n+ \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n+ \"allowImportingTsExtensions\": false,\n+ \"verbatimModuleSyntax\": true,\n+ \"strict\": true,\n+ \"noUnusedLocals\": true,\n+ \"noUnusedParameters\": true,\n+ \"noFallthroughCasesInSwitch\": true,\n+ ","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/tsconfig.json"},{"filename":"vite.config.ts","previous_filename":null,"status":"added","additions":5,"deletions":0,"changes":5,"patch":"@@ -0,0 +1,5 @@\n+import { defineConfig } from 'vite'\n+\n+export default defineConfig({\n+ base: './',\n+})","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/3f7c4881bca3dca0fd1b6fe190bb9e9b935c45a1/vite.config.ts"}]},"e9558300657918150bd7d09708e887c6c5716498":{"sha":"e9558300657918150bd7d09708e887c6c5716498","stats":{"additions":2,"deletions":0,"total":2},"files":[{"filename":"README.md","previous_filename":null,"status":"modified","additions":2,"deletions":0,"changes":2,"patch":"@@ -2,6 +2,8 @@\n \n Renren lives between browser tabs. Open a second room, watch both pages discover each other, then send him through a door. He leaves one tab and appears in the other — no account and no application server.\n \n+**[Open the live experiment →](https://renrenmimi.github.io/renren-across-tabs/)**\n+\n ## What it demonstrates\n \n - Cross-tab presence and messaging with `BroadcastChannel`","blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/e9558300657918150bd7d09708e887c6c5716498/README.md"}]},"0f1ebeecf6607fd5d465db401fc866ca4cdd720b":{"sha":"0f1ebeecf6607fd5d465db401fc866ca4cdd720b","stats":{"additions":1,"deletions":1,"total":2},"files":[{"filename":"public/renren-character.png","previous_filename":null,"status":"modified","additions":0,"deletions":0,"changes":0,"patch":null,"blob_url":"https://github.com/renrenmimi/renren-across-tabs/blob/0f1ebeecf6607fd5d465db401fc866ca4cdd720b/public%2Frenren-character.png"},{"filename":"src/main.ts","previous_filename":null,"status":"modified","additions":1,"deletions":1,"changes":2,"patch":"@@ -47,7 +47,7 @@ app.innerHTML = `\n \n
\n

Give me a second. I’m checking the doors.

\n- \"Renren,\n+ \"Renren,\n

Give me a second. I’m checking the doors.

\n- \"Renren,\n+ \"A { + const files = + i === 0 + ? rootFiles.map((filename) => ({ + filename, + previous_filename: null, + status: 'added', + additions: 20 + (i % 7), + deletions: 0, + changes: 20 + (i % 7), + patch: `@@ -0,0 +1,3 @@\n+line one\n+line two\n+line three`, + blob_url: null, + })) + : [ + { + filename: paths[rootFiles.length + i - 1], + previous_filename: null, + status: 'added', + additions: 14 + ((i * 5) % 40), + deletions: 0, + changes: 14 + ((i * 5) % 40), + patch: `@@ -0,0 +1,3 @@\n+export const step${i} = ${i};\n+\n+export default step${i};`, + blob_url: null, + }, + ]; + commitDetail[commit.sha] = { + sha: commit.sha, + stats: { + additions: files.reduce((sum, f) => sum + f.additions, 0), + deletions: 0, + total: files.reduce((sum, f) => sum + f.additions, 0), + }, + files, + }; + }); + + const treeFor = (count) => ({ + sha: hash(`${name}:tree:${count}`), + truncated: false, + tree: paths.slice(0, count).map((p, i) => ({ + path: p, + mode: '100644', + type: 'blob', + sha: hash(`${name}:blob:${p}`), + size: 320 + ((i * 53) % 4200), + })), + }); + + const tree = {}; + for (const index of spread(total, treeSlots)) { + const count = index === 0 ? rootFiles.length : rootFiles.length + index; + tree[oldestFirst[index].sha] = treeFor(count); + } + + return { + repo: syntheticRepo(name, { description, size: 240 + total * 6 }), + commits, + commitDetail, + tree, + tags: total >= 8 ? [{ name: 'v1.0.0', commit: { sha: oldestFirst[total - 2].sha } }] : [], + }; +} + +const sampleAppBundle = () => + appBundle('sample-app', 12, { + treeSlots: 3, + description: 'Synthetic fixture repository standing in for a live GitHub repository.', + }); + +const tinyAppBundle = () => + appBundle('tiny-app', 5, { + treeSlots: 2, + description: 'Synthetic five-commit fixture used to pin the request budget.', + }); + // -------------------------------------------------------------------- main --- async function writeBundle(directory, bundle) { @@ -370,6 +489,8 @@ async function main() { 'rtm-fixtures/one-commit': 'one-commit', 'rtm-fixtures/empty-repo': 'empty-repo', 'rtm-fixtures/big-history': 'big-history', + 'rtm-fixtures/sample-app': 'sample-app', + 'rtm-fixtures/tiny-app': 'tiny-app', // Directives that make the server produce a specific failure. 'rtm-fixtures/rate-limited': '__error:rate-limited', 'rtm-fixtures/private-repo': '__error:private', @@ -380,21 +501,18 @@ async function main() { await writeBundle('empty-repo', emptyBundle()); await writeBundle('big-history', bigHistoryBundle()); - if (process.env.RTM_SKIP_NETWORK === '1') { - process.stdout.write('skipping recorded repositories (RTM_SKIP_NETWORK=1)\n'); - } else { - const recorded = [ - // Every commit's diff is recorded so fixture mode can serve a correct tree - // for any commit, derived the same way the client does it. - ['renrenmimi', 'renren-across-tabs', { treeSlots: 5, detailSlots: 5, maxCommits: 100 }], - ['renrenmimi', 'DrillLab', { treeSlots: 5, detailSlots: 64, maxCommits: 100 }], - ]; - for (const [owner, repo, options] of recorded) { - const bundle = await record(owner, repo, options); - const directory = repo.toLowerCase(); - await writeBundle(directory, bundle); - index[`${owner}/${repo}`.toLowerCase()] = directory; - } + await writeBundle('sample-app', sampleAppBundle()); + await writeBundle('tiny-app', tinyAppBundle()); + + // Recording a live repository is opt-in and must be named on the command line. + // Nothing personal is recorded by default. + const requested = process.argv.slice(2).filter((value) => value.includes('/')); + for (const slug of requested) { + const [owner, repo] = slug.split('/'); + const bundle = await record(owner, repo, { treeSlots: 4, detailSlots: 40, maxCommits: 100 }); + const directory = repo.toLowerCase(); + await writeBundle(directory, bundle); + index[slug.toLowerCase()] = directory; } await writeFile(path.join(ROOT, 'index.json'), `${JSON.stringify(index, null, 2)}\n`, 'utf8'); diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs index 36c74f5..f39cd83 100644 --- a/scripts/screenshots.mjs +++ b/scripts/screenshots.mjs @@ -2,7 +2,8 @@ /** * Captures the screenshots used to review layout and hierarchy. * - * Point it at a running server. Fixture mode gives deterministic images: + * Point it at a running server. The built-in demo is deterministic on its own; + * fixture mode additionally makes the loaded-range shot reproducible: * * npm run build * RTM_FIXTURE_MODE=1 npx next start -p 3311 & @@ -18,7 +19,9 @@ import { chromium } from '@playwright/test'; const BASE = (process.env.RTM_BASE_URL ?? 'http://127.0.0.1:3000').replace(/\/$/, ''); const OUT = path.join(process.cwd(), 'docs', 'screenshots'); -const REPO = 'renrenmimi/DrillLab'; +/** The built-in demo needs no fixture mode and no GitHub quota. */ +const REPO = 'demo/learning-platform'; +/** Only reachable with RTM_FIXTURE_MODE=1; skipped otherwise. */ const BIG = 'rtm-fixtures/big-history'; /** @type {{name: string, width: number, height: number, path: string, steps?: (page: import('@playwright/test').Page) => Promise}[]} */ diff --git a/src/components/CommitPanel.tsx b/src/components/CommitPanel.tsx index 3ae4c07..3b252be 100644 --- a/src/components/CommitPanel.tsx +++ b/src/components/CommitPanel.tsx @@ -48,10 +48,18 @@ export function CommitPanel({ commit, previous, detail, detailPending, milestone Commit - - {commit.shortSha} - - + {/* Built-in commits have no real URL, so there is nothing to link to and + no "view on GitHub" affordance is shown. */} + {commit.htmlUrl ? ( + + {commit.shortSha} + + + ) : ( + + {commit.shortSha} + + )}
diff --git a/src/components/InsightsPanel.tsx b/src/components/InsightsPanel.tsx index 5b9f00e..a2f2794 100644 --- a/src/components/InsightsPanel.tsx +++ b/src/components/InsightsPanel.tsx @@ -6,6 +6,7 @@ import type { Milestone } from '@/lib/milestones/detect'; import type { ActivityMap, GrowthSeries } from '@/lib/stats/growth'; import { estimateLanguages } from '@/lib/stats/growth'; import type { Projection } from '@/lib/tree/projector'; +import { BUILTIN_DEMO } from '@/lib/demos'; import { formatDate, formatNumber } from '@/lib/format'; import { ActivityGrid, GrowthChart, LanguageBar } from './Charts'; import { useVirtualRows } from './hooks'; @@ -96,8 +97,20 @@ function GrowthTab({ meta, commits, currentIndex, projection, growth, activity,
{meta ? (
-

{meta.slug}

+

+ {meta.dataSource === 'builtin' ? BUILTIN_DEMO.title : meta.slug} + {meta.dataSource === 'builtin' ? {BUILTIN_DEMO.badge} : null} +

{meta.description ?

{meta.description}

: null} + {meta.dataSource === 'builtin' ? ( +

{BUILTIN_DEMO.disclosure}

+ ) : meta.htmlUrl ? ( +

+ + Open on GitHub + +

+ ) : null}
branch
diff --git a/src/components/States.tsx b/src/components/States.tsx index 119d4f3..0e710a8 100644 --- a/src/components/States.tsx +++ b/src/components/States.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import type { ApiErrorInfo } from '@/lib/client/api'; -import { DEMOS } from '@/lib/demos'; +import { BUILTIN_DEMO } from '@/lib/demos'; import { formatRelativeSeconds } from '@/lib/format'; import type { RepoRef } from '@/lib/repo-ref'; import { RepoInput } from './RepoInput'; @@ -12,7 +12,15 @@ import styles from './states.module.css'; * The idle screen. Deliberately not a marketing hero: it explains what the tool * does, what "loaded history" means, and gets out of the way. */ -export function Landing({ onSelect, busy }: { onSelect: (ref: RepoRef) => void; busy: boolean }) { +export function Landing({ + onOpenDemo, + onSelect, + busy, +}: { + onOpenDemo: () => void; + onSelect: (ref: RepoRef) => void; + busy: boolean; +}) { return (
@@ -22,36 +30,42 @@ export function Landing({ onSelect, busy }: { onSelect: (ref: RepoRef) => void; Watch a repository grow, commit by commit.

- Paste a public GitHub repository. Repo Time Machine reconstructs the file tree at each commit, shows what - changed, and marks the commits where the project visibly turned a corner. + Reconstructs the file tree at each commit, shows what changed, and marks the commits where a project + visibly turned a corner. Start with the built-in demo, or point it at any public GitHub repository.

-
- -
-
+
+

+ + {BUILTIN_DEMO.badge} +

+

+ {BUILTIN_DEMO.title} +

+

{BUILTIN_DEMO.description}

+

{BUILTIN_DEMO.disclosure}

+ +

+ {BUILTIN_DEMO.commitCount} commits · no GitHub account · 0 API requests +

+
-
-

Start with one of these

-
    - {DEMOS.map((demo) => ( -
  • - -
  • - ))} -
+
+

Or explore a live repository

+
+ +
+

What it reads

- Public repositories only, through GitHub’s official REST API. No login, no cloning, and nothing from - your repository is ever executed. + Public repositories only, through GitHub’s official REST API. No login, no cloning, and nothing + from your repository is ever executed.

@@ -74,14 +88,25 @@ export function Landing({ onSelect, busy }: { onSelect: (ref: RepoRef) => void; ); } +/** The mark that distinguishes built-in data from a live repository. */ +export function BuiltinGlyph() { + return ( + + ); +} + type ErrorProps = { error: ApiErrorInfo; slug: string | null; onRetry: () => void; onClear: () => void; + onOpenDemo: () => void; }; -export function ErrorState({ error, slug, onRetry, onClear }: ErrorProps) { +export function ErrorState({ error, slug, onRetry, onClear, onOpenDemo }: ErrorProps) { const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); useEffect(() => { @@ -125,10 +150,20 @@ export function ErrorState({ error, slug, onRetry, onClear }: ErrorProps) { +
+ + {error.code === 'rate-limited' ? ( +

+ The built-in demo is curated synthetic data. It loads without a GitHub account and costs no quota, but it + is not the repository you asked for. +

+ ) : null}
); diff --git a/src/components/TimeMachine.tsx b/src/components/TimeMachine.tsx index 2428f47..5dfaa08 100644 --- a/src/components/TimeMachine.tsx +++ b/src/components/TimeMachine.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { DEMOS } from '@/lib/demos'; +import { BUILTIN_DEMO } from '@/lib/demos'; import { EMPTY_HISTORY_STATE, HistoryController, @@ -16,7 +16,7 @@ import { CommitPanel } from './CommitPanel'; import { InsightsPanel } from './InsightsPanel'; import { RateLimitMeter } from './RateLimitMeter'; import { RepoInput } from './RepoInput'; -import { EmptyRepoState, ErrorState, Landing, LoadingState } from './States'; +import { BuiltinGlyph, EmptyRepoState, ErrorState, Landing, LoadingState } from './States'; import { Transport } from './Transport'; import { TreePanel } from './TreePanel'; import { usePrefersReducedMotion } from './hooks'; @@ -83,6 +83,10 @@ export function TimeMachine() { controller.reset(); }, [controller, writeUrl]); + const openBuiltinDemo = useCallback(() => { + openRepo(BUILTIN_DEMO.ref); + }, [openRepo]); + // Keep the share URL current. Playback steps replace the entry; deliberate // jumps push one, so Back returns to where the visitor jumped from. const lastPushedRef = useRef(null); @@ -221,6 +225,8 @@ export function TimeMachine() { const busy = state.status === 'loading'; const showTimeline = state.status === 'ready' && state.commits.length > 0; + // Comes from the server, never inferred from a label. + const isBuiltin = state.meta?.dataSource === 'builtin'; return (
@@ -245,11 +251,19 @@ export function TimeMachine() {
- {state.status === 'idle' ? : null} + {state.status === 'idle' ? ( + + ) : null} {state.status === 'loading' ? : null} @@ -304,6 +325,7 @@ export function TimeMachine() { slug={state.ref?.slug ?? null} onRetry={() => state.ref && void controller.load(state.ref)} onClear={clearRepo} + onOpenDemo={openBuiltinDemo} /> ) : null} diff --git a/src/components/commit-panel.module.css b/src/components/commit-panel.module.css index 1559c78..4a70df2 100644 --- a/src/components/commit-panel.module.css +++ b/src/components/commit-panel.module.css @@ -15,6 +15,18 @@ border-radius: 999px; } +.shaPlain { + display: inline-flex; + align-items: center; + padding: 1px var(--space-2); + border: 1px dashed var(--line); + border-radius: 999px; + font-family: var(--font-mono); + font-size: var(--text-2xs); + color: var(--text-faint); + cursor: help; +} + .shaLink:hover { color: var(--signal); border-color: var(--line-signal); diff --git a/src/components/insights-panel.module.css b/src/components/insights-panel.module.css index 2be87a6..a8ecf3b 100644 --- a/src/components/insights-panel.module.css +++ b/src/components/insights-panel.module.css @@ -96,6 +96,15 @@ overflow-wrap: anywhere; } +.disclosureInline { + margin-bottom: var(--space-3); + padding-left: var(--space-2); + border-left: 2px solid var(--line-strong); + font-size: var(--text-2xs); + line-height: 1.5; + color: var(--text-faint); +} + .facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/components/shell.module.css b/src/components/shell.module.css index cef9104..f9d0940 100644 --- a/src/components/shell.module.css +++ b/src/components/shell.module.css @@ -111,65 +111,88 @@ } } -/* --------------------------------------------------------------- demo bar */ +/* ------------------------------------------------------------- source bar */ -.demoBar[hidden] { +.sourceBar[hidden] { display: none; } -.demoBar { +.sourceBar { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-4); border-bottom: 1px solid var(--line-faint); - overflow-x: auto; - scrollbar-width: none; - /* The strip scrolls sideways on narrow screens; fade the cut edge. */ - mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent 100%); + min-width: 0; } -.demoBar::-webkit-scrollbar { - display: none; +.sourceNote { + display: flex; + align-items: baseline; + gap: var(--space-2); + min-width: 0; + font-size: var(--text-2xs); + color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.demoLabel { - font-size: var(--text-3xs); - letter-spacing: var(--tracking-wider); - text-transform: uppercase; - color: var(--text-faint); +.sourceStrong { flex-shrink: 0; + font-weight: 600; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + font-size: var(--text-3xs); + color: var(--text-dim); +} + +.sourceSpacer { + flex: 1; } -.demoChip { +.sourceButton { display: inline-flex; - align-items: baseline; - gap: var(--space-2); + align-items: center; + gap: 5px; + flex-shrink: 0; padding: 3px var(--space-3); border: 1px solid var(--line); border-radius: 999px; - font-size: var(--text-xs); + font-size: var(--text-2xs); color: var(--text-dim); - white-space: nowrap; transition: border-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out); - flex-shrink: 0; } -.demoChip:hover { +.sourceButton:hover { border-color: var(--line-signal); color: var(--text-bright); } -.demoChipActive { - border-color: var(--signal-dim); +/* The built-in marker never relies on colour alone: it carries a glyph and text. */ +.builtinBadge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px var(--space-2); + border: 1px solid var(--line-signal); + border-radius: var(--radius); background: var(--signal-wash); - color: var(--text-bright); + font-size: var(--text-2xs); + color: var(--signal); + white-space: nowrap; } -.demoCount { +.builtinCost { font-family: var(--font-mono); font-size: var(--text-3xs); - color: var(--text-faint); + color: var(--signal-dim); +} + +@media (max-width: 520px) { + .builtinCost { + display: none; + } } /* ------------------------------------------------------------------- stage */ diff --git a/src/components/states.module.css b/src/components/states.module.css index ceb105c..bc859f0 100644 --- a/src/components/states.module.css +++ b/src/components/states.module.css @@ -47,7 +47,7 @@ } .sectionTitle { - margin-top: var(--space-6); + margin-top: 0; margin-bottom: var(--space-3); font-size: var(--text-2xs); font-weight: 600; @@ -56,57 +56,91 @@ color: var(--text-faint); } -.demos { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: var(--space-2); -} +/* ------------------------------------------------------- built-in demo card */ .demoCard { - display: flex; - flex-direction: column; - gap: var(--space-1); - width: 100%; - height: 100%; - padding: var(--space-3); - border: 1px solid var(--line); + margin-top: var(--space-5); + padding: var(--space-4); + border: 1px solid var(--line-signal); + border-left-width: 2px; border-radius: var(--radius); - background: var(--surface-panel); - text-align: left; - transition: - border-color var(--duration-fast) var(--ease-out), - background var(--duration-fast) var(--ease-out); + background: linear-gradient(180deg, var(--signal-wash), transparent 70%), var(--surface-panel); + max-width: 520px; } -.demoCard:hover:not(:disabled) { - border-color: var(--line-signal); - background: var(--surface-raised); +.demoBadge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: var(--text-3xs); + font-weight: 600; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--signal); } -.demoCard:disabled { - opacity: 0.6; - cursor: progress; +.demoTitle { + margin-top: var(--space-2); + font-size: var(--text-lg); + font-weight: 500; + color: var(--text-bright); } -.demoName { - font-family: var(--font-mono); +.demoDescription { + margin-top: var(--space-2); font-size: var(--text-sm); - color: var(--text-bright); - overflow-wrap: anywhere; + line-height: 1.55; + color: var(--text-dim); } -.demoNote { +.demoDisclosure { + margin-top: var(--space-2); + padding-left: var(--space-2); + border-left: 2px solid var(--line-strong); font-size: var(--text-2xs); line-height: 1.5; - color: var(--text-dim); + color: var(--text-faint); +} + +.demoAction { + display: block; + width: 100%; + margin-top: var(--space-4); + padding: var(--space-3); + border: 1px solid var(--signal-dim); + border-radius: var(--radius); + background: var(--signal-wash-strong); + font-size: var(--text-sm); + font-weight: 500; + color: var(--signal-bright); + transition: border-color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out); } -.demoCommits { - margin-top: auto; - padding-top: var(--space-2); +.demoAction:hover:not(:disabled) { + border-color: var(--signal); + background: color-mix(in srgb, var(--signal) 22%, transparent); +} + +.demoAction:disabled { + opacity: 0.6; + cursor: progress; +} + +.demoCost { + margin-top: var(--space-2); font-family: var(--font-mono); font-size: var(--text-3xs); color: var(--text-faint); + text-align: center; +} + +/* ------------------------------------------------------- live repository ---- */ + +.liveBlock { + margin-top: var(--space-6); + padding-top: var(--space-4); + border-top: 1px solid var(--line-faint); + max-width: 520px; } .notes { @@ -137,41 +171,24 @@ * Wide screens: prose, the field and the honesty notes down the left; the demos * beside them, spanning both rows, so neither column is left with a hole in it. */ +/* + * Wide screens: the two ways in on the left, the honesty notes on the right, so + * neither column is left with a hole under it. + */ @media (min-width: 1000px) { .landingInner { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(300px, 0.7fr); - grid-template-areas: - 'intro demos' - 'notes demos'; - grid-template-rows: auto 1fr; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.6fr); column-gap: var(--space-7); - } - - .landingIntro { - grid-area: intro; - } - - .landingDemos { - grid-area: demos; - align-self: start; + align-items: start; } .notes { - grid-area: notes; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); - margin-top: var(--space-5); - padding-top: var(--space-4); - align-self: start; - } - - .sectionTitle { margin-top: 0; - } - - .demos { - grid-template-columns: minmax(0, 1fr); + padding-top: 0; + border-top: none; } } diff --git a/src/lib/builtin/learning-platform.ts b/src/lib/builtin/learning-platform.ts new file mode 100644 index 0000000..3ae0ffb --- /dev/null +++ b/src/lib/builtin/learning-platform.ts @@ -0,0 +1,345 @@ +/** + * The built-in demo history. + * + * This is a curated, synthetic history — sixteen invented commits describing how + * a learning product might plausibly grow. It is **not** a reproduction of any + * real repository, and none of the file contents, messages, authors or object ids + * come from one. It exists so the application has something to demonstrate + * without reading anybody's repository and without spending GitHub quota. + * + * The data below is the fixture: object ids, timestamps, subjects and per-file + * line counts are literals checked into the repository. Trees, statistics and + * milestones are derived from it by the same code paths a live repository uses. + */ + +export const BUILTIN_DEMO_SLUG = 'demo/learning-platform'; +export const BUILTIN_DEMO_OWNER = 'demo'; +export const BUILTIN_DEMO_REPO = 'learning-platform'; +export const BUILTIN_DEMO_TITLE = 'Learning Platform'; +export const BUILTIN_DEMO_BADGE = 'Built-in demo'; +export const BUILTIN_DEMO_DESCRIPTION = + 'A curated 16-commit history showing a learning product grow from a shell into a guided, accessible experience.'; +export const BUILTIN_DEMO_DISCLOSURE = + 'This is a curated, synthetic history — not a live GitHub repository.'; +export const BUILTIN_DEMO_BRANCH = 'main'; +export const BUILTIN_DEMO_AUTHOR = 'Demo Maintainer'; + +/** How a commit touched one path. Line counts are part of the fixture. */ +export type DemoChangeKind = 'added' | 'modified' | 'removed' | 'renamed'; + +export type DemoChange = { + kind: DemoChangeKind; + path: string; + additions: number; + deletions: number; + /** Previous path, for renames. */ + from?: string; + /** A short synthetic diff, shown in the commit panel. */ + patch?: string; +}; + +export type DemoCommitSpec = { + sha: string; + /** ISO 8601, strictly increasing across the list. */ + date: string; + subject: string; + body: string; + changes: DemoChange[]; +}; + +const add = (path: string, additions: number, patch?: string): DemoChange => ({ + kind: 'added', + path, + additions, + deletions: 0, + ...(patch ? { patch } : {}), +}); + +const mod = (path: string, additions: number, deletions: number, patch?: string): DemoChange => ({ + kind: 'modified', + path, + additions, + deletions, + ...(patch ? { patch } : {}), +}); + +const del = (path: string, deletions: number): DemoChange => ({ + kind: 'removed', + path, + additions: 0, + deletions, +}); + +const ren = (from: string, path: string, additions = 0, deletions = 0): DemoChange => ({ + kind: 'renamed', + path, + from, + additions, + deletions, +}); + +/** + * Sixteen commits, oldest first. Exactly sixteen: the count is asserted by the + * test suite, because the demo's whole point is being a fixed, known history. + */ +export const DEMO_COMMITS: readonly DemoCommitSpec[] = [ + { + sha: 'efc489e4521d2c40c30fe6fd18678c6219245813', + date: '2025-01-06T09:12:00Z', + subject: 'chore: scaffold the application shell', + body: 'An empty application that builds and serves one page. No product code yet, only the pieces needed to run something.', + changes: [ + add('package.json', 32, '@@ -0,0 +1,8 @@\n+{\n+ "name": "learning-platform",\n+ "private": true,\n+ "scripts": {\n+ "dev": "next dev",\n+ "build": "next build"\n+ }\n+}'), + add('tsconfig.json', 27), + add('.gitignore', 14), + add('README.md', 21, '@@ -0,0 +1,5 @@\n+# Learning Platform\n+\n+A place to learn something in small, finishable steps.\n+\n+Work in progress.'), + add('app/layout.tsx', 24), + add('app/page.tsx', 12, '@@ -0,0 +1,6 @@\n+export default function HomePage() {\n+ return (\n+
\n+

Learning Platform

\n+
\n+ );\n+}'), + ], + }, + { + sha: 'f19511761fe6afd7fa6ce5fc6e53245967feb9db', + date: '2025-01-08T14:40:00Z', + subject: 'feat: establish design tokens and layout primitives', + body: 'Every colour, spacing step and radius now has one name. Two layout primitives cover the shapes the product actually needs, so pages stop inventing their own margins.', + changes: [ + add('next.config.ts', 16), + add('package-lock.json', 1840), + add('styles/tokens.css', 96, '@@ -0,0 +1,10 @@\n+:root {\n+ --surface-base: #0f1115;\n+ --surface-panel: #16191f;\n+ --text: #e6e6e6;\n+ --text-dim: #9aa0a6;\n+ --accent: #7fb2ff;\n+ --space-2: 0.5rem;\n+ --space-4: 1rem;\n+ --radius: 6px;\n+}'), + add('styles/global.css', 58), + add('components/layout/app-shell.tsx', 74), + add('components/layout/container.tsx', 22), + add('public/favicon.svg', 9), + mod('app/layout.tsx', 18, 6), + ], + }, + { + sha: 'cb5a18f82d77821319fda7225a34722ea328f630', + date: '2025-01-13T10:05:00Z', + subject: 'feat: define lesson and exercise models', + body: 'A lesson is a title, a body and an ordered list of exercises. An exercise is a prompt plus a way to check an answer. Everything else in the product is built on these two.', + changes: [ + add('lib/models/lesson.ts', 64, '@@ -0,0 +1,11 @@\n+export type Lesson = {\n+ slug: string;\n+ title: string;\n+ summary: string;\n+ estimatedMinutes: number;\n+ exercises: Exercise[];\n+};\n+\n+export function lessonDuration(lesson: Lesson): number {\n+ return lesson.exercises.reduce((total, item) => total + item.minutes, 0);\n+}'), + add('lib/models/exercise.ts', 58), + add('lib/models/index.ts', 8), + add('tests/models.test.ts', 46, '@@ -0,0 +1,7 @@\n+import { describe, expect, it } from "vitest";\n+import { lessonDuration } from "../lib/models/lesson";\n+\n+describe("lessonDuration", () => {\n+ it("sums the exercise minutes", () => {\n+ expect(lessonDuration(fixture)).toBe(18);\n+ });\n+});'), + ], + }, + { + sha: 'a909ce5397b9c0af2103c82c216498fb75de9c04', + date: '2025-01-17T16:22:00Z', + subject: 'feat: add progress state and local persistence', + body: 'Progress survives a reload. It is stored per lesson rather than as one blob, so a corrupt entry loses one lesson instead of everything.', + changes: [ + add('lib/progress/types.ts', 34), + add('lib/progress/store.ts', 118, '@@ -0,0 +1,9 @@\n+export function markExerciseComplete(state: ProgressState, id: string): ProgressState {\n+ if (state.completed.includes(id)) return state;\n+ return {\n+ ...state,\n+ completed: [...state.completed, id],\n+ updatedAt: Date.now(),\n+ };\n+}'), + add('lib/progress/storage.ts', 72), + add('tests/progress.test.ts', 88), + mod('lib/models/index.ts', 4, 1), + ], + }, + { + sha: '68e631c1cd7a309874ad8fbc39906129410f23df', + date: '2025-01-23T11:48:00Z', + subject: 'feat: publish the first guided lesson', + body: 'One lesson, end to end: read a short explanation, answer three exercises, see progress recorded. Enough to tell whether the shape of the thing works.', + changes: [ + add('content/lessons/getting-started.mdx', 142), + add('components/lesson/lesson-view.tsx', 156, '@@ -0,0 +1,8 @@\n+export function LessonView({ lesson }: { lesson: Lesson }) {\n+ return (\n+
\n+

{lesson.title}

\n+ \n+
\n+ );\n+}'), + add('components/lesson/exercise-list.tsx', 94), + add('app/lessons/[slug]/page.tsx', 48), + add('tests/lesson-view.test.tsx', 64), + mod('app/page.tsx', 22, 4), + ], + }, + { + sha: '62618def3b583489d0125be04e5e77fbcc030556', + date: '2025-02-03T09:30:00Z', + subject: 'feat: expand the learning library', + body: 'Four more lessons, and a grid to find them in. Writing several at once exposed how much of the lesson view assumed a single hard-coded page.', + changes: [ + add('LICENSE', 21), + add('content/lessons/reading-errors.mdx', 168), + add('content/lessons/shaping-data.mdx', 184), + add('content/lessons/writing-tests.mdx', 176), + add('content/lessons/asking-questions.mdx', 152), + add('components/library/library-grid.tsx', 118), + add('components/library/lesson-card.tsx', 86), + add('app/library/page.tsx', 54), + add('lib/content/load-lessons.ts', 92), + add('tests/library.test.tsx', 78), + mod('components/lesson/lesson-view.tsx', 46, 38), + mod('styles/tokens.css', 18, 2), + ], + }, + { + sha: 'f0620b3d28d3e9e64bb8456b7f7212992ea3474a', + date: '2025-02-11T15:15:00Z', + subject: 'feat: generate navigation and search indexes', + body: 'Navigation and search read one generated index instead of walking the content directory at request time. The index is built ahead of time and treated as generated output.', + changes: [ + add('lib/index/build-index.ts', 134), + add('lib/index/search.ts', 108, '@@ -0,0 +1,7 @@\n+export function search(index: SearchIndex, query: string): SearchHit[] {\n+ const needle = query.trim().toLowerCase();\n+ if (!needle) return [];\n+ return index.entries\n+ .filter((entry) => entry.haystack.includes(needle))\n+ .slice(0, 20);\n+}'), + add('components/search/search-dialog.tsx', 146), + add('public/generated/search-index.json', 620), + add('scripts/build-index.sh', 34, '@@ -0,0 +1,5 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+\n+node ./scripts/build-index.mjs\n+echo "index written"'), + add('tests/search.test.ts', 96), + mod('components/layout/app-shell.tsx', 34, 12), + ], + }, + { + sha: '31a8fae638b019a6fa546d527fdd4d0723668bc0', + date: '2025-02-20T13:02:00Z', + subject: 'feat: add guided practice sessions', + body: 'A practice session picks exercises the learner has not finished and asks them in a fixed order, so a session is repeatable rather than random.', + changes: [ + add('lib/practice/session.ts', 148, '@@ -0,0 +1,8 @@\n+export function buildSession(pool: Exercise[], progress: ProgressState, size: number): Session {\n+ const unfinished = pool.filter((item) => !progress.completed.includes(item.id));\n+ return {\n+ id: sessionId(unfinished, size),\n+ items: unfinished.slice(0, size),\n+ };\n+}'), + add('lib/practice/scoring.ts', 84), + add('components/practice/practice-runner.tsx', 162), + add('components/practice/practice-summary.tsx', 92), + add('app/practice/page.tsx', 58), + add('tests/practice.test.ts', 112), + mod('lib/progress/store.ts', 38, 9), + ], + }, + { + sha: 'fd2d50be85fa72719f4fae7bcaee26cac60792ec', + date: '2025-03-04T10:41:00Z', + subject: 'feat: introduce the coding workspace', + body: 'Some exercises need code, not multiple choice. The workspace runs a snippet against fixed cases in a worker and reports which ones passed. The largest change so far, and the one that most changed the shape of the app.', + changes: [ + add('lib/workspace/runner.ts', 486), + add('lib/workspace/worker.ts', 342), + add('lib/workspace/cases.ts', 218), + add('components/workspace/editor-pane.tsx', 604), + add('components/workspace/result-pane.tsx', 296), + add('components/workspace/workspace-layout.tsx', 244), + add('app/workspace/page.tsx', 88), + add('styles/workspace.css', 182), + add('tests/workspace-runner.test.ts', 268), + add('tests/workspace-cases.test.ts', 154), + mod('lib/models/exercise.ts', 68, 14), + mod('components/lesson/exercise-list.tsx', 52, 22), + ], + }, + { + sha: '47915e96ba5e1d83231f231a800aa842482612e8', + date: '2025-03-14T17:26:00Z', + subject: 'feat: add assessment and review modes', + body: 'Assessment asks without hints and reports at the end. Review comes back to the exercises that were wrong. They share one scoring module so the two can never disagree.', + changes: [ + add('.github/workflows/ci.yml', 42, '@@ -0,0 +1,9 @@\n+name: CI\n+on: [push, pull_request]\n+jobs:\n+ check:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@v4\n+ - run: npm ci\n+ - run: npm test'), + add('lib/assessment/score.ts', 116), + add('lib/assessment/review.ts', 94), + add('components/assessment/assessment-runner.tsx', 158), + add('components/assessment/review-list.tsx', 104), + add('app/assessment/page.tsx', 56), + add('tests/assessment.test.ts', 124), + mod('lib/practice/scoring.ts', 28, 46), + ], + }, + { + sha: '78c2808ce57786285cf655e6a2633962b54718fc', + date: '2025-03-25T12:08:00Z', + subject: 'feat: introduce guided learning paths', + body: 'A path is an ordered set of lessons with a stated goal. Paths are declared as data so adding one is an editorial act rather than a code change.', + changes: [ + add('content/paths/first-steps.yaml', 46, '@@ -0,0 +1,9 @@\n+slug: first-steps\n+title: First steps\n+goal: Finish something small, all the way through.\n+lessons:\n+ - getting-started\n+ - reading-errors\n+ - writing-tests'), + add('content/paths/reading-code.yaml', 52), + add('content/paths/building-confidence.yaml', 58), + add('lib/paths/resolve.ts', 128), + add('lib/paths/types.ts', 38), + add('components/paths/path-card.tsx', 96), + add('components/paths/path-outline.tsx', 112), + add('app/paths/page.tsx', 54), + add('tests/paths.test.ts', 106), + ], + }, + { + sha: 'a97b30455ee5375b9e0c2e0e1b0f44f4c4617588', + date: '2025-04-02T09:55:00Z', + subject: 'feat: make progress and continuation plan-aware', + body: 'Progress now knows which path a learner is on, so "continue" means the next step in that path rather than the next lesson in the library.', + changes: [ + add('lib/progress/plan.ts', 142, '@@ -0,0 +1,8 @@\n+export function nextStep(plan: Plan, progress: ProgressState): Step | null {\n+ for (const step of plan.steps) {\n+ if (!progress.completed.includes(step.id)) return step;\n+ }\n+ return null;\n+}'), + add('lib/paths/next-step.ts', 88), + add('tests/plan.test.ts', 118), + mod('lib/progress/store.ts', 64, 22), + mod('lib/progress/types.ts', 26, 6), + mod('lib/paths/resolve.ts', 34, 11), + ], + }, + { + sha: '69e42370ba56d099c90b917746e446679d14c7ce', + date: '2025-04-11T14:33:00Z', + subject: 'refactor: unify navigation and next actions', + body: 'Four places decided what "next" meant and two of them disagreed. There is now one navigation module, and the duplicates are gone.', + changes: [ + add('components/navigation/nav-rail.tsx', 128), + add('components/navigation/next-action.tsx', 86), + add('components/navigation/mobile-drawer.tsx', 142), + add('lib/navigation/resolve-next.ts', 104), + ren('components/library/lesson-card.tsx', 'components/library/library-card.tsx', 12, 8), + del('components/practice/practice-summary.tsx', 92), + del('components/paths/path-outline.tsx', 112), + mod('components/layout/app-shell.tsx', 58, 74), + mod('app/practice/page.tsx', 22, 18), + mod('app/paths/page.tsx', 24, 16), + mod('tests/paths.test.ts', 18, 22), + ], + }, + { + sha: '99e7880a3699caf73dbe932dff20d854140cc843', + date: '2025-04-22T11:19:00Z', + subject: 'feat: add dashboard progress views', + body: 'One screen that answers "where am I": what is finished, what is next, and how much of the current path is left. Read-only, derived entirely from stored progress.', + changes: [ + add('vercel.json', 12), + add('lib/stats/summary.ts', 132, '@@ -0,0 +1,8 @@\n+export function summarise(progress: ProgressState, plan: Plan): Summary {\n+ const done = plan.steps.filter((step) => progress.completed.includes(step.id));\n+ return {\n+ completed: done.length,\n+ total: plan.steps.length,\n+ share: plan.steps.length === 0 ? 0 : done.length / plan.steps.length,\n+ };\n+}'), + add('components/dashboard/progress-ring.tsx', 108), + add('components/dashboard/streak-strip.tsx', 92), + add('components/dashboard/dashboard-grid.tsx', 116), + add('app/dashboard/page.tsx', 64), + add('tests/summary.test.ts', 96), + mod('components/navigation/nav-rail.tsx', 26, 8), + ], + }, + { + sha: '1fc9a6407085795e6f6f7ecb194c41138be153b4', + date: '2025-05-06T16:04:00Z', + subject: 'fix: align responsive layouts and accessibility', + body: 'A pass with a keyboard and a narrow window. Focus order follows reading order, the workspace stops overflowing at 360px, and every control states what it does.', + changes: [ + add('styles/responsive.css', 88), + add('tests/accessibility.test.tsx', 134), + mod('components/workspace/workspace-layout.tsx', 52, 34), + mod('components/workspace/editor-pane.tsx', 38, 26), + mod('components/dashboard/dashboard-grid.tsx', 34, 18), + mod('components/navigation/nav-rail.tsx', 42, 24), + mod('components/lesson/lesson-view.tsx', 28, 16), + mod('styles/global.css', 46, 22), + mod('styles/tokens.css', 22, 8), + ], + }, + { + sha: '49b79263eceed79350be604175fe8d0ff9d112a7', + date: '2025-05-14T10:27:00Z', + subject: 'fix: lock background scrolling behind the mobile drawer', + body: 'Opening the drawer on a phone left the page behind it scrollable, and closing it lost the reading position. The lock records the offset, fixes the body while the drawer is open, and restores the offset on close.', + changes: [ + add('lib/dom/scroll-lock.ts', 78, '@@ -0,0 +1,10 @@\n+export function lockScroll(): () => void {\n+ const offset = window.scrollY;\n+ document.body.style.position = "fixed";\n+ document.body.style.top = `-${offset}px`;\n+ return () => {\n+ document.body.style.position = "";\n+ document.body.style.top = "";\n+ window.scrollTo(0, offset);\n+ };\n+}'), + add('tests/scroll-lock.test.ts', 92), + mod('components/navigation/mobile-drawer.tsx', 44, 18), + mod('styles/responsive.css', 16, 4), + ], + }, +]; + +/** Release tags, so the demo exercises the tag milestone too. */ +export const DEMO_TAGS: readonly { name: string; commitIndex: number }[] = [ + { name: 'v0.1.0', commitIndex: 4 }, + { name: 'v1.0.0', commitIndex: 13 }, +]; + +export const DEMO_CREATED_AT = DEMO_COMMITS[0]!.date; +export const DEMO_PUSHED_AT = DEMO_COMMITS[DEMO_COMMITS.length - 1]!.date; diff --git a/src/lib/builtin/provider.ts b/src/lib/builtin/provider.ts new file mode 100644 index 0000000..a88d6b3 --- /dev/null +++ b/src/lib/builtin/provider.ts @@ -0,0 +1,284 @@ +import type { + Commit, + CommitDetail, + FileChange, + RepoMeta, + RepoTree, + Tag, + TreeEntry, +} from '@/lib/domain/types'; +import type { RepoRef } from '@/lib/repo-ref'; +import { + BUILTIN_DEMO_AUTHOR, + BUILTIN_DEMO_BRANCH, + BUILTIN_DEMO_DESCRIPTION, + BUILTIN_DEMO_OWNER, + BUILTIN_DEMO_REPO, + BUILTIN_DEMO_SLUG, + DEMO_COMMITS, + DEMO_CREATED_AT, + DEMO_PUSHED_AT, + DEMO_TAGS, + type DemoChange, +} from './learning-platform'; + +/** + * Serves the built-in demo from the fixture in `learning-platform.ts`. + * + * The point of this module is that it is the *only* thing that answers for + * `demo/learning-platform`, and it never touches the network. It runs in + * production, unlike the test-fixture provider, and it is scoped to that one + * reference: anything else falls through to the normal GitHub path, so an + * arbitrary `demo/whatever` gets the ordinary not-found behaviour. + */ + +/** True only for the exact built-in reference. */ +export function isBuiltinRef(ref: Pick): boolean { + return ref.slug.toLowerCase() === BUILTIN_DEMO_SLUG; +} + +export function isBuiltinSlug(slug: string): boolean { + return slug.trim().toLowerCase() === BUILTIN_DEMO_SLUG; +} + +export const BUILTIN_DEMO_REF: RepoRef = { + owner: BUILTIN_DEMO_OWNER, + repo: BUILTIN_DEMO_REPO, + slug: BUILTIN_DEMO_SLUG, +}; + +// ---------------------------------------------------------------- helpers --- + +/** Deterministic 32-bit hash, so sizes and blob ids are stable across runs. */ +function hash32(seed: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < seed.length; i += 1) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h >>> 0; +} + +/** Deterministic 40-character hex, used for synthetic blob and tree ids. */ +function hexId(seed: string): string { + let out = ''; + for (let i = 0; out.length < 40; i += 1) { + out += hash32(`${seed}#${i}`).toString(16).padStart(8, '0'); + } + return out.slice(0, 40); +} + +/** A plausible starting size for a path, before any edits. */ +function baseSize(path: string): number { + return 180 + (hash32(path) % 5200); +} + +type BlobState = { size: number; revision: number }; + +// ------------------------------------------------------------------ build --- + +type BuiltDemo = { + meta: RepoMeta; + /** Oldest first, `index` assigned. */ + commits: Commit[]; + details: Map; + trees: Map; + tags: Tag[]; +}; + +function toFileChange(change: DemoChange): FileChange { + return { + path: change.path, + previousPath: change.from ?? null, + status: change.kind, + additions: change.additions, + deletions: change.deletions, + patch: change.patch ?? null, + // A change with no recorded diff says so, exactly as the GitHub path does. + patchOmittedReason: change.patch ? null : 'not-provided', + patchTruncated: false, + blobUrl: null, + }; +} + +function treeFrom(commitSha: string, blobs: ReadonlyMap): RepoTree { + const entries: TreeEntry[] = []; + const directories = new Set(); + + for (const [path, state] of blobs) { + const segments = path.split('/'); + for (let i = 1; i < segments.length; i += 1) directories.add(segments.slice(0, i).join('/')); + entries.push({ + path, + type: 'blob', + size: state.size, + sha: hexId(`blob:${path}:${state.revision}`), + }); + } + + for (const directory of directories) { + entries.push({ path: directory, type: 'tree', size: null, sha: hexId(`tree:${directory}`), }); + } + + entries.sort((a, b) => a.path.localeCompare(b.path, 'en')); + return { commitSha, entries, truncated: false }; +} + +/** + * Replays the fixture into the same domain objects the GitHub adapter produces, + * so every downstream module — projector, statistics, milestones, panels — runs + * unchanged. The tree is materialised at every commit, so no position is ever an + * approximation. + */ +function build(): BuiltDemo { + const commits: Commit[] = []; + const details = new Map(); + const trees = new Map(); + const blobs = new Map(); + + DEMO_COMMITS.forEach((spec, index) => { + for (const change of spec.changes) { + switch (change.kind) { + case 'added': { + blobs.set(change.path, { + size: baseSize(change.path) + change.additions * 6, + revision: 1, + }); + break; + } + case 'removed': { + blobs.delete(change.path); + break; + } + case 'renamed': { + const previous = change.from ? blobs.get(change.from) : undefined; + if (change.from) blobs.delete(change.from); + blobs.set(change.path, { + size: Math.max(40, (previous?.size ?? baseSize(change.path)) + (change.additions - change.deletions) * 6), + revision: (previous?.revision ?? 0) + 1, + }); + break; + } + default: { + const current = blobs.get(change.path); + blobs.set(change.path, { + size: Math.max(40, (current?.size ?? baseSize(change.path)) + (change.additions - change.deletions) * 6), + revision: (current?.revision ?? 0) + 1, + }); + break; + } + } + } + + const files = spec.changes.map(toFileChange); + commits.push({ + sha: spec.sha, + shortSha: spec.sha.slice(0, 7), + subject: spec.subject, + body: spec.body, + author: { + name: BUILTIN_DEMO_AUTHOR, + login: null, + avatarUrl: null, + date: spec.date, + }, + committerDate: spec.date, + parents: index === 0 ? [] : [DEMO_COMMITS[index - 1]!.sha], + htmlUrl: null, + index, + }); + + details.set(spec.sha, { + sha: spec.sha, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + changedFiles: files.length, + files, + filesTruncated: false, + }); + + trees.set(spec.sha, treeFrom(spec.sha, blobs)); + }); + + const meta: RepoMeta = { + dataSource: 'builtin', + owner: BUILTIN_DEMO_OWNER, + repo: BUILTIN_DEMO_REPO, + slug: BUILTIN_DEMO_SLUG, + description: BUILTIN_DEMO_DESCRIPTION, + defaultBranch: BUILTIN_DEMO_BRANCH, + htmlUrl: null, + createdAt: DEMO_CREATED_AT, + pushedAt: DEMO_PUSHED_AT, + primaryLanguage: 'TypeScript', + stars: 0, + forks: 0, + sizeKb: Math.round([...blobs.values()].reduce((sum, blob) => sum + blob.size, 0) / 1024), + license: 'MIT', + topics: [], + isArchived: false, + isFork: false, + isEmpty: false, + }; + + const tags: Tag[] = DEMO_TAGS.map((tag) => ({ + name: tag.name, + sha: DEMO_COMMITS[tag.commitIndex]!.sha, + })); + + return { meta, commits, details, trees, tags }; +} + +let cached: BuiltDemo | null = null; + +function demo(): BuiltDemo { + cached ??= build(); + return cached; +} + +// -------------------------------------------------------------------- api --- + +export function builtinRepoMeta(): RepoMeta { + return demo().meta; +} + +export function builtinCommitCount(): number { + return demo().commits.length; +} + +/** One page of commits, newest first, matching the GitHub listing contract. */ +export function builtinCommitPage(page: number, perPage: number) { + const newestFirst = [...demo().commits].reverse(); + const start = (page - 1) * perPage; + const slice = newestFirst.slice(start, start + perPage); + return { + commits: slice.map((commit, i) => ({ ...commit, index: start + i })), + hasOlder: start + slice.length < newestFirst.length, + totalCommits: newestFirst.length, + }; +} + +export function builtinCommitDetail(sha: string): CommitDetail | null { + return demo().details.get(sha) ?? null; +} + +export function builtinTree(sha: string): RepoTree | null { + return demo().trees.get(sha) ?? null; +} + +export function builtinTags(): Tag[] { + return demo().tags; +} + +/** The oldest commit that touched a path, for the milestone path probes. */ +export function builtinFirstCommitForPath(path: string): string | null { + for (const spec of DEMO_COMMITS) { + if (spec.changes.some((change) => change.path === path || change.from === path)) return spec.sha; + } + return null; +} + +/** Test seam. */ +export function resetBuiltinCache(): void { + cached = null; +} diff --git a/src/lib/client/history-controller.ts b/src/lib/client/history-controller.ts index 39d1682..f325b89 100644 --- a/src/lib/client/history-controller.ts +++ b/src/lib/client/history-controller.ts @@ -409,23 +409,25 @@ export class HistoryController { } } - /** Trees, tags, milestone probes and background diffs, in priority order. */ + /** + * Everything after the commit list, in the order the screen needs it. + * + * The first usable screen is the selected commit: its tree and its diff. Those + * two go out together and nothing else is allowed to queue ahead of them. + * Tags, the remaining checkpoints, background diffs and milestone probes follow. + */ async #loadSupporting(generation: number, ref: RepoRef, branch: string): Promise { const commits = this.#state.commits; if (commits.length === 0) return; - // The newest commit first: it is what the visitor sees on arrival. - await this.#ensureTree(generation, ref, commits[commits.length - 1]!.sha); + const selected = commits[this.#state.playback.index] ?? commits[commits.length - 1]!; + await Promise.all([ + this.#ensureTree(generation, ref, selected.sha), + this.ensureDetail(selected.sha), + ]); if (this.#isStale(generation)) return; this.#scheduleDerived(); - // Then the oldest, which anchors playback from the start of the range. - if (commits.length > 1) { - await this.#ensureTree(generation, ref, commits[0]!.sha); - if (this.#isStale(generation)) return; - this.#scheduleDerived(); - } - void this.#loadTags(generation, ref); void this.#ensureCheckpointTrees(generation, ref); this.#queueDensify(generation, ref); @@ -444,9 +446,22 @@ export class HistoryController { } } + /** + * How many full trees are worth reading. + * + * A tree is only needed where diffs cannot reach. If every diff in the range is + * going to be loaded anyway, a baseline at the oldest commit plus the one + * already read at the selected commit makes every position exact, and further + * trees would be spent for nothing. + */ + #treeBudget(count: number): number { + if (count <= this.#budget(DETAIL_BUDGET)) return 2; + return this.#budget(TREE_BUDGET); + } + async #ensureCheckpointTrees(generation: number, ref: RepoRef): Promise { const commits = this.#state.commits; - const budget = this.#budget(TREE_BUDGET); + const budget = this.#treeBudget(commits.length); for (const index of planCheckpoints(commits.length, budget)) { if (this.#isStale(generation) || this.#quotaExhausted()) return; const sha = commits[index]?.sha; diff --git a/src/lib/demos.ts b/src/lib/demos.ts index ee44890..c53ae1f 100644 --- a/src/lib/demos.ts +++ b/src/lib/demos.ts @@ -1,44 +1,36 @@ +import { + BUILTIN_DEMO_BADGE, + BUILTIN_DEMO_DESCRIPTION, + BUILTIN_DEMO_DISCLOSURE, + BUILTIN_DEMO_SLUG, + BUILTIN_DEMO_TITLE, +} from '@/lib/builtin/learning-platform'; +import { BUILTIN_DEMO_REF } from '@/lib/builtin/provider'; import type { RepoRef } from '@/lib/repo-ref'; -export type Demo = { +/** + * The one demo the application ships with. + * + * It is a curated synthetic history rather than somebody's repository, so the + * landing page can show what the tool does without reading a real account and + * without spending any GitHub quota. + */ +export type BuiltinDemo = { ref: RepoRef; - label: string; - /** What this repository demonstrates about the timeline, not marketing copy. */ - note: string; - /** Approximate commit count when the demo list was written. */ - approxCommits: number; + slug: string; + title: string; + badge: string; + description: string; + disclosure: string; + commitCount: number; }; -const ref = (owner: string, repo: string): RepoRef => ({ owner, repo, slug: `${owner}/${repo}` }); - -/** - * Demo repositories, chosen for different history shapes: a five-commit - * experiment, a mid-sized course project, an application built over a weekend, - * and a long-running app with several hundred commits. - */ -export const DEMOS: Demo[] = [ - { - ref: ref('renrenmimi', 'renren-across-tabs'), - label: 'renren-across-tabs', - note: 'Five commits. The whole history fits on one screen.', - approxCommits: 5, - }, - { - ref: ref('renrenmimi', 'DataData'), - label: 'DataData', - note: 'About 30 commits; a course project that grew a structure early.', - approxCommits: 32, - }, - { - ref: ref('renrenmimi', 'DrillLab'), - label: 'DrillLab', - note: 'About 60 commits over a few days, with a visible framework moment.', - approxCommits: 64, - }, - { - ref: ref('renrenmimi', 'PetNote'), - label: 'PetNote', - note: 'Several hundred commits across months. Shows the loaded-range limits.', - approxCommits: 246, - }, -]; +export const BUILTIN_DEMO: BuiltinDemo = { + ref: BUILTIN_DEMO_REF, + slug: BUILTIN_DEMO_SLUG, + title: BUILTIN_DEMO_TITLE, + badge: BUILTIN_DEMO_BADGE, + description: BUILTIN_DEMO_DESCRIPTION, + disclosure: BUILTIN_DEMO_DISCLOSURE, + commitCount: 16, +}; diff --git a/src/lib/domain/types.ts b/src/lib/domain/types.ts index d541d2d..6333373 100644 --- a/src/lib/domain/types.ts +++ b/src/lib/domain/types.ts @@ -12,13 +12,22 @@ export type RateLimitSnapshot = { authenticated: boolean; }; +/** + * Where a repository's data came from. Set by the server, never inferred from a + * label, so the UI can be certain which mode it is in. + */ +export type DataSource = 'builtin' | 'github'; + export type RepoMeta = { + /** `builtin` for the curated demo, `github` for a real repository. */ + dataSource: DataSource; owner: string; repo: string; slug: string; description: string | null; defaultBranch: string; - htmlUrl: string; + /** Null for built-in data: there is no real repository to link to. */ + htmlUrl: string | null; createdAt: string; pushedAt: string; /** GitHub's own guess at the primary language; may be null for empty repos. */ @@ -52,7 +61,8 @@ export type Commit = { author: CommitAuthor; committerDate: string; parents: string[]; - htmlUrl: string; + /** Null for built-in data: there is no real commit to link to. */ + htmlUrl: string | null; /** Index within the loaded range, 0 = oldest loaded commit. */ index: number; }; diff --git a/src/lib/github/adapter.ts b/src/lib/github/adapter.ts index 8dece49..d42d859 100644 --- a/src/lib/github/adapter.ts +++ b/src/lib/github/adapter.ts @@ -26,6 +26,7 @@ export const MAX_COMMIT_FILES = 300; export function toRepoMeta(raw: RawRepo, isEmpty: boolean): RepoMeta { return { + dataSource: 'github', owner: raw.owner.login, repo: raw.name, slug: raw.full_name, diff --git a/src/lib/github/service.ts b/src/lib/github/service.ts index 2e44a46..e87abde 100644 --- a/src/lib/github/service.ts +++ b/src/lib/github/service.ts @@ -1,5 +1,14 @@ import 'server-only'; +import { + builtinCommitDetail, + builtinCommitPage, + builtinFirstCommitForPath, + builtinRepoMeta, + builtinTags, + builtinTree, + isBuiltinRef, +} from '@/lib/builtin/provider'; import type { Commit, CommitDetail, RepoMeta, RepoTree, Tag } from '@/lib/domain/types'; import type { RepoRef } from '@/lib/repo-ref'; import { toCommit, toCommitDetail, toRepoMeta, toRepoTree, toTags } from './adapter'; @@ -31,6 +40,9 @@ async function request(args: Parameters>[0]) { } export async function fetchRepoMeta(ref: RepoRef): Promise { + // The built-in demo is resolved before anything else, in every environment, + // so it can never reach api.github.com or be sent a token. + if (isBuiltinRef(ref)) return builtinRepoMeta(); if (fixtureModeEnabled()) { const fixtures = await loadFixtures(ref); return toRepoMeta(fixtures.repo, fixtures.commits.length === 0); @@ -64,6 +76,8 @@ export async function fetchCommitPage( ): Promise { const safePage = Math.min(Math.max(1, Math.floor(page)), MAX_COMMIT_PAGES); + if (isBuiltinRef(ref)) return builtinCommitPage(safePage, COMMITS_PER_PAGE); + if (fixtureModeEnabled()) { const fixtures = await loadFixtures(ref); const start = (safePage - 1) * COMMITS_PER_PAGE; @@ -109,6 +123,11 @@ async function countCommits(ref: RepoRef, branch: string): Promise { + if (isBuiltinRef(ref)) { + const detail = builtinCommitDetail(sha); + if (!detail) throw new GitHubError('not-found', 'That commit is not part of the built-in demo.'); + return detail; + } if (fixtureModeEnabled()) { const fixtures = await loadFixtures(ref); const raw = fixtureCommitDetail(fixtures, sha); @@ -124,6 +143,11 @@ export async function fetchCommitDetail(ref: RepoRef, sha: string): Promise { + if (isBuiltinRef(ref)) { + const tree = builtinTree(sha); + if (!tree) throw new GitHubError('not-found', 'That commit is not part of the built-in demo.'); + return tree; + } if (fixtureModeEnabled()) { const fixtures = await loadFixtures(ref); const raw = fixtureTree(fixtures, sha); @@ -140,6 +164,7 @@ export async function fetchTree(ref: RepoRef, sha: string): Promise { } export async function fetchTags(ref: RepoRef): Promise { + if (isBuiltinRef(ref)) return builtinTags(); if (fixtureModeEnabled()) { const fixtures = await loadFixtures(ref); return toTags(fixtures.tags); @@ -168,6 +193,7 @@ export async function probeFirstCommitForPath( branch: string, path: string, ): Promise<{ firstSha: string | null; incomplete: boolean }> { + if (isBuiltinRef(ref)) return { firstSha: builtinFirstCommitForPath(path), incomplete: false }; if (fixtureModeEnabled()) { const fixtures = await loadFixtures(ref); const touching = fixtures.commits.filter((commit) => { diff --git a/src/lib/repo-ref.ts b/src/lib/repo-ref.ts index 50588a4..2b356de 100644 --- a/src/lib/repo-ref.ts +++ b/src/lib/repo-ref.ts @@ -168,7 +168,7 @@ export function parseRepoRef(input: string): ParseRepoRefResult { // The shorthand form is strict: exactly one separator, nothing empty. const segments = raw.split('/'); if (segments.length !== 2 || !segments[0] || !segments[1]) { - return fail('malformed', 'Use owner/repository, for example renrenmimi/DrillLab.'); + return fail('malformed', 'Use the owner/repository form, for example octocat/hello-world.'); } return build(segments[0], segments[1]); } diff --git a/tests/builtin-demo.test.ts b/tests/builtin-demo.test.ts new file mode 100644 index 0000000..2d0c64d --- /dev/null +++ b/tests/builtin-demo.test.ts @@ -0,0 +1,374 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BUILTIN_DEMO_SLUG, + DEMO_COMMITS, + DEMO_TAGS, +} from '@/lib/builtin/learning-platform'; +import { + BUILTIN_DEMO_REF, + builtinCommitDetail, + builtinCommitPage, + builtinFirstCommitForPath, + builtinRepoMeta, + builtinTags, + builtinTree, + isBuiltinRef, + isBuiltinSlug, + resetBuiltinCache, +} from '@/lib/builtin/provider'; +import { BUILTIN_DEMO } from '@/lib/demos'; +import { detectMilestones } from '@/lib/milestones/detect'; +import { parseRepoRef } from '@/lib/repo-ref'; +import { buildActivityMap, buildGrowthSeries, estimateLanguages } from '@/lib/stats/growth'; +import { TreeProjector } from '@/lib/tree/projector'; +import { fileSetFromTree } from '@/lib/tree/build'; +import { parseAppUrl, buildAppUrl } from '@/lib/url/state'; + +/** The demo, oldest first, in the domain shape the UI consumes. */ +function loadDemo() { + const page = builtinCommitPage(1, 100); + const commits = [...page.commits].reverse().map((commit, index) => ({ ...commit, index })); + return { commits, totalCommits: page.totalCommits, hasOlder: page.hasOlder }; +} + +beforeEach(() => { + resetBuiltinCache(); +}); + +describe('the demo is exactly sixteen commits', () => { + it('declares sixteen in the fixture', () => { + expect(DEMO_COMMITS).toHaveLength(16); + expect(BUILTIN_DEMO.commitCount).toBe(16); + }); + + it('serves sixteen, with no older page', () => { + const { commits, totalCommits, hasOlder } = loadDemo(); + expect(commits).toHaveLength(16); + expect(totalCommits).toBe(16); + expect(hasOlder).toBe(false); + }); + + it('uses the prescribed subjects, in order', () => { + expect(loadDemo().commits.map((commit) => commit.subject)).toEqual([ + 'chore: scaffold the application shell', + 'feat: establish design tokens and layout primitives', + 'feat: define lesson and exercise models', + 'feat: add progress state and local persistence', + 'feat: publish the first guided lesson', + 'feat: expand the learning library', + 'feat: generate navigation and search indexes', + 'feat: add guided practice sessions', + 'feat: introduce the coding workspace', + 'feat: add assessment and review modes', + 'feat: introduce guided learning paths', + 'feat: make progress and continuation plan-aware', + 'refactor: unify navigation and next actions', + 'feat: add dashboard progress views', + 'fix: align responsive layouts and accessibility', + 'fix: lock background scrolling behind the mobile drawer', + ]); + }); +}); + +describe('object ids and timestamps', () => { + it('has sixteen unique, valid 40-character hexadecimal shas', () => { + const shas = DEMO_COMMITS.map((commit) => commit.sha); + expect(new Set(shas).size).toBe(16); + for (const sha of shas) { + expect(sha).toMatch(/^[0-9a-f]{40}$/); + } + }); + + it('is deterministic across rebuilds', () => { + const first = loadDemo().commits.map((commit) => commit.sha); + resetBuiltinCache(); + const second = loadDemo().commits.map((commit) => commit.sha); + expect(second).toEqual(first); + }); + + it('is strictly chronological', () => { + const times = loadDemo().commits.map((commit) => Date.parse(commit.author.date)); + expect(times.every((value) => Number.isFinite(value))).toBe(true); + for (let i = 1; i < times.length; i += 1) { + expect(times[i]!).toBeGreaterThan(times[i - 1]!); + } + }); + + it('links each commit to its parent, starting from a parentless first commit', () => { + const { commits } = loadDemo(); + expect(commits[0]!.parents).toEqual([]); + for (let i = 1; i < commits.length; i += 1) { + expect(commits[i]!.parents).toEqual([commits[i - 1]!.sha]); + } + }); + + it('uses a generic author and never links to a repository', () => { + for (const commit of loadDemo().commits) { + expect(commit.author.name).toBe('Demo Maintainer'); + expect(commit.author.login).toBeNull(); + expect(commit.author.avatarUrl).toBeNull(); + expect(commit.htmlUrl).toBeNull(); + } + expect(builtinRepoMeta().htmlUrl).toBeNull(); + }); +}); + +describe('commit detail and trees', () => { + it('has a detail record for every commit', () => { + for (const commit of loadDemo().commits) { + const detail = builtinCommitDetail(commit.sha); + expect(detail, commit.subject).not.toBeNull(); + expect(detail!.files.length).toBeGreaterThan(0); + expect(detail!.changedFiles).toBe(detail!.files.length); + } + }); + + it('records meaningful additions and deletions', () => { + const details = loadDemo().commits.map((commit) => builtinCommitDetail(commit.sha)!); + expect(details.every((detail) => detail.additions > 0)).toBe(true); + // Not a history that only ever adds: some commits remove lines too. + expect(details.filter((detail) => detail.deletions > 0).length).toBeGreaterThan(4); + }); + + it('has an exact tree for every commit', () => { + for (const commit of loadDemo().commits) { + const tree = builtinTree(commit.sha); + expect(tree, commit.subject).not.toBeNull(); + expect(tree!.commitSha).toBe(commit.sha); + expect(tree!.truncated).toBe(false); + expect(tree!.entries.some((entry) => entry.type === 'blob')).toBe(true); + } + }); + + it('grows visibly from the first tree to the last', () => { + const { commits } = loadDemo(); + const first = fileSetFromTree(builtinTree(commits[0]!.sha)!); + const last = fileSetFromTree(builtinTree(commits[15]!.sha)!); + + expect(first.size).toBeGreaterThan(0); + expect(last.size).toBeGreaterThan(first.size * 5); + // And it is a different set, not simply a longer one. + expect([...last.keys()].some((path) => !first.has(path))).toBe(true); + }); + + it('removes files as well as adding them', () => { + const { commits } = loadDemo(); + const before = fileSetFromTree(builtinTree(commits[11]!.sha)!); + const after = fileSetFromTree(builtinTree(commits[12]!.sha)!); + const removed = [...before.keys()].filter((path) => !after.has(path)); + expect(removed.length).toBeGreaterThan(0); + }); + + it('reports blob sizes, so byte statistics are complete', () => { + const tree = builtinTree(loadDemo().commits[15]!.sha)!; + const blobs = tree.entries.filter((entry) => entry.type === 'blob'); + expect(blobs.every((entry) => typeof entry.size === 'number' && entry.size > 0)).toBe(true); + }); + + it('returns nothing for a sha that is not part of the demo', () => { + expect(builtinCommitDetail('0'.repeat(40))).toBeNull(); + expect(builtinTree('0'.repeat(40))).toBeNull(); + }); +}); + +describe('derived views use the normal application logic', () => { + function projector() { + const { commits } = loadDemo(); + const instance = new TreeProjector(); + instance.setCommits(commits); + for (const commit of commits) { + instance.addSnapshot(commit.sha, builtinTree(commit.sha)!); + instance.addDetail(commit.sha, builtinCommitDetail(commit.sha)!); + } + return { instance, commits }; + } + + it('renders every timeline position exactly', () => { + const { instance, commits } = projector(); + for (let index = 0; index < commits.length; index += 1) { + const projection = instance.project(index); + expect(projection.fidelity, `commit ${index + 1}`).toBe('exact'); + expect(projection.missingCommits).toBe(0); + expect(projection.files.size).toBeGreaterThan(0); + } + }); + + it('derives a complete growth series through buildGrowthSeries', () => { + const { instance, commits } = projector(); + const series = buildGrowthSeries(commits, instance); + + expect(series.points).toHaveLength(16); + expect(series.knownCommits).toBe(16); + expect(series.coverage).toBe(1); + expect(series.points.every((point) => point.measured)).toBe(true); + expect(series.totalAdditions).toBeGreaterThan(0); + expect(series.totalDeletions).toBeGreaterThan(0); + // A growing project: the file count never falls below where it started. + expect(series.points.at(-1)!.fileCount).toBeGreaterThan(series.points[0]!.fileCount); + expect(series.maxFiles).toBe(series.points.at(-1)!.fileCount); + }); + + it('derives a language estimate through estimateLanguages', () => { + const { instance, commits } = projector(); + const estimate = estimateLanguages(instance.project(commits.length - 1).files); + expect(estimate.countedFiles).toBeGreaterThan(10); + expect(estimate.slices.map((slice) => slice.language)).toContain('TypeScript'); + expect(estimate.slices.every((slice) => slice.share > 0)).toBe(true); + }); + + it('derives an activity map through buildActivityMap', () => { + const { commits } = loadDemo(); + const details = new Map(commits.map((commit) => [commit.sha, builtinCommitDetail(commit.sha)!])); + const map = buildActivityMap(commits, details); + expect(map.knownCommits).toBe(16); + expect(map.areas.length).toBeGreaterThan(2); + expect(map.max).toBeGreaterThan(0); + }); + + it('detects milestones that explain the progression', () => { + const { commits } = loadDemo(); + const changesBySha = new Map( + commits.map((commit) => [commit.sha, builtinCommitDetail(commit.sha)!.files]), + ); + const milestones = detectMilestones({ + commits, + tags: builtinTags(), + changesBySha, + snapshots: commits.map((commit) => ({ + commitIndex: commit.index, + paths: new Set(fileSetFromTree(builtinTree(commit.sha)!).keys()), + })), + }); + + const kinds = new Set(milestones.map((milestone) => milestone.kind)); + for (const expected of [ + 'initial-commit', + 'first-readme', + 'first-manifest', + 'first-lockfile', + 'first-typescript', + 'framework-config', + 'first-test', + 'first-ci', + 'first-deploy-config', + 'first-license', + 'release-tag', + 'structural-change', + 'large-change', + 'new-language', + ]) { + expect(kinds, `expected a ${expected} milestone`).toContain(expected); + } + + // Every path milestone is pinned to a single commit, because all diffs exist. + expect(milestones.every((milestone) => milestone.confidence === 'exact')).toBe(true); + expect(milestones.every((milestone) => milestone.commitIndex >= 0 && milestone.commitIndex < 16)).toBe(true); + }); + + it('places its release tags inside the range', () => { + const { commits } = loadDemo(); + expect(DEMO_TAGS).toHaveLength(2); + for (const tag of builtinTags()) { + expect(commits.some((commit) => commit.sha === tag.sha)).toBe(true); + } + }); +}); + +describe('scope of the built-in provider', () => { + it('recognises only the exact reference', () => { + expect(isBuiltinSlug(BUILTIN_DEMO_SLUG)).toBe(true); + expect(isBuiltinSlug('DEMO/Learning-Platform')).toBe(true); + expect(isBuiltinRef(BUILTIN_DEMO_REF)).toBe(true); + }); + + it.each([ + 'demo/learning', + 'demo/learning-platform-2', + 'demo/big-history', + 'demo/one-commit', + 'demo/sample-app', + 'rtm-fixtures/sample-app', + 'demo/x', + 'octocat/learning-platform', + ])('does not claim %s', (slug) => { + expect(isBuiltinSlug(slug)).toBe(false); + expect(isBuiltinRef({ slug })).toBe(false); + }); + + it('parses as an ordinary reference, so no parser exception is needed', () => { + const parsed = parseRepoRef(BUILTIN_DEMO_SLUG); + expect(parsed.ok).toBe(true); + if (parsed.ok) expect(parsed.value.slug).toBe(BUILTIN_DEMO_SLUG); + }); + + it('declares itself built-in and carries no GitHub identity', () => { + const meta = builtinRepoMeta(); + expect(meta.dataSource).toBe('builtin'); + expect(meta.htmlUrl).toBeNull(); + expect(meta.slug).toBe(BUILTIN_DEMO_SLUG); + expect(meta.isEmpty).toBe(false); + }); + + it('answers path probes from the fixture', () => { + expect(builtinFirstCommitForPath('README.md')).toBe(DEMO_COMMITS[0]!.sha); + expect(builtinFirstCommitForPath('.github/workflows/ci.yml')).toBe(DEMO_COMMITS[9]!.sha); + expect(builtinFirstCommitForPath('nothing/here.ts')).toBeNull(); + }); +}); + +describe('the demo makes no network request', () => { + it('never calls fetch while building or serving the whole history', () => { + const spy = vi.spyOn(globalThis, 'fetch'); + resetBuiltinCache(); + + const { commits } = loadDemo(); + builtinRepoMeta(); + builtinTags(); + for (const commit of commits) { + builtinCommitDetail(commit.sha); + builtinTree(commit.sha); + builtinFirstCommitForPath('README.md'); + } + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe('privacy of the fixture content', () => { + const text = JSON.stringify({ + commits: DEMO_COMMITS, + tags: DEMO_TAGS, + meta: builtinRepoMeta(), + }); + + it.each(['renren-across-tabs', 'DataData', 'DrillLab', 'PetNote'])( + 'contains no reference to %s', + (name) => { + expect(text.toLowerCase()).not.toContain(name.toLowerCase()); + }, + ); + + it('contains no e-mail address and no personal GitHub path', () => { + expect(text).not.toMatch(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/); + expect(text).not.toContain('github.com/'); + }); + + it('contains no issue or pull-request references', () => { + expect(text).not.toMatch(/\(#\d+\)/); + expect(text.toLowerCase()).not.toContain('pull request'); + expect(text.toLowerCase()).not.toContain('merge pull'); + }); +}); + +describe('sharing a built-in demo URL', () => { + it('round-trips the demo reference and a selected commit', () => { + const commit = loadDemo().commits[7]!; + const url = buildAppUrl({ repo: BUILTIN_DEMO_REF, commit: commit.sha }); + expect(url).toContain('repo=demo%2Flearning-platform'); + + const restored = parseAppUrl(url.replace('/?', '?')); + expect(restored.repo?.slug).toBe(BUILTIN_DEMO_SLUG); + expect(commit.sha.startsWith(restored.commit!)).toBe(true); + }); +}); diff --git a/tests/history-controller.test.ts b/tests/history-controller.test.ts index 5702075..5533b97 100644 --- a/tests/history-controller.test.ts +++ b/tests/history-controller.test.ts @@ -3,19 +3,22 @@ import { AUTO_PAGES, HistoryController, MAX_PAGES, planCheckpoints } from '@/lib import { FakeApi, notFound, rateLimited, ref, settle, until } from './fake-api'; const BUNDLES = { - 'renrenmimi/renren-across-tabs': 'renren-across-tabs', - 'renrenmimi/drilllab': 'drilllab', + 'rtm-fixtures/tiny-app': 'tiny-app', + 'rtm-fixtures/sample-app': 'sample-app', 'rtm-fixtures/one-commit': 'one-commit', 'rtm-fixtures/empty-repo': 'empty-repo', 'rtm-fixtures/big-history': 'big-history', }; -const TINY = ref('renrenmimi/renren-across-tabs'); -const MEDIUM = ref('renrenmimi/DrillLab'); +const TINY = ref('rtm-fixtures/tiny-app'); +const MEDIUM = ref('rtm-fixtures/sample-app'); const ONE = ref('rtm-fixtures/one-commit'); const EMPTY = ref('rtm-fixtures/empty-repo'); const BIG = ref('rtm-fixtures/big-history'); +/** The same bundles; named where a test is specifically about a live repository. */ +const BUILTIN_FREE_BUNDLES = BUNDLES; + const controllers: HistoryController[] = []; function make(api: FakeApi): HistoryController { @@ -43,7 +46,7 @@ describe('loading a repository', () => { const state = controller.getState(); expect(state.status).toBe('ready'); - expect(state.meta?.slug).toBe('renrenmimi/renren-across-tabs'); + expect(state.meta?.slug).toBe('rtm-fixtures/tiny-app'); expect(state.commits).toHaveLength(5); expect(state.totalCommits).toBe(5); expect(state.hasOlder).toBe(false); @@ -138,7 +141,7 @@ describe('pagination boundaries', () => { const api = new FakeApi({ bundles: BUNDLES }); const controller = make(api); await controller.load(MEDIUM); - expect(controller.getState().commits).toHaveLength(64); + expect(controller.getState().commits).toHaveLength(12); expect(api.countOf('commits')).toBe(1); expect(controller.getState().hasOlder).toBe(false); }); @@ -258,9 +261,10 @@ describe('request budgeting', () => { const api = new FakeApi({ bundles: BUNDLES, tokenConfigured: false }); api.setRateLimit({ limit: 60, remaining: 55, resetAt: Math.floor(Date.now() / 1000) + 600, authenticated: false }); const controller = make(api); - await controller.load(MEDIUM); + // A range longer than the anonymous diff budget, so the limit has to be stated. + await controller.load(BIG); await settle(controller); - expect(controller.getState().densify.target).toBeLessThan(64); + expect(controller.getState().densify.target).toBeLessThan(controller.getState().commits.length); expect(controller.getState().densify.limitReason).toMatch(/60 requests\/hour|leave GitHub requests/); }); @@ -290,7 +294,7 @@ describe('stale requests and repository switching', () => { await Promise.all([first, second]); expect(controller.getState().ref?.slug).toBe(MEDIUM.slug); - expect(controller.getState().meta?.slug).toBe('renrenmimi/DrillLab'); + expect(controller.getState().meta?.slug).toBe('rtm-fixtures/sample-app'); }); it('does not let a slow first repository overwrite the second one’s commits', async () => { @@ -316,9 +320,9 @@ describe('stale requests and repository switching', () => { await controller.load(TINY); const state = controller.getState(); expect(state.detailCount).toBeLessThanOrEqual(state.commits.length); - // No file from DrillLab may survive into the new projection. + // No file from the previous repository may survive into the new projection. const projected = [...controller.projector.project(state.commits.length - 1).files.keys()]; - expect(projected.some((path) => path.startsWith('components/arena-'))).toBe(false); + expect(projected.some((path) => path.startsWith('src/components/module-'))).toBe(false); }); it('resets to the idle state', async () => { @@ -360,7 +364,7 @@ describe('selection', () => { it('falls back to the newest commit when the requested sha is not in range', async () => { const controller = make(new FakeApi({ bundles: BUNDLES })); await controller.load(MEDIUM, 'deadbee'); - expect(controller.getState().playback.index).toBe(63); + expect(controller.getState().playback.index).toBe(11); }); it('moves the playhead through dispatchPlayback', async () => { @@ -390,8 +394,8 @@ describe('derived data', () => { await settle(controller); const growth = controller.getState().growth!; - expect(growth.points).toHaveLength(64); - expect(growth.knownCommits).toBeLessThanOrEqual(64); + expect(growth.points).toHaveLength(12); + expect(growth.knownCommits).toBeLessThanOrEqual(12); expect(growth.coverage).toBeGreaterThan(0); for (const point of growth.points) { if (!point.known) expect(point.additions + point.deletions).toBe(0); @@ -464,3 +468,68 @@ describe('error tolerance', () => { expect(controller.getState().commits).toHaveLength(5); }); }); + +describe('request efficiency', () => { + it('reads only what the first usable screen needs, in order', async () => { + const api = new FakeApi({ bundles: BUNDLES }); + const controller = make(api); + await controller.load(TINY); + + const kinds = api.calls.map((call) => call.endpoint); + const selected = controller.currentCommit!; + + // Metadata, then the commit list, then the selected commit's tree and diff. + expect(kinds[0]).toBe('repo'); + expect(kinds[1]).toBe('commits'); + expect(kinds.slice(2).sort()).toEqual(['commit', 'tree']); + expect(api.calls.filter((call) => call.arg === selected.sha)).toHaveLength(2); + + // Nothing optional has gone out yet. + expect(api.countOf('tags')).toBe(0); + expect(api.countOf('probe')).toBe(0); + + // And the optional work does still happen, just afterwards. + await settle(controller); + expect(api.countOf('tags')).toBe(1); + }); + + it('does not read a tree per commit for a five-commit repository', async () => { + const api = new FakeApi({ bundles: BUILTIN_FREE_BUNDLES }); + const controller = make(api); + await controller.load(TINY); + await settle(controller); + + expect(controller.getState().commits).toHaveLength(5); + // Every diff is loaded, so a baseline tree plus the selected one is enough. + expect(api.countOf('tree')).toBeLessThanOrEqual(2); + expect(api.countOf('commit')).toBeLessThanOrEqual(5); + // Which still leaves every position exact. + for (let index = 0; index < 5; index += 1) { + expect(controller.projector.project(index).fidelity).not.toBe('partial'); + } + }); + + it('keeps the whole first load well under one request per commit', async () => { + const api = new FakeApi({ bundles: BUNDLES }); + const controller = make(api); + await controller.load(MEDIUM); + await settle(controller); + + const perCommit = api.callCount / controller.getState().commits.length; + expect(controller.getState().commits).toHaveLength(12); + // Diffs are one per commit by nature; everything else must stay bounded. + expect(api.countOf('tree') + api.countOf('commits') + api.countOf('repo') + api.countOf('tags')).toBeLessThan(8); + expect(perCommit).toBeLessThan(2); + }); + + it('still reads more trees when diffs cannot cover the range', async () => { + const api = new FakeApi({ bundles: BUNDLES, tokenConfigured: false }); + api.setRateLimit({ limit: 60, remaining: 58, resetAt: Math.floor(Date.now() / 1000) + 600, authenticated: false }); + const controller = make(api); + await controller.load(BIG); + await settle(controller); + + // 300 commits with a 12-diff budget: checkpoints are the only way to cover it. + expect(api.countOf('tree')).toBeGreaterThan(2); + }); +}); diff --git a/tests/repo-ref.test.ts b/tests/repo-ref.test.ts index c93f0da..c5ddbb9 100644 --- a/tests/repo-ref.test.ts +++ b/tests/repo-ref.test.ts @@ -15,23 +15,23 @@ const failure = (input: string) => { describe('parseRepoRef — accepted forms', () => { it('accepts owner/repo shorthand', () => { - expect(ok('renrenmimi/DrillLab')).toEqual({ - owner: 'renrenmimi', - repo: 'DrillLab', - slug: 'renrenmimi/DrillLab', + expect(ok('octocat/hello-world')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + slug: 'octocat/hello-world', }); }); it('trims surrounding whitespace', () => { - expect(ok(' renrenmimi/DrillLab ').slug).toBe('renrenmimi/DrillLab'); + expect(ok(' octocat/hello-world ').slug).toBe('octocat/hello-world'); }); it('accepts a plain repository URL', () => { - expect(ok('https://github.com/renrenmimi/PetNote').slug).toBe('renrenmimi/PetNote'); + expect(ok('https://github.com/octocat/Spoon-Knife').slug).toBe('octocat/Spoon-Knife'); }); it('accepts a URL with extra path segments', () => { - expect(ok('https://github.com/renrenmimi/PetNote/tree/main/src/app').slug).toBe('renrenmimi/PetNote'); + expect(ok('https://github.com/octocat/Spoon-Knife/tree/main/src/app').slug).toBe('octocat/Spoon-Knife'); }); it('accepts a URL with a query string and fragment', () => { @@ -47,11 +47,11 @@ describe('parseRepoRef — accepted forms', () => { }); it('accepts an scp-style clone URL and strips the .git suffix', () => { - expect(ok('git@github.com:renrenmimi/DataData.git').slug).toBe('renrenmimi/DataData'); + expect(ok('git@github.com:octocat/example-repo.git').slug).toBe('octocat/example-repo'); }); it('accepts an https clone URL and strips the .git suffix', () => { - expect(ok('https://github.com/renrenmimi/DataData.git').slug).toBe('renrenmimi/DataData'); + expect(ok('https://github.com/octocat/example-repo.git').slug).toBe('octocat/example-repo'); }); it('accepts a trailing slash', () => { diff --git a/tests/service-routing.test.ts b/tests/service-routing.test.ts new file mode 100644 index 0000000..745354f --- /dev/null +++ b/tests/service-routing.test.ts @@ -0,0 +1,152 @@ +/** + * Where each repository reference is served from. + * + * The service layer is the place that decides between the built-in demo, the + * test fixtures and GitHub, so these tests exercise it directly with `fetch` + * stubbed out — any request that would have gone to api.github.com shows up as + * a call on the spy. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BUILTIN_DEMO_SLUG } from '@/lib/builtin/learning-platform'; +import { resetBuiltinCache } from '@/lib/builtin/provider'; +import { clearFixtureCache } from '@/lib/github/fixtures'; +import { + fetchCommitDetail, + fetchCommitPage, + fetchRepoMeta, + fetchTags, + fetchTree, + probeFirstCommitForPath, +} from '@/lib/github/service'; +import { isGitHubError } from '@/lib/github/errors'; +import { parseRepoRef, type RepoRef } from '@/lib/repo-ref'; + +const ref = (slug: string): RepoRef => { + const parsed = parseRepoRef(slug); + if (!parsed.ok) throw new Error(`bad slug ${slug}: ${parsed.message}`); + return parsed.value; +}; + +let fetchSpy: ReturnType; + +beforeEach(() => { + resetBuiltinCache(); + clearFixtureCache(); + delete process.env.RTM_FIXTURE_MODE; + // Any GitHub request fails loudly, so nothing can quietly reach the network. + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + throw new Error(`unexpected network request to ${String(input)}`); + }); +}); + +afterEach(() => { + fetchSpy.mockRestore(); + delete process.env.RTM_FIXTURE_MODE; +}); + +describe('the built-in demo, in production configuration', () => { + it('serves metadata without any network request', async () => { + const meta = await fetchRepoMeta(ref(BUILTIN_DEMO_SLUG)); + expect(meta.dataSource).toBe('builtin'); + expect(meta.slug).toBe(BUILTIN_DEMO_SLUG); + expect(meta.htmlUrl).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('serves the whole history without any network request', async () => { + const demoRef = ref(BUILTIN_DEMO_SLUG); + const page = await fetchCommitPage(demoRef, 'main', 1); + expect(page.commits).toHaveLength(16); + expect(page.totalCommits).toBe(16); + expect(page.hasOlder).toBe(false); + + for (const commit of page.commits) { + const detail = await fetchCommitDetail(demoRef, commit.sha); + const tree = await fetchTree(demoRef, commit.sha); + expect(detail.sha).toBe(commit.sha); + expect(tree.commitSha).toBe(commit.sha); + } + + await fetchTags(demoRef); + await probeFirstCommitForPath(demoRef, 'main', 'README.md'); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('reports a page past the end as empty rather than reaching out', async () => { + const page = await fetchCommitPage(ref(BUILTIN_DEMO_SLUG), 'main', 2); + expect(page.commits).toEqual([]); + expect(page.hasOlder).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects a sha that is not part of the demo, without reaching out', async () => { + await expect(fetchTree(ref(BUILTIN_DEMO_SLUG), '0'.repeat(40))).rejects.toSatisfy( + (error: unknown) => isGitHubError(error) && error.code === 'not-found', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('works identically when fixture mode is off and on', async () => { + const first = await fetchRepoMeta(ref(BUILTIN_DEMO_SLUG)); + process.env.RTM_FIXTURE_MODE = '1'; + resetBuiltinCache(); + const second = await fetchRepoMeta(ref(BUILTIN_DEMO_SLUG)); + expect(second).toEqual(first); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('other demo references are not special', () => { + it.each([ + 'demo/learning', + 'demo/learning-platform-extra', + 'demo/sample-app', + 'demo/big-history', + 'demo/one-commit', + ])('sends %s down the ordinary GitHub path', async (slug) => { + await expect(fetchRepoMeta(ref(slug))).rejects.toThrow(); + // It tried GitHub, which is the ordinary behaviour for an unknown repository. + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0]?.[0])).toContain('api.github.com'); + }); +}); + +describe('test fixtures are not reachable in production', () => { + it.each([ + 'rtm-fixtures/sample-app', + 'rtm-fixtures/big-history', + 'rtm-fixtures/one-commit', + 'rtm-fixtures/rate-limited', + ])('sends %s to GitHub when fixture mode is off', async (slug) => { + await expect(fetchRepoMeta(ref(slug))).rejects.toThrow(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0]?.[0])).toContain('api.github.com'); + }); + + it('only serves them once fixture mode is explicitly enabled', async () => { + process.env.RTM_FIXTURE_MODE = '1'; + const meta = await fetchRepoMeta(ref('rtm-fixtures/sample-app')); + expect(meta.dataSource).toBe('github'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('live repositories still use the GitHub adapter', () => { + it('builds an api.github.com URL from the validated owner and repository', async () => { + await expect(fetchRepoMeta(ref('octocat/hello-world'))).rejects.toThrow(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const url = String(fetchSpy.mock.calls[0]?.[0]); + expect(url).toBe('https://api.github.com/repos/octocat/hello-world'); + }); + + it('never sends the built-in reference to GitHub, even mixed in with live ones', async () => { + await fetchRepoMeta(ref(BUILTIN_DEMO_SLUG)); + await expect(fetchRepoMeta(ref('octocat/hello-world'))).rejects.toThrow(); + await fetchRepoMeta(ref(BUILTIN_DEMO_SLUG)); + + const urls = fetchSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(urls).toHaveLength(1); + expect(urls[0]).not.toContain('learning-platform'); + }); +}); diff --git a/tests/ui/panels.test.tsx b/tests/ui/panels.test.tsx index c0b9381..d685bc9 100644 --- a/tests/ui/panels.test.tsx +++ b/tests/ui/panels.test.tsx @@ -449,17 +449,17 @@ describe('RepoInput', () => { it('submits a valid reference', async () => { const onSubmit = vi.fn(); render(); - await userEvent.type(screen.getByRole('textbox'), 'renrenmimi/DrillLab'); + await userEvent.type(screen.getByRole('textbox'), 'octocat/hello-world'); await userEvent.click(screen.getByRole('button', { name: 'Load' })); - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ slug: 'renrenmimi/DrillLab' })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ slug: 'octocat/hello-world' })); }); it('accepts a pasted GitHub URL', async () => { const onSubmit = vi.fn(); render(); - await userEvent.type(screen.getByRole('textbox'), 'https://github.com/renrenmimi/PetNote/tree/main'); + await userEvent.type(screen.getByRole('textbox'), 'https://github.com/octocat/Spoon-Knife/tree/main'); await userEvent.click(screen.getByRole('button', { name: 'Load' })); - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ slug: 'renrenmimi/PetNote' })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ slug: 'octocat/Spoon-Knife' })); }); it('refuses a non-GitHub host and explains why', async () => { @@ -492,12 +492,12 @@ describe('RepoInput', () => { it('shows whatever repository is loaded', () => { render( , ); - expect(screen.getByRole('textbox')).toHaveValue('renrenmimi/DataData'); + expect(screen.getByRole('textbox')).toHaveValue('octocat/example-repo'); }); it('reverts an edit on Escape', async () => { diff --git a/tests/url-state.test.ts b/tests/url-state.test.ts index dc867c8..e98d742 100644 --- a/tests/url-state.test.ts +++ b/tests/url-state.test.ts @@ -10,8 +10,8 @@ const ref = (slug: string) => { describe('parseAppUrl', () => { it('reads a repository and commit', () => { - const state = parseAppUrl('?repo=renrenmimi%2FDrillLab&c=6cc6ba6'); - expect(state.repo?.slug).toBe('renrenmimi/DrillLab'); + const state = parseAppUrl('?repo=octocat%2Fhello-world&c=6cc6ba6'); + expect(state.repo?.slug).toBe('octocat/hello-world'); expect(state.commit).toBe('6cc6ba6'); }); @@ -79,7 +79,7 @@ describe('buildAppUrl', () => { describe('round trip', () => { it('rebuilds the same state a shared URL described', () => { - const original = { repo: ref('renrenmimi/PetNote'), commit: 'abcdef1234' }; + const original = { repo: ref('octocat/Spoon-Knife'), commit: 'abcdef1234' }; const restored = parseAppUrl(buildAppUrl(original).replace('/?', '?')); expect(restored.repo?.slug).toBe(original.repo.slug); expect(restored.commit).toBe(original.commit); From 756170e6ef96e428d61259897ea59a9d3072fda0 Mon Sep 17 00:00:00 2001 From: renrenmimi Date: Tue, 25 Aug 2026 02:07:08 -0700 Subject: [PATCH 2/5] refactor(ui): make the data source visible and drop the personal demo list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing page now offers one built-in demo and one neutral repository field instead of four named projects. Which data source is in use is stated rather than implied: - a badge reading "Built-in demo · 0 GitHub requests" replaces the quota meter when the data is built in, so no anonymous GitHub quota is shown as if the demo had spent it; - a source bar under the header names the current mode and, in live mode, offers the demo as a one-click alternative; - a built-in commit shows its sha as plain text, because there is no repository to open, and the insights panel repeats the synthetic-history disclosure; - the built-in marker is a glyph plus words, so it does not rely on colour. Built-in data is also exempt from the GitHub request budgets, which exist to protect a quota it does not consume: the demo now loads all sixteen diffs and all sixteen trees for every visitor, with or without a token, so every timeline position reads "exact". The quota meter gained an accessible name, which it needed anyway. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/api.spec.ts | Bin 9495 -> 12608 bytes e2e/helpers.ts | 35 +++++++- e2e/motion.spec.ts | 12 +-- e2e/playback.spec.ts | 105 +++++++++++----------- e2e/shell.spec.ts | 129 +++++++++++++++++++++++---- e2e/tree-and-detail.spec.ts | 91 +++++++++++-------- src/components/RateLimitMeter.tsx | 7 +- src/lib/client/history-controller.ts | 19 +++- 8 files changed, 279 insertions(+), 119 deletions(-) diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 96ff9c0c74f94d9a4f1804efe8e7dd35b03bd19c..b1160fcf0e185992229e7c1d721d2923066b588a 100644 GIT binary patch delta 2243 zcmbVNO>7%Q6jod(p|;#4PMtq1V%kPo8?u|YjUo_ok({1N6A@6wAu3|L9^2dO&RDy% z&7YD8i4!*%3AiFv;ub1dzzGovA<-Kb&N+i3!JP~5?XI20NqoSvwd||_#B=rg)R_7;@eW?wx3A@|;mwWn zym_xl)n~I4iVyvw#K>42^5O8`J&)QOuiP3ygpn9Ri^?WKw4Yxe3>M=UC#jz&W3(2} z^E7ponNr8;p(!fq`@(lkRy3rju!7dGWYez(0qx`T;Xn@ywVHXFDBV@xW?sh&!lbj4 z?evWwlf99r*-wyej8S`U{JRT)r`cI$=0tDkAGxz+&ogy4WAqj&ieCg5%n%;F4Orpqai) zC99Frif#(Wu_S3)9&STZuV$wWwnI&6?-CDYnK4en7;gc#%o!1Nl*l2r#kXkHX0Yvf z^-nURzRu047um^seI%G~(FQA*v|@_Dr)WI&1TPfdN~w=C3yIQ7I8iInL`@$N0=kic zDYz#i7FfP2YP$xCXhh74WIq5TgzdDLDIo)QWn9n&YhmTyKsbab&>EFyv#|K#=92y< z)$2!3_Lr9q<`%C=qP}bj*WlFY9-Id_P-~zu>daiNf2J)OR?wIV!=`n}9RS4x$V{nX zc1)|mG+1^pag<1xu|N~{jKR>M(vdFMg`^E#RA1%pU>lD9M;iidE|naSuaG?KPDHaz z=r)CUa+&%RHn2W^eJ$+8+hQ98Dfc4yJ*eVnkA_0wP2EhNM>$7g@CfxY6h@S zkDGWPqXS>3ws$FkiefjH)ejTn709E~gQ*}s-{PQcwX72a3-m@9K?|La;>bt&|uN}n>29gXl1GV9+Q_~fmr%PJ(P^t%D zPWTJ%UCXu~(JJ1*%jCL{OnY+fSY0cUY)2Li=t0b>18<2eYn`B0G4Hx8A$oPAF)oJG O9LupfKGYE#-Q{01MEjWl delta 545 zcmX?*G~H{%Iz|qcqRgBepTwleI~dJ4Q}c>a^KvtDGbdXyDNL4RT7o2Uf+-Ne+pNN@ z$Se+5u2z&$hNogLO7JQIi&W1~J@_m)y N$rjw=n`M+snE<6l$FBeY diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 4a15ee8..2d21761 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,17 +1,31 @@ import { expect, type Page } from '@playwright/test'; -/** Fixture repositories served by the production build in fixture mode. */ +/** + * References used by the suite. + * + * `demo` is the built-in curated history and needs no fixture mode. Everything + * under `rtm-fixtures/` is synthetic recorded-shaped data, served only because + * the e2e server runs with `RTM_FIXTURE_MODE=1`, and stands in for a live + * GitHub repository. + */ export const REPOS = { - tiny: 'renrenmimi/renren-across-tabs', - medium: 'renrenmimi/DrillLab', + /** The built-in demo: sixteen commits, no GitHub requests. */ + demo: 'demo/learning-platform', + /** Synthetic stand-ins for live repositories. */ + tiny: 'rtm-fixtures/tiny-app', + medium: 'rtm-fixtures/sample-app', one: 'rtm-fixtures/one-commit', empty: 'rtm-fixtures/empty-repo', big: 'rtm-fixtures/big-history', rateLimited: 'rtm-fixtures/rate-limited', private: 'rtm-fixtures/private-repo', missing: 'nobody/does-not-exist', + unknownDemo: 'demo/not-a-real-demo', } as const; +/** Repository names that must never appear in the shipped interface again. */ +export const REMOVED_PERSONAL_REPOS = ['renren-across-tabs', 'DataData', 'DrillLab', 'PetNote'] as const; + export function repoUrl(slug: string, commit?: string): string { const params = new URLSearchParams({ repo: slug }); if (commit) params.set('c', commit); @@ -60,6 +74,21 @@ export function landingLoadButton(page: Page) { return page.locator('main').getByRole('button', { name: 'Load', exact: true }); } +/** The one-click entry point for the built-in demo. */ +export function demoButton(page: Page) { + return page.getByRole('button', { name: 'Play the built-in demo' }); +} + +/** The header marker that says the current data is built in. */ +export function builtinBadge(page: Page) { + return page.locator('[class*="builtinBadge"]'); +} + +/** The GitHub quota meter, in either the known or the not-yet-observed state. */ +export function quotaMeter(page: Page) { + return page.getByLabel('GitHub request quota'); +} + /** The subject line of the commit under the playhead. */ export function commitSubject(page: Page) { return page.locator('#selected-commit-subject'); diff --git a/e2e/motion.spec.ts b/e2e/motion.spec.ts index eab460d..c1f0836 100644 --- a/e2e/motion.spec.ts +++ b/e2e/motion.spec.ts @@ -14,7 +14,7 @@ test.describe('reduced motion', () => { }); test('transition durations collapse to nothing', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const durations = await page.evaluate(() => { const root = getComputedStyle(document.documentElement); @@ -32,8 +32,8 @@ test.describe('reduced motion', () => { }); test('the change marker settles immediately instead of pulsing', async ({ page }) => { - await openRepo(page, REPOS.medium); - await transport(page).slider.fill('63'); + await openRepo(page, REPOS.demo); + await transport(page).slider.fill('15'); await page.waitForTimeout(400); const marker = page.locator('[role="treeitem"][data-status] [class*="marker"]').first(); @@ -43,13 +43,13 @@ test.describe('reduced motion', () => { }); test('playback still works, and still steps one commit at a time', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const t = transport(page); - await t.slider.fill('10'); + await t.slider.fill('4'); await t.play.click(); await expect(t.pause).toBeVisible(); - await expect.poll(() => currentIndex(page), { timeout: 8000 }).toBeGreaterThan(11); + await expect.poll(() => currentIndex(page), { timeout: 8000 }).toBeGreaterThan(5); await t.pause.click(); await expect(t.play).toBeVisible(); }); diff --git a/e2e/playback.spec.ts b/e2e/playback.spec.ts index 203fc14..d0013f5 100644 --- a/e2e/playback.spec.ts +++ b/e2e/playback.spec.ts @@ -1,9 +1,9 @@ import { expect, test } from '@playwright/test'; -import { commitSubject, currentIndex, openRepo, REPOS, repoUrl, transport } from './helpers'; +import { builtinBadge, commitSubject, currentIndex, openRepo, REPOS, repoUrl, transport } from './helpers'; test.describe('playback', () => { test('plays forward from the beginning and can be paused at any point', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const t = transport(page); // Pressing play at the newest commit rewinds to the start of the range. @@ -19,7 +19,7 @@ test.describe('playback', () => { }); test('resumes from where it paused', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const t = transport(page); await t.slider.fill('10'); @@ -33,14 +33,14 @@ test.describe('playback', () => { }); test('changing speed does not reset the timeline', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const t = transport(page); - await t.slider.fill('25'); - expect(await currentIndex(page)).toBe(25); + await t.slider.fill('9'); + expect(await currentIndex(page)).toBe(9); await page.getByRole('button', { name: /^4/ }).click(); - expect(await currentIndex(page)).toBe(25); + expect(await currentIndex(page)).toBe(9); await expect(page.getByRole('button', { name: /^4/ })).toHaveAttribute('aria-pressed', 'true'); // And it does not start playback either. @@ -48,7 +48,7 @@ test.describe('playback', () => { }); test('changing speed while playing keeps playing from the same place', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const t = transport(page); await t.slider.fill('5'); @@ -75,18 +75,18 @@ test.describe('playback', () => { }); test('steps one commit at a time', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const t = transport(page); - await t.slider.fill('30'); + await t.slider.fill('9'); await t.next.click(); - expect(await currentIndex(page)).toBe(31); + expect(await currentIndex(page)).toBe(10); await t.previous.click(); - expect(await currentIndex(page)).toBe(30); + expect(await currentIndex(page)).toBe(9); }); test('keeps the selected commit readable while playing', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.getByRole('button', { name: /^2/ }).click(); await transport(page).play.click(); @@ -100,7 +100,7 @@ test.describe('playback', () => { test.describe('keyboard control', () => { test('space toggles playback', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.locator('body').click({ position: { x: 5, y: 200 } }); await page.keyboard.press('Space'); @@ -110,28 +110,28 @@ test.describe('keyboard control', () => { }); test('arrows step, shift-arrows jump, Home and End go to the ends', async ({ page }) => { - await openRepo(page, REPOS.medium); - await transport(page).slider.fill('40'); + await openRepo(page, REPOS.demo); + await transport(page).slider.fill('12'); await page.locator('body').click({ position: { x: 5, y: 200 } }); await page.keyboard.press('ArrowLeft'); - expect(await currentIndex(page)).toBe(39); + expect(await currentIndex(page)).toBe(11); await page.keyboard.press('ArrowRight'); - expect(await currentIndex(page)).toBe(40); + expect(await currentIndex(page)).toBe(12); await page.keyboard.press('Shift+ArrowLeft'); - expect(await currentIndex(page)).toBe(30); + expect(await currentIndex(page)).toBe(2); await page.keyboard.press('Shift+ArrowRight'); - expect(await currentIndex(page)).toBe(40); + expect(await currentIndex(page)).toBe(12); await page.keyboard.press('Home'); expect(await currentIndex(page)).toBe(0); await page.keyboard.press('End'); - expect(await currentIndex(page)).toBe(63); + expect(await currentIndex(page)).toBe(15); }); test('brackets change the speed', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.locator('body').click({ position: { x: 5, y: 200 } }); await page.keyboard.press(']'); @@ -141,14 +141,14 @@ test.describe('keyboard control', () => { }); test('slash focuses the path filter', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.locator('body').click({ position: { x: 5, y: 200 } }); await page.keyboard.press('/'); await expect(page.getByLabel('Filter file paths in the tree')).toBeFocused(); }); test('shortcuts stay out of the way while typing', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const filter = page.getByLabel('Filter file paths in the tree'); await filter.click(); await filter.type('page k'); @@ -157,19 +157,19 @@ test.describe('keyboard control', () => { }); test('the scrubber responds to arrow keys', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const slider = transport(page).slider; - await slider.fill('20'); + await slider.fill('8'); await slider.focus(); await page.keyboard.press('ArrowRight'); - expect(await currentIndex(page)).toBe(21); + expect(await currentIndex(page)).toBe(9); }); }); test.describe('URL and history', () => { test('a shared URL reconstructs the same view', async ({ page }) => { - await openRepo(page, REPOS.medium); - await transport(page).slider.fill('12'); + await openRepo(page, REPOS.demo); + await transport(page).slider.fill('7'); await page.waitForTimeout(400); const shared = page.url(); @@ -177,37 +177,37 @@ test.describe('URL and history', () => { expect(shared).toMatch(/[?&]c=[0-9a-f]{7,}/); await page.goto(shared); - await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('12'); + await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('7'); await expect(commitSubject(page)).toHaveText(subject ?? ''); }); test('an abbreviated sha in the URL selects the right commit', async ({ page }) => { - await openRepo(page, REPOS.medium); - await transport(page).slider.fill('7'); + await openRepo(page, REPOS.demo); + await transport(page).slider.fill('4'); await page.waitForTimeout(400); const sha = new URL(page.url()).searchParams.get('c')!; - await page.goto(repoUrl(REPOS.medium, sha.slice(0, 7))); - await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('7'); + await page.goto(repoUrl(REPOS.demo, sha.slice(0, 7))); + await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('4'); }); test('Back and Forward move between repositories', async ({ page }) => { await openRepo(page, REPOS.tiny); await expect(page.getByText('full history — 5 commits')).toBeVisible(); - await page.getByRole('button', { name: /DrillLab/ }).first().click(); - await expect(page.getByText('full history — 64 commits')).toBeVisible(); + await page.getByRole('button', { name: /Built-in demo/ }).first().click(); + await expect(page.getByText('full history — 16 commits')).toBeVisible(); await page.goBack(); await expect(page.getByText('full history — 5 commits')).toBeVisible(); await page.goForward(); - await expect(page.getByText('full history — 64 commits')).toBeVisible(); + await expect(page.getByText('full history — 16 commits')).toBeVisible(); }); test('Back returns to the commit a jump started from', async ({ page }) => { - await openRepo(page, REPOS.medium); - await transport(page).slider.fill('50'); + await openRepo(page, REPOS.demo); + await transport(page).slider.fill('11'); await page.waitForTimeout(400); await page.getByRole('tab', { name: 'Milestones' }).click(); @@ -215,47 +215,50 @@ test.describe('URL and history', () => { await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('0'); await page.goBack(); - await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('50'); + await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('11'); }); test('playback does not flood the history stack', async ({ page }) => { - await openRepo(page, REPOS.medium); + // A long range, so playback is still running when the count is taken. + await openRepo(page, REPOS.big); const before = await page.evaluate(() => history.length); await page.getByRole('button', { name: /^8/ }).click(); await transport(page).play.click(); await page.waitForTimeout(2000); - await transport(page).pause.click(); const after = await page.evaluate(() => history.length); + expect(await currentIndex(page)).toBeGreaterThan(5); expect(after - before).toBeLessThanOrEqual(2); }); test('an invalid commit in the URL still loads the repository', async ({ page }) => { - await page.goto(`/?repo=${encodeURIComponent(REPOS.medium)}&c=zzzz`); + await page.goto(`/?repo=${encodeURIComponent(REPOS.demo)}&c=zzzz`); await expect(page.getByRole('slider', { name: /Commit position/ })).toBeVisible(); - await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('63'); + await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('15'); }); }); test.describe('switching repositories', () => { test('replaces the timeline rather than mixing two histories', async ({ page }) => { - await openRepo(page, REPOS.medium); - await expect(page.getByText('full history — 64 commits')).toBeVisible(); - await expect(page.getByText('app-shell.tsx').first()).toBeVisible(); + await openRepo(page, REPOS.demo); + await expect(page.getByText('full history — 16 commits')).toBeVisible(); + await expect(page.getByText('scroll-lock.ts').first()).toBeVisible(); + await expect(builtinBadge(page)).toBeVisible(); - await page.getByRole('button', { name: /renren-across-tabs/ }).first().click(); + await openRepo(page, REPOS.tiny); await expect(page.getByText('full history — 5 commits')).toBeVisible(); - await expect(page.getByText('app-shell.tsx')).toHaveCount(0); + await expect(page.getByText('scroll-lock.ts')).toHaveCount(0); + await expect(builtinBadge(page)).toHaveCount(0); await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('4'); }); test('switching mid-playback stops the old timeline', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await transport(page).play.click(); await expect(transport(page).pause).toBeVisible(); - await page.getByRole('button', { name: /renren-across-tabs/ }).first().click(); + await openRepo(page, REPOS.tiny); await expect(page.getByText('full history — 5 commits')).toBeVisible(); const index = await currentIndex(page); diff --git a/e2e/shell.spec.ts b/e2e/shell.spec.ts index 24c3f7e..8570c81 100644 --- a/e2e/shell.spec.ts +++ b/e2e/shell.spec.ts @@ -1,11 +1,15 @@ import { expect, test } from '@playwright/test'; import { appAlert, + builtinBadge, collectProblems, + demoButton, expectNoHorizontalOverflow, landingInput, landingLoadButton, openRepo, + quotaMeter, + REMOVED_PERSONAL_REPOS, REPOS, repoUrl, } from './helpers'; @@ -27,15 +31,80 @@ test.describe('initial shell', () => { expect(problems).toEqual([]); }); - test('offers one-click demos', async ({ page }) => { + test('offers exactly one built-in demo, not a list of projects', async ({ page }) => { await page.goto('/'); - for (const label of ['renren-across-tabs', 'DataData', 'DrillLab', 'PetNote']) { - await expect(page.getByRole('button', { name: new RegExp(label) }).first()).toBeVisible(); + + await expect(page.getByRole('heading', { level: 2, name: 'Learning Platform' })).toBeVisible(); + await expect(demoButton(page)).toBeVisible(); + await expect(page.getByText(/curated, synthetic history/)).toBeVisible(); + await expect(page.getByText(/16 commits · no GitHub account · 0 API requests/)).toBeVisible(); + + // One H1 on the page, and the demo is a section under it. + await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); + }); + + test('names no personal repository anywhere in the interface', async ({ page }) => { + for (const path of ['/', repoUrl(REPOS.demo), repoUrl(REPOS.medium)]) { + await page.goto(path); + await page.waitForTimeout(1200); + const text = await page.evaluate(() => document.body.innerText); + const html = await page.content(); + for (const name of REMOVED_PERSONAL_REPOS) { + expect(text, `${name} in text of ${path}`).not.toContain(name); + expect(html, `${name} in markup of ${path}`).not.toContain(name); + } } }); - test('does not autoplay on arrival', async ({ page }) => { + test('loads the built-in demo in one action, with no GitHub request', async ({ page }) => { + const githubCalls: string[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.hostname.includes('github')) githubCalls.push(request.url()); + }); + + await page.goto('/'); + await demoButton(page).click(); + + await expect(page.getByRole('slider', { name: /Commit position/ })).toBeVisible(); + await expect(page.getByText('full history — 16 commits')).toBeVisible(); + await expect(builtinBadge(page)).toContainText('Built-in demo'); + await expect(builtinBadge(page)).toContainText('0 GitHub requests'); + expect(githubCalls).toEqual([]); + }); + + test('does not show a GitHub quota meter for built-in data', async ({ page }) => { + await openRepo(page, REPOS.demo); + await expect(builtinBadge(page)).toBeVisible(); + await expect(quotaMeter(page)).toHaveCount(0); + + // A live repository still reports its quota honestly. await openRepo(page, REPOS.medium); + await expect(quotaMeter(page)).toHaveCount(1); + await expect(builtinBadge(page)).toHaveCount(0); + }); + + test('offers no "view on GitHub" affordance for a built-in commit', async ({ page }) => { + await openRepo(page, REPOS.demo); + await page.waitForTimeout(1200); + await expect(page.locator('a[href*="/commit/"]')).toHaveCount(0); + await expect(page.locator('main a[href*="github.com"]')).toHaveCount(0); + await expect(page.getByText(/curated, synthetic history/).first()).toBeVisible(); + }); + + test('distinguishes built-in from live without relying on colour', async ({ page }) => { + await openRepo(page, REPOS.demo); + // A glyph and words, not a hue. + await expect(builtinBadge(page).locator('svg')).toHaveCount(1); + await expect(page.getByText('Learning Platform', { exact: true }).first()).toBeVisible(); + + await openRepo(page, REPOS.medium); + await expect(page.getByText('Live repository')).toBeVisible(); + await expect(page.getByText(/Read from GitHub/)).toBeVisible(); + }); + + test('does not autoplay on arrival', async ({ page }) => { + await openRepo(page, REPOS.demo); await expect(page.getByRole('button', { name: 'Play history' })).toBeVisible(); const before = await page.getByRole('slider', { name: /Commit position/ }).inputValue(); await page.waitForTimeout(1500); @@ -43,10 +112,10 @@ test.describe('initial shell', () => { }); test('starts on the newest commit of the loaded range', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const slider = page.getByRole('slider', { name: /Commit position/ }); - expect(await slider.inputValue()).toBe('63'); - await expect(page.getByText('full history — 64 commits')).toBeVisible(); + expect(await slider.inputValue()).toBe('15'); + await expect(page.getByText('full history — 16 commits')).toBeVisible(); }); test('has a working skip link and does not trap focus', async ({ page }) => { @@ -109,14 +178,19 @@ test.describe('repository input', () => { await landingInput(page).fill(`https://github.com/${REPOS.medium}/tree/main/src`); await landingLoadButton(page).click(); await expect(page.getByRole('slider', { name: /Commit position/ })).toBeVisible(); - await expect(page).toHaveURL(/repo=renrenmimi%2FDrillLab/); + await expect(page).toHaveURL(/repo=rtm-fixtures%2Fsample-app/); }); - test('loads a demo in one click', async ({ page }) => { + test('uses a neutral placeholder and a neutral validation example', async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: /renren-across-tabs/ }).first().click(); - await expect(page.getByRole('slider', { name: /Commit position/ })).toBeVisible(); - await expect(page.getByText('full history — 5 commits')).toBeVisible(); + await expect(landingInput(page)).toHaveAttribute('placeholder', 'owner/repository'); + + await landingInput(page).fill('not-a-repo'); + await landingLoadButton(page).click(); + const message = await appAlert(page).innerText(); + for (const name of REMOVED_PERSONAL_REPOS) { + expect(message).not.toContain(name); + } }); }); @@ -140,6 +214,23 @@ test.describe('states', () => { await expect(appAlert(page)).toContainText(/rate-limit window resets/); }); + test('a rate-limited repository offers the built-in demo, and says it is a substitution', async ({ page }) => { + await page.goto(repoUrl(REPOS.rateLimited)); + await expect(appAlert(page)).toContainText(/rate limit/i); + await expect(page.getByText(/not the repository you asked for/)).toBeVisible(); + + await page.getByRole('button', { name: 'Open the built-in demo' }).click(); + await expect(page.getByRole('slider', { name: /Commit position/ })).toBeVisible(); + await expect(builtinBadge(page)).toContainText('Built-in demo'); + await expect(page.getByText('full history — 16 commits')).toBeVisible(); + }); + + test('treats an unknown demo reference as an ordinary missing repository', async ({ page }) => { + await page.goto(repoUrl(REPOS.unknownDemo)); + await expect(appAlert(page)).toContainText(/could not be found/); + await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveCount(0); + }); + test('explains an empty repository', async ({ page }) => { await page.goto(repoUrl(REPOS.empty)); await expect(page.getByRole('heading', { name: /Nothing to play back yet/ })).toBeVisible(); @@ -156,8 +247,8 @@ test.describe('states', () => { }); test('lets the visitor return to the landing screen', async ({ page }) => { - await openRepo(page, REPOS.tiny); - await page.getByRole('button', { name: 'clear' }).click(); + await openRepo(page, REPOS.demo); + await page.getByRole('button', { name: 'Start over' }).click(); await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); await expect(page).toHaveURL(/\/$/); }); @@ -166,11 +257,11 @@ test.describe('states', () => { test.describe('layout', () => { test('does not overflow sideways at 360px', async ({ page }) => { await page.setViewportSize({ width: 360, height: 780 }); - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await expectNoHorizontalOverflow(page); // Also with a patch open, which is the widest content in the app. - await page.getByRole('button', { name: /CLAUDE\.md|\.tsx|\.ts/ }).first().click(); + await page.locator('[class*="fileButton"]').first().click(); await expectNoHorizontalOverflow(page); }); @@ -182,7 +273,7 @@ test.describe('layout', () => { test('puts the commit detail first on a narrow screen', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const commitTop = await page.locator('section', { has: page.getByRole('heading', { name: 'Commit' }) }).first().evaluate((el) => el.getBoundingClientRect().top + window.scrollY); const treeTop = await page.locator('section', { has: page.getByRole('heading', { name: 'Working tree' }) }).first().evaluate((el) => el.getBoundingClientRect().top + window.scrollY); @@ -191,8 +282,8 @@ test.describe('layout', () => { test('reports no console errors while browsing a repository', async ({ page }) => { const problems = collectProblems(page); - await openRepo(page, REPOS.medium); - await page.getByRole('slider', { name: /Commit position/ }).fill('20'); + await openRepo(page, REPOS.demo); + await page.getByRole('slider', { name: /Commit position/ }).fill('8'); await page.waitForTimeout(800); await page.getByRole('tab', { name: 'Milestones' }).click(); await page.getByRole('tab', { name: 'Commits' }).click(); diff --git a/e2e/tree-and-detail.spec.ts b/e2e/tree-and-detail.spec.ts index 88d62b7..abf696c 100644 --- a/e2e/tree-and-detail.spec.ts +++ b/e2e/tree-and-detail.spec.ts @@ -3,21 +3,21 @@ import { commitSubject, openRepo, REPOS, transport } from './helpers'; test.describe('evolving file tree', () => { test('starts nearly empty and fills in as the timeline advances', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const t = transport(page); await t.slider.fill('0'); - await expect(page.getByText('1 file')).toBeVisible(); + await expect(page.getByText('6 files')).toBeVisible(); - await t.slider.fill('63'); - await expect(page.getByText(/\d{3} files/)).toBeVisible(); + await t.slider.fill('15'); + await expect(page.getByText(/\d{2,} files/)).toBeVisible(); }); test('expands and collapses folders', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); // Rows carry their full path as a title, which is the only unambiguous handle. const app = page.locator('[role="treeitem"] button[title="app"]'); - const arena = page.locator('[role="treeitem"] button[title="app/arena"]'); + const arena = page.locator('[role="treeitem"] button[title="app/lessons"]'); // Top-level folders start open. await expect(arena).toBeVisible(); @@ -28,8 +28,8 @@ test.describe('evolving file tree', () => { }); test('marks what the selected commit changed', async ({ page }) => { - await openRepo(page, REPOS.medium); - await transport(page).slider.fill('63'); + await openRepo(page, REPOS.demo); + await transport(page).slider.fill('15'); await page.waitForTimeout(600); const changed = page.locator('[role="treeitem"][data-status]'); @@ -38,34 +38,36 @@ test.describe('evolving file tree', () => { }); test('filters paths', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const filter = page.getByLabel('Filter file paths in the tree'); - await filter.fill('arena-clock'); - await expect(page.getByText('arena-clock.tsx')).toBeVisible(); - await expect(page.getByText('globals.css')).toHaveCount(0); + await filter.fill('scroll-lock'); + await expect(page.getByText('scroll-lock.ts').first()).toBeVisible(); + await expect(page.getByText('tokens.css')).toHaveCount(0); await filter.fill('zzzzz'); await expect(page.getByText(/No paths match/)).toBeVisible(); }); test('labels generated and binary files', async ({ page }) => { - await openRepo(page, REPOS.tiny); - await transport(page).slider.fill('4'); - await page.waitForTimeout(500); - const badges = page.locator('[role="treeitem"] >> text=/^(binary|generated)$/'); - expect(await badges.count()).toBeGreaterThanOrEqual(0); + await openRepo(page, REPOS.demo); + await transport(page).slider.fill('15'); + await page.waitForTimeout(600); + + // The demo ships a lockfile and a generated index, both of which are labelled. + await page.getByLabel('Filter file paths in the tree').fill('lock'); + await expect(page.getByText('generated', { exact: true }).first()).toBeVisible(); }); test('states how the tree was reconstructed', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const badge = page.locator('[class*="fidelity"]').first(); await expect(badge).toBeVisible(); await expect(badge).toHaveText(/exact|rebuilt|≈|no snapshot/); }); test('shows the most recent change to a selected file', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.waitForTimeout(1500); await page.getByLabel('Filter file paths in the tree').fill('app-shell'); @@ -87,24 +89,24 @@ test.describe('evolving file tree', () => { test.describe('commit detail', () => { test('shows message, author, timestamp and a link to the real commit', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await expect(commitSubject(page)).not.toHaveText(''); await expect(page.getByText(/UTC/)).toBeVisible(); - const link = page.locator('a[href*="/commit/"]').first(); - await expect(link).toHaveAttribute('href', /^https:\/\/github\.com\/renrenmimi\/DrillLab\/commit\/[0-9a-f]{40}$/); - await expect(link).toHaveAttribute('rel', /noopener/); + // Built-in commits have no repository to open, so the sha is plain text. + await expect(page.locator('a[href*="/commit/"]')).toHaveCount(0); + await expect(page.locator('[class*="shaPlain"]')).toHaveCount(1); }); test('lists files changed with additions and deletions', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.waitForTimeout(1200); await expect(page.getByRole('heading', { name: 'Files changed' })).toBeVisible(); await expect(page.locator('[class*="fileItem"]').first()).toBeVisible(); }); test('opens a readable patch on demand', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.waitForTimeout(1500); const firstFile = page.locator('[class*="fileButton"]').first(); @@ -117,7 +119,7 @@ test.describe('commit detail', () => { }); test('closes the patch when the timeline moves on', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.waitForTimeout(1500); await page.locator('[class*="fileButton"]').first().click(); await expect(page.locator('[aria-label^="Diff for"]').first()).toBeVisible(); @@ -128,7 +130,7 @@ test.describe('commit detail', () => { }); test('explains why a milestone fired', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await transport(page).slider.fill('0'); await page.waitForTimeout(600); @@ -139,7 +141,7 @@ test.describe('commit detail', () => { test.describe('insights', () => { test('moves between the three views with the keyboard', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); const growth = page.getByRole('tab', { name: 'Growth' }); await growth.click(); await expect(growth).toHaveAttribute('aria-selected', 'true'); @@ -153,7 +155,7 @@ test.describe('insights', () => { }); test('offers a text alternative for the growth chart', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.waitForTimeout(1500); const chart = page.getByRole('img', { name: /Repository growth across/ }); @@ -165,25 +167,25 @@ test.describe('insights', () => { }); test('labels the language mix an estimate and says what it excluded', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.waitForTimeout(1500); await expect(page.getByText('estimate', { exact: true })).toBeVisible(); await expect(page.getByText(/Estimated from file extensions, not from file contents/)).toBeVisible(); }); test('states the milestone disclaimer', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.getByRole('tab', { name: /Milestones/ }).click(); await expect(page.getByText(/pattern matches on file paths, commit metadata and tags/)).toBeVisible(); await expect(page.getByText(/not what anyone intended/)).toBeVisible(); }); test('searches commit messages inside the loaded range', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.getByRole('tab', { name: 'Commits' }).click(); const search = page.getByLabel(/Search commit messages/); - await search.fill('mobile'); + await search.fill('drawer'); await expect(page.locator('[class*="commitItem"]').first()).toBeVisible(); const hits = await page.locator('[class*="commitItem"]').count(); expect(hits).toBeGreaterThan(0); @@ -193,10 +195,10 @@ test.describe('insights', () => { }); test('jumps to a commit from the list', async ({ page }) => { - await openRepo(page, REPOS.medium); + await openRepo(page, REPOS.demo); await page.getByRole('tab', { name: 'Commits' }).click(); await page.locator('[class*="commitItem"]').nth(3).click(); - await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('60'); + await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveValue('12'); }); }); @@ -227,8 +229,23 @@ test.describe('loaded range honesty', () => { }); test('calls a complete history full', async ({ page }) => { - await openRepo(page, REPOS.tiny); - await expect(page.getByText('full history — 5 commits')).toBeVisible(); + await openRepo(page, REPOS.demo); + await expect(page.getByText('full history — 16 commits')).toBeVisible(); await expect(page.getByText(/latest \d+ of/)).toHaveCount(0); }); + + test('reports full coverage for the built-in demo, with no gaps', async ({ page }) => { + await openRepo(page, REPOS.demo); + await page.waitForTimeout(1800); + + await expect(page.getByText('diffs loaded for 16 of 16 commits')).toBeVisible(); + // Every position is read from a real tree, so nothing is carried forward. + await expect(page.getByText('carried forward')).toHaveCount(0); + + for (const index of ['0', '5', '10', '15']) { + await page.getByRole('slider', { name: /Commit position/ }).fill(index); + await page.waitForTimeout(250); + await expect(page.locator('[class*="fidelity"]').first()).toHaveText('exact'); + } + }); }); diff --git a/src/components/RateLimitMeter.tsx b/src/components/RateLimitMeter.tsx index 830a6f0..99b72b8 100644 --- a/src/components/RateLimitMeter.tsx +++ b/src/components/RateLimitMeter.tsx @@ -28,7 +28,11 @@ export function RateLimitMeter({ snapshot, ageMs, tokenConfigured }: Props) { if (!snapshot) { return ( -
+
quota
@@ -44,6 +48,7 @@ export function RateLimitMeter({ snapshot, ageMs, tokenConfigured }: Props) {
{ - if (!this.#authenticated()) return; // too expensive on the anonymous limit + // Path probes cost a request each, which the anonymous limit cannot spare. + // Built-in data has no limit to spare. + if (!this.#authenticated() && !this.#isBuiltin()) return; const commits = this.#state.commits; const headSha = commits[commits.length - 1]?.sha; if (!headSha) return; From 631dba1336a881f4622e698857b919e113a44003 Mon Sep 17 00:00:00 2001 From: renrenmimi Date: Tue, 25 Aug 2026 02:22:24 -0700 Subject: [PATCH 3/5] docs: rewrite the demo sections and add a token canary check The README described four personal repositories as the demo set. It now covers the built-in demo, why it replaced them, and live GitHub mode as a separate path, plus the `meta.dataSource` field, the new tree-budget rule, the Vercel instructions for the optional server-only token, and the fact that the demo needs neither a token nor any quota. Adds `npm run token-canary`, which builds with a deliberately fake token and verifies it appears in no page response, no response header, none of the JavaScript or CSS chunks the browser actually loads, and no API payload. Adds a CI workflow. Beyond the usual checks it runs a privacy audit that fails the build if a removed repository name reappears in the tree, if a `github.com/renrenmimi/...` link points anywhere other than this project, or if the token variable is ever given a `NEXT_PUBLIC_` prefix. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 88 ++++++++++++++++ README.md | 174 +++++++++++++++++++++++-------- e2e/api.spec.ts | Bin 12608 -> 12593 bytes e2e/helpers.ts | 30 +++++- e2e/shell.spec.ts | 72 ++++++++----- package.json | 3 +- scripts/screenshots.mjs | 2 + scripts/token-canary.mjs | 63 +++++++++++ src/components/States.tsx | 2 +- src/components/TimeMachine.tsx | 17 ++- src/components/shell.module.css | 14 +++ src/components/states.module.css | 9 ++ tests/builtin-demo.test.ts | 33 ++++-- tests/repo-ref.test.ts | 2 +- 14 files changed, 425 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/token-canary.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6e3c408 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: typecheck, lint, unit, build, e2e + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit and component tests + run: npm test + + - name: Production build + run: npm run build + + # The end-to-end suite runs against the production build above, in fixture + # mode and with no token, so it never contacts GitHub. + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: End-to-end tests + run: npm run test:e2e:only + + - name: Upload the Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report + retention-days: 7 + + privacy-audit: + name: privacy audit + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + + # The demo is synthetic; no personal repository may reappear in the tree. + - name: No personal repository references in tracked files + run: | + if git grep -nIi -e 'renren-across-tabs' -e 'DataData' -e 'DrillLab' -e 'PetNote' -- .; then + echo "::error::a removed personal repository name is back in the tree" + exit 1 + fi + echo "no personal repository references" + + - name: Every github.com/renrenmimi link points at this project + run: | + if git grep -nI 'github.com/renrenmimi/' -- . | grep -v 'github.com/renrenmimi/RepoTimeMachine'; then + echo "::error::a github.com/renrenmimi link points somewhere other than RepoTimeMachine" + exit 1 + fi + echo "all renrenmimi links point at RepoTimeMachine" + + - name: No token name is exposed to the client bundle + run: | + if git grep -nI 'NEXT_PUBLIC_GITHUB_TOKEN' -- .; then + echo "::error::GITHUB_TOKEN must never be exposed with a NEXT_PUBLIC_ prefix" + exit 1 + fi + echo "no public token variable" diff --git a/README.md b/README.md index 95ebfa0..3af4c41 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ # Repo Time Machine -Turn the Git history of a public GitHub repository into a timeline you can play. +Turn the Git history of a repository into a timeline you can play. -Paste `owner/repository` or a GitHub URL. Repo Time Machine loads the default -branch, reconstructs the file tree at each commit, and lets you scrub or play -through the history while the tree fills in, files change, and the commit detail -follows along. +There are two ways in: + +- **the built-in demo** — a curated sixteen-commit history that needs no GitHub + account and makes no GitHub requests; +- **any public GitHub repository** — paste `owner/repository` or a URL and it + reads the default branch through GitHub's REST API. + +Either way, the tool reconstructs the file tree at each commit and lets you scrub +or play through the history while the tree fills in, files change, and the commit +detail follows along. It answers questions that are awkward to answer on GitHub itself: @@ -32,18 +38,47 @@ what it knows. GitHub's API does not hand out a cheap answer to "what did the tree look like at commit N", and most tools that claim to show it are quietly guessing. This one says which parts it measured and which parts it inferred. -## Demos +## The built-in demo -Four repositories with different history shapes: +`Learning Platform` is a curated sixteen-commit history describing how a learning +product might plausibly grow: a shell, then design tokens, then models, lessons, +practice, a coding workspace, learning paths, a dashboard, and finally a pass for +responsive layout and accessibility. -| Repository | Commits | What it shows | -| --- | --- | --- | -| [`renrenmimi/renren-across-tabs`](https://github.com/renrenmimi/renren-across-tabs) | ~5 | The whole history fits on one screen. | -| [`renrenmimi/DataData`](https://github.com/renrenmimi/DataData) | ~32 | A course project that grew a structure early. | -| [`renrenmimi/DrillLab`](https://github.com/renrenmimi/DrillLab) | ~64 | A few days of work with a visible framework moment. | -| [`renrenmimi/PetNote`](https://github.com/renrenmimi/PetNote) | ~246 | Months of history; shows the loaded-range limits. | +It is **synthetic**. The commits, authors, object ids, file paths and diffs are +invented; nothing is copied from, or reproduces, any real repository. It exists so +the tool can demonstrate itself without reading anybody's account. + +Because it ships with the application: + +- it makes **zero GitHub requests** and needs no token; +- it is identical for every visitor, so a shared link always shows the same thing; +- every timeline position is **exact**, because a full tree is stored for all + sixteen commits. + +The interface says which mode you are in. Built-in data carries a +`Built-in demo · 0 GitHub requests` badge, the GitHub quota meter is hidden, and +commits show their sha as plain text rather than as a link, because there is no +repository to open. -Any other public repository works too. +### Why it replaced a list of real projects + +Earlier versions shipped a demo list of the author's own repositories. That put +personal project names, file trees and commit messages into the product, the +fixtures, the tests and the documentation for no functional reason. One synthetic +history does the same job without exposing anything. + +Earlier public commits still contain those references; this change removes them +from the current tree rather than rewriting history. + +## Live GitHub mode + +Enter `owner/repository`, a GitHub URL, or a clone URL. Everything documented +below — loaded ranges, tree reconstruction, caching, rate limits, milestones — +applies to this mode. The built-in demo is served locally and bypasses all of it. + +Unknown `demo/*` references are not special: they go down the ordinary GitHub path +and produce the ordinary not-found result. ## What "loaded history" means @@ -122,6 +157,10 @@ The browser never talks to GitHub. Every request goes through a route handler under `/api/gh/*`, which means the token stays on the server and the Content Security Policy can restrict `connect-src` to `'self'`. +Every response carries `meta.dataSource`, which is `builtin` or `github`. The +interface switches on that field rather than on any visible label, so it can never +be wrong about which mode it is in. + | Route | GitHub endpoint | Cache | | --- | --- | --- | | `/api/gh/repo` | `/repos/{o}/{r}` | 300s | @@ -147,10 +186,18 @@ so the UI does not depend on the shape of GitHub's JSON. | Commit diffs | background: up to 400 with a token, 12 without | | Milestone path probes | up to 10, only with a token | -Two rules keep this in bounds: +None of it applies to the built-in demo, which is served locally. -- **Nothing is fetched per commit during the initial load.** Metadata, commits, - tags and two trees are enough to render; everything else arrives afterwards. +Four rules keep it in bounds: + +- **The first usable screen is the selected commit.** Metadata, then the commit + list, then that commit's tree and diff — concurrently. Tags, remaining + checkpoints, background diffs and milestone probes all queue behind it. +- **Nothing is fetched per commit during the initial load.** +- **Trees are only read where diffs cannot reach.** If every diff in the range is + going to be loaded anyway, a baseline tree plus the selected one already makes + every position exact, so no further trees are requested. A five-commit + repository therefore costs two tree reads, not five. - **Background work stops** when the observed remaining quota drops below a floor (60 authenticated, 12 anonymous), and is **cancelled outright** when the visitor switches repository. @@ -184,7 +231,9 @@ from when it was first fetched, so presenting the number as live would be a smal lie. Hover it for the exact age and reset time. When the limit is reached, the error state names the limit, shows when the window -resets, and explains that a self-hosted deployment can raise it with a token. +resets, explains that a self-hosted deployment can raise it with a token, and +offers the built-in demo as an alternative. Choosing it swaps the data source and +says so — the requested repository is never silently replaced with synthetic data. ## Optional token setup @@ -200,8 +249,25 @@ GITHUB_TOKEN=github_pat_... - Classic token: create it with **no scopes ticked**. - Fine-grained token: **Public repositories (read-only)** is enough. -On Vercel, add `GITHUB_TOKEN` as a **server-side** environment variable. Do not -prefix it with `NEXT_PUBLIC_`; that prefix is what makes a variable public. +The built-in demo needs **no token and no GitHub quota**, so a deployment with no +token configured still has something complete to show. + +### On Vercel + +```bash +vercel env add GITHUB_TOKEN production +# paste the token when prompted, then redeploy +vercel --prod +``` + +Or in the dashboard: **Settings → Environment Variables**, name `GITHUB_TOKEN`, +scope it to the environments you want, and leave it as a plain (server-side) +variable. + +Do not prefix it with `NEXT_PUBLIC_`; that prefix is exactly what would publish it +to the browser. The variable is read only inside route handlers, is never logged, +and never appears in a response body — the e2e suite asserts the last part against +a real production build. ## Security @@ -352,8 +418,8 @@ Explicit goals, and how they are met: ```bash npm run typecheck # tsc --noEmit npm run lint # eslint -npm test # vitest, 336 tests, no network -npm run test:e2e # production build + playwright, 112 tests, no network +npm test # vitest, no network +npm run test:e2e # production build + playwright, no network npm run verify # all four, in order ``` @@ -374,6 +440,16 @@ Component tests (jsdom) cover the transport controls, the tree markers and the patch fallbacks, including that hostile commit messages, author names, file names and patch content render as text and create no elements. +A dedicated suite pins the built-in demo: exactly sixteen commits, sixteen unique +valid object ids, chronological timestamps, a detail record and an exact tree for +every commit, a first tree that differs meaningfully from the last, every position +projectable, statistics derived through the normal `buildGrowthSeries`, +`estimateLanguages` and `buildActivityMap` paths, and no `fetch` call anywhere in +building or serving it. A second suite pins the routing: the demo never reaches +`api.github.com`, arbitrary `demo/*` references fall through to the ordinary +not-found path, and the test fixtures are unreachable unless `RTM_FIXTURE_MODE` is +explicitly set. + ### End-to-end (Playwright) Playwright runs against a **real production build**. `RTM_FIXTURE_MODE=1` makes @@ -384,28 +460,32 @@ spending rate limit. **No token is provided**, so the suite also proves the anonymous path works. Three projects: desktop, a Pixel 7 for the narrow layout, and a browser reporting -`prefers-reduced-motion: reduce`. +`prefers-reduced-motion: reduce`. Most specs run against the built-in demo; the +synthetic fixture repositories are used where a test is specifically about the +live GitHub path. ### Fixtures -`fixtures/` holds sanitised recordings from public repositories, plus synthetic -bundles for the cases GitHub cannot provide on demand: a one-commit repository, -an empty repository, a 640-commit history, and directives that make the server -produce rate-limit, private-repository and timeout failures. +`fixtures/` is entirely synthetic. No real account's repository metadata, file +tree or commit messages are stored there. It holds a five-commit and a +twelve-commit stand-in for live repositories, a one-commit repository, an empty +repository, a 640-commit history for the pagination paths, and directives that +make the server produce rate-limit, private-repository and timeout failures. -Sanitising drops e-mail addresses and avatar URLs, keeps only the fields the -application reads, and caps patches. A detail bundle stores only what a -commit-detail response adds over the list response, and a tree for any commit is -derived from the nearest recorded one plus the diffs in between — the same -reconstruction the client performs. That keeps the whole fixture set to about -1.5 MB of JSON. +A detail bundle stores only what a commit-detail response adds over the list +response, and a tree for any commit is derived from the nearest recorded one plus +the diffs in between — the same reconstruction the client performs. -Re-record with: +Regenerate with: ```bash -GITHUB_TOKEN=... npm run fixtures +npm run fixtures # synthetic bundles only +npm run fixtures -- owner/repo # optional, opt-in recording of a named repository ``` +Recording is opt-in and must name the repository on the command line, so nothing +is captured by accident. + ### Optional live smoke test Kept separate on purpose, so the standard suite never depends on GitHub: @@ -417,8 +497,12 @@ RTM_BASE_URL=http://127.0.0.1:3311 npm run test:live ``` It checks the adapter against what GitHub actually returns today: ordering, -pagination totals, patch caps, tree entry types, and that a malformed owner is -rejected before any request goes out. About ten requests. +pagination totals, patch caps, tree entry types, that a malformed owner is +rejected before any request goes out, and that the built-in demo resolves locally +while an unknown `demo/*` reference does not. About ten requests. + +It defaults to `octocat/Hello-World`, a public repository owned by GitHub itself. +Point it elsewhere with `RTM_LIVE_REPO=owner/repo`. ### Screenshots @@ -440,15 +524,20 @@ npm install npm run dev # http://localhost:3000 ``` -A token is optional; see [Optional token setup](#optional-token-setup). Without -one you get 60 requests per hour, which is enough for a few repositories. +The built-in demo works immediately, with no configuration. A token is optional +and only affects live GitHub mode; see +[Optional token setup](#optional-token-setup). Without one you get 60 requests per +hour, which is enough for a few repositories. -To run the app against fixtures instead of GitHub: +To serve the synthetic test fixtures as if they were live repositories: ```bash RTM_FIXTURE_MODE=1 npm run dev ``` +That flag is for tests and local inspection. It is never set in production, and +the built-in demo does not depend on it. + ## Deployment Deploys as a standard Next.js App Router application; it was built with Vercel in @@ -481,6 +570,7 @@ src/ components/ the interface, one CSS module per component lib/ repo-ref.ts input parsing and validation (the security boundary) + builtin/ the curated demo: fixture data and its local provider domain/types.ts the types the UI works against github/ HTTP client, adapter, service, fixtures, rate-limit store api/ route/response contract shared by server and client @@ -499,8 +589,8 @@ e2e/ Playwright ``` The pieces worth reading first are `src/lib/tree/projector.ts` (how a tree is -reconstructed) and `src/lib/client/history-controller.ts` (how requests are -budgeted and cancelled). +reconstructed), `src/lib/client/history-controller.ts` (how requests are budgeted +and cancelled) and `src/lib/builtin/` (the demo, and how it stays off the network). ## Licence diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index b1160fcf0e185992229e7c1d721d2923066b588a..b09da676a34b1ab882c3b75809a076817dbe7e0e 100644 GIT binary patch delta 129 zcmX?*v@vPJVLgSM%)IPkg}nUojMSo3g~YrRg}lVvRFH^5etBMM(dPSlQy3?=$p}y8 zGZbc2pIj>=17vY>W|Wi^6kF-*gG7||qaAgl5_PK#bS?GbRkd|vwH=T|fZP~8C_lE^ MNT*hRvZ1Un0C}}32LJ#7 delta 163 zcmdm(bRcQNVLh3=#N5pD)Rs;Y*B{yaO diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 2d21761..73725c5 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -23,8 +23,15 @@ export const REPOS = { unknownDemo: 'demo/not-a-real-demo', } as const; -/** Repository names that must never appear in the shipped interface again. */ -export const REMOVED_PERSONAL_REPOS = ['renren-across-tabs', 'DataData', 'DrillLab', 'PetNote'] as const; +/** + * The only GitHub destinations the shipped interface is allowed to link to: this + * project, and its author's profile. Anything else would mean the interface is + * pointing at somebody's repository again. + */ +export const ALLOWED_GITHUB_LINKS = [ + 'https://github.com/renrenmimi/RepoTimeMachine', + 'https://github.com/renrenmimi', +] as const; export function repoUrl(slug: string, commit?: string): string { const params = new URLSearchParams({ repo: slug }); @@ -104,6 +111,25 @@ export function collectProblems(page: Page): string[] { return problems; } +/** + * Fails if the page links to any GitHub location other than this project. + * + * A live repository legitimately links to itself, so pass its slug to allow it. + */ +export async function expectNoForeignGitHubLinks(page: Page, allowSlug?: string): Promise { + const hrefs = await page.evaluate(() => + [...document.querySelectorAll('a[href]')].map((anchor) => (anchor as HTMLAnchorElement).href), + ); + const allowed = [...ALLOWED_GITHUB_LINKS, ...(allowSlug ? [`https://github.com/${allowSlug}`] : [])]; + for (const href of hrefs) { + if (!href.includes('github.com')) continue; + expect( + allowed.some((prefix) => href.startsWith(prefix)), + `unexpected GitHub link: ${href}`, + ).toBe(true); + } +} + /** Fails if the document scrolls sideways at the current viewport. */ export async function expectNoHorizontalOverflow(page: Page): Promise { const overflow = await page.evaluate(() => ({ diff --git a/e2e/shell.spec.ts b/e2e/shell.spec.ts index 8570c81..57ec7c4 100644 --- a/e2e/shell.spec.ts +++ b/e2e/shell.spec.ts @@ -4,12 +4,12 @@ import { builtinBadge, collectProblems, demoButton, + expectNoForeignGitHubLinks, expectNoHorizontalOverflow, landingInput, landingLoadButton, openRepo, quotaMeter, - REMOVED_PERSONAL_REPOS, REPOS, repoUrl, } from './helpers'; @@ -37,23 +37,36 @@ test.describe('initial shell', () => { await expect(page.getByRole('heading', { level: 2, name: 'Learning Platform' })).toBeVisible(); await expect(demoButton(page)).toBeVisible(); await expect(page.getByText(/curated, synthetic history/)).toBeVisible(); - await expect(page.getByText(/16 commits · no GitHub account · 0 API requests/)).toBeVisible(); + await expect(page.getByText(/16 commits · no GitHub account · 0 GitHub requests/)).toBeVisible(); // One H1 on the page, and the demo is a section under it. await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); }); - test('names no personal repository anywhere in the interface', async ({ page }) => { - for (const path of ['/', repoUrl(REPOS.demo), repoUrl(REPOS.medium)]) { - await page.goto(path); - await page.waitForTimeout(1200); - const text = await page.evaluate(() => document.body.innerText); - const html = await page.content(); - for (const name of REMOVED_PERSONAL_REPOS) { - expect(text, `${name} in text of ${path}`).not.toContain(name); - expect(html, `${name} in markup of ${path}`).not.toContain(name); - } - } + test('links to no GitHub repository other than this project', async ({ page }) => { + await page.goto('/'); + await expectNoForeignGitHubLinks(page); + + await openRepo(page, REPOS.demo); + await page.waitForTimeout(1200); + await expectNoForeignGitHubLinks(page); + + // A live repository may link to itself, and to nothing else. + await openRepo(page, REPOS.medium); + await page.waitForTimeout(1200); + await expectNoForeignGitHubLinks(page, REPOS.medium); + }); + + test('offers no repository as an example in the input or its errors', async ({ page }) => { + await page.goto('/'); + // The placeholder is the generic form, not somebody's repository. + await expect(landingInput(page)).toHaveAttribute('placeholder', 'owner/repository'); + + await landingInput(page).fill('not-a-repo'); + await landingLoadButton(page).click(); + const message = await appAlert(page).innerText(); + expect(message).toContain('owner/repository'); + expect(message).not.toMatch(/github\.com\/[A-Za-z0-9-]+\/[A-Za-z0-9._-]+/); }); test('loads the built-in demo in one action, with no GitHub request', async ({ page }) => { @@ -89,7 +102,8 @@ test.describe('initial shell', () => { await page.waitForTimeout(1200); await expect(page.locator('a[href*="/commit/"]')).toHaveCount(0); await expect(page.locator('main a[href*="github.com"]')).toHaveCount(0); - await expect(page.getByText(/curated, synthetic history/).first()).toBeVisible(); + // The disclosure is repeated; at least one instance is on screen at any width. + await expect(page.getByText(/curated, synthetic history/).filter({ visible: true }).first()).toBeVisible(); }); test('distinguishes built-in from live without relying on colour', async ({ page }) => { @@ -100,7 +114,7 @@ test.describe('initial shell', () => { await openRepo(page, REPOS.medium); await expect(page.getByText('Live repository')).toBeVisible(); - await expect(page.getByText(/Read from GitHub/)).toBeVisible(); + await expect(quotaMeter(page)).toBeVisible(); }); test('does not autoplay on arrival', async ({ page }) => { @@ -181,17 +195,7 @@ test.describe('repository input', () => { await expect(page).toHaveURL(/repo=rtm-fixtures%2Fsample-app/); }); - test('uses a neutral placeholder and a neutral validation example', async ({ page }) => { - await page.goto('/'); - await expect(landingInput(page)).toHaveAttribute('placeholder', 'owner/repository'); - await landingInput(page).fill('not-a-repo'); - await landingLoadButton(page).click(); - const message = await appAlert(page).innerText(); - for (const name of REMOVED_PERSONAL_REPOS) { - expect(message).not.toContain(name); - } - }); }); test.describe('states', () => { @@ -291,3 +295,21 @@ test.describe('layout', () => { expect(problems).toEqual([]); }); }); + +test.describe('document structure', () => { + test('has exactly one H1 in every state', async ({ page }) => { + for (const path of ['/', repoUrl(REPOS.demo), repoUrl(REPOS.medium), repoUrl(REPOS.missing)]) { + await page.goto(path); + await page.waitForTimeout(1200); + await expect(page.getByRole('heading', { level: 1 }), path).toHaveCount(1); + } + }); + + test('names the current view in the heading once a timeline is open', async ({ page }) => { + await openRepo(page, REPOS.demo); + await expect(page.getByRole('heading', { level: 1 })).toHaveText(/built-in demo timeline/); + + await openRepo(page, REPOS.medium); + await expect(page.getByRole('heading', { level: 1 })).toHaveText(/sample-app — commit timeline/); + }); +}); diff --git a/package.json b/package.json index a51d204..79d9efb 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "test:live": "node scripts/live-smoke.mjs", "fixtures": "node scripts/record-fixtures.mjs", "screenshots": "node scripts/screenshots.mjs", - "verify": "npm run typecheck && npm run lint && npm run test && npm run test:e2e" + "verify": "npm run typecheck && npm run lint && npm run test && npm run test:e2e", + "token-canary": "node scripts/token-canary.mjs" }, "dependencies": { "next": "^16.3.2", diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs index f39cd83..c29537d 100644 --- a/scripts/screenshots.mjs +++ b/scripts/screenshots.mjs @@ -80,6 +80,7 @@ const shots = [ }, }, { name: '08-error-desktop', width: 1440, height: 940, path: '/?repo=rtm-fixtures%2Frate-limited' }, + { name: '09-live-desktop', width: 1440, height: 940, path: '/?repo=rtm-fixtures%2Fsample-app' }, { name: '10-landing-mobile', width: 390, height: 900, path: '/' }, { name: '11-timeline-mobile', width: 390, height: 900, path: `/?repo=${encodeURIComponent(REPO)}` }, { @@ -94,6 +95,7 @@ const shots = [ }, }, { name: '13-timeline-360', width: 360, height: 780, path: `/?repo=${encodeURIComponent(REPO)}` }, + { name: '14-live-360', width: 360, height: 780, path: '/?repo=rtm-fixtures%2Fsample-app' }, ]; async function main() { diff --git a/scripts/token-canary.mjs b/scripts/token-canary.mjs new file mode 100644 index 0000000..524d360 --- /dev/null +++ b/scripts/token-canary.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Verifies that a server-side token never becomes browser-visible. + * + * Build and start the server with a *fake* token, then run this against it. It + * checks page responses, response headers, every JavaScript and CSS chunk the + * page actually loads, and the API payloads. + * + * CANARY=ghp_not_a_real_token_0000000000000000000 + * GITHUB_TOKEN=$CANARY npm run build + * GITHUB_TOKEN=$CANARY npx next start -p 3411 & + * RTM_CANARY=$CANARY RTM_BASE_URL=http://127.0.0.1:3411 npm run token-canary + * + * Never run it with a real token. + */ + +const CANARY = process.env.RTM_CANARY; +const BASE = process.env.RTM_BASE_URL ?? 'http://127.0.0.1:3411'; +if (!CANARY) throw new Error('RTM_CANARY is required'); + +let fail = 0; +const check = (name, leaked) => { + console.log(`${leaked ? 'FAIL' : 'PASS'} ${name}`); + if (leaked) fail += 1; +}; + +const paths = [ + '/', + '/?repo=demo%2Flearning-platform', + '/api/gh/repo?owner=demo&repo=learning-platform', + '/api/gh/commits?owner=demo&repo=learning-platform&page=1', + '/api/gh/tags?owner=demo&repo=learning-platform', + '/api/gh/repo?owner=rtm-fixtures&repo=sample-app', +]; + +for (const path of paths) { + const response = await fetch(`${BASE}${path}`); + const body = await response.text(); + const headers = [...response.headers.entries()].map(([k, v]) => `${k}: ${v}`).join('\n'); + check(`response ${path}`, body.includes(CANARY) || headers.includes(CANARY)); +} + +const html = await (await fetch(`${BASE}/?repo=demo%2Flearning-platform`)).text(); +const chunks = [...new Set([...html.matchAll(/\/_next\/static\/[^"']+\.(?:js|css)/g)].map((m) => m[0]))]; +let leaked = 0; +for (const chunk of chunks) { + const text = await (await fetch(`${BASE}${chunk}`)).text(); + if (text.includes(CANARY)) { + console.log(`FAIL canary in ${chunk}`); + leaked += 1; + } +} +check(`${chunks.length} browser JS/CSS chunks`, leaked > 0); + +const repo = await (await fetch(`${BASE}/api/gh/repo?owner=demo&repo=learning-platform`)).json(); +console.log( + `\nexposed metadata only: tokenConfigured=${repo.data.tokenConfigured} dataSource=${repo.data.meta.dataSource} rateLimit=${JSON.stringify(repo.rateLimit)}`, +); +const keys = Object.keys(repo.data).concat(Object.keys(repo)); +console.log(`payload keys: ${[...new Set(keys)].join(', ')}`); + +console.log(fail === 0 ? '\nno canary anywhere browser-visible' : `\n${fail} leak(s)`); +process.exit(fail === 0 ? 0 : 1); diff --git a/src/components/States.tsx b/src/components/States.tsx index 0e710a8..a53b27f 100644 --- a/src/components/States.tsx +++ b/src/components/States.tsx @@ -48,7 +48,7 @@ export function Landing({ Play the built-in demo

- {BUILTIN_DEMO.commitCount} commits · no GitHub account · 0 API requests + {BUILTIN_DEMO.commitCount} commits · no GitHub account · 0 GitHub requests

diff --git a/src/components/TimeMachine.tsx b/src/components/TimeMachine.tsx index 5dfaa08..51f3f16 100644 --- a/src/components/TimeMachine.tsx +++ b/src/components/TimeMachine.tsx @@ -234,6 +234,16 @@ export function TimeMachine() { Skip to the timeline + {/* Exactly one H1 in every state: the landing screen provides its own, so + this one only appears once a repository or the demo is open. */} + {state.status === 'idle' ? null : ( +

+ {isBuiltin + ? `${BUILTIN_DEMO.title} — built-in demo timeline` + : `${state.ref?.slug ?? 'Repository'} — commit timeline`} +

+ )} +
@@ -255,7 +265,7 @@ export function TimeMachine() {

{BUILTIN_DEMO.badge} - · 0 GitHub requests + · 0 GitHub requests

) : state.status === 'idle' ? null : ( {BUILTIN_DEMO.title} - {BUILTIN_DEMO.disclosure} + {/* Repeated in full in the insights panel, so it can drop on a phone. */} + {BUILTIN_DEMO.disclosure}

) : (

Live repository - Read from GitHub’s public REST API. + Read from GitHub’s public REST API.

)} diff --git a/src/components/shell.module.css b/src/components/shell.module.css index f9d0940..06f8f86 100644 --- a/src/components/shell.module.css +++ b/src/components/shell.module.css @@ -147,6 +147,20 @@ color: var(--text-dim); } +.sourceDetail { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* On a phone the mode name is enough; the sentence is repeated in the panel. */ +@media (max-width: 620px) { + .sourceDetail { + display: none; + } +} + .sourceSpacer { flex: 1; } diff --git a/src/components/states.module.css b/src/components/states.module.css index bc859f0..25cb332 100644 --- a/src/components/states.module.css +++ b/src/components/states.module.css @@ -266,6 +266,15 @@ background: var(--line-strong); } +.stateFootnote { + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--line-faint); + font-size: var(--text-2xs); + line-height: 1.55; + color: var(--text-faint); +} + .stateActions { display: flex; flex-wrap: wrap; diff --git a/tests/builtin-demo.test.ts b/tests/builtin-demo.test.ts index 2d0c64d..4e36592 100644 --- a/tests/builtin-demo.test.ts +++ b/tests/builtin-demo.test.ts @@ -342,16 +342,15 @@ describe('privacy of the fixture content', () => { meta: builtinRepoMeta(), }); - it.each(['renren-across-tabs', 'DataData', 'DrillLab', 'PetNote'])( - 'contains no reference to %s', - (name) => { - expect(text.toLowerCase()).not.toContain(name.toLowerCase()); - }, - ); - - it('contains no e-mail address and no personal GitHub path', () => { + it('names no repository owner and links nowhere', () => { + // A curated history has no upstream, so it must not smell like one. + expect(text).not.toContain('github.com'); + expect(text).not.toContain('https://'); + expect(text).not.toMatch(/\bgit@/); + }); + + it('contains no e-mail address', () => { expect(text).not.toMatch(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/); - expect(text).not.toContain('github.com/'); }); it('contains no issue or pull-request references', () => { @@ -359,6 +358,22 @@ describe('privacy of the fixture content', () => { expect(text.toLowerCase()).not.toContain('pull request'); expect(text.toLowerCase()).not.toContain('merge pull'); }); + + it('uses only the generic maintainer identity', () => { + const authors = new Set(loadDemo().commits.map((commit) => commit.author.name)); + expect([...authors]).toEqual(['Demo Maintainer']); + }); + + it('uses paths that read as a generic learning product', () => { + const paths = new Set(DEMO_COMMITS.flatMap((commit) => commit.changes.map((change) => change.path))); + expect(paths.size).toBeGreaterThan(50); + for (const path of paths) { + // Repository-relative, no absolute paths and no escapes. + expect(path.startsWith('/')).toBe(false); + expect(path).not.toContain('..'); + expect(path).toMatch(/^[A-Za-z0-9._[\]/-]+$/); + } + }); }); describe('sharing a built-in demo URL', () => { diff --git a/tests/repo-ref.test.ts b/tests/repo-ref.test.ts index c5ddbb9..74c0e96 100644 --- a/tests/repo-ref.test.ts +++ b/tests/repo-ref.test.ts @@ -100,7 +100,7 @@ describe('parseRepoRef — rejected input', () => { ); it('rejects a URL that is not a repository', () => { - expect(failure('https://github.com/renrenmimi').code).toBe('not-a-repo-url'); + expect(failure('https://github.com/octocat').code).toBe('not-a-repo-url'); }); it.each(['settings', 'marketplace', 'topics', 'explore', 'sponsors'])( From 2424c2471c35ee6de206b6da13b14580f8e3f22d Mon Sep 17 00:00:00 2001 From: renrenmimi Date: Tue, 25 Aug 2026 02:25:54 -0700 Subject: [PATCH 4/5] ci: stop the privacy audit from matching its own patterns Spelling the removed names out in the workflow put them back into the tree, so the audit found itself and failed. The patterns are now assembled from halves at run time, which keeps the literals out of every tracked file while still searching for the whole name. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e3c408..b71e0a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,9 +63,16 @@ jobs: - uses: actions/checkout@v4 # The demo is synthetic; no personal repository may reappear in the tree. + # + # The names are assembled from halves on purpose: written out in full they + # would appear in this file, and the audit would then find itself. - name: No personal repository references in tracked files run: | - if git grep -nIi -e 'renren-across-tabs' -e 'DataData' -e 'DrillLab' -e 'PetNote' -- .; then + A='renren-'; A="${A}across-tabs" + B='Data'; B="${B}Data" + C='Drill'; C="${C}Lab" + D='Pet'; D="${D}Note" + if git grep -nIi -e "$A" -e "$B" -e "$C" -e "$D" -- .; then echo "::error::a removed personal repository name is back in the tree" exit 1 fi From a95e3cd60dd22d409bb3a4da9a2dc1433b41801a Mon Sep 17 00:00:00 2001 From: renrenmimi Date: Tue, 25 Aug 2026 08:58:25 -0700 Subject: [PATCH 5/5] ci: assemble the public-prefix pattern at run time too The last audit step had the same self-match problem as the first: spelling the forbidden variable name out put it into the tree, so the grep found this file. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b71e0a7..97f27bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,10 +86,13 @@ jobs: fi echo "all renrenmimi links point at RepoTimeMachine" - - name: No token name is exposed to the client bundle + # Assembled from halves for the same reason as above: spelled out, the + # pattern would appear in this file and the audit would find itself. + - name: The token is never given a public prefix run: | - if git grep -nI 'NEXT_PUBLIC_GITHUB_TOKEN' -- .; then - echo "::error::GITHUB_TOKEN must never be exposed with a NEXT_PUBLIC_ prefix" + P='NEXT_'; P="${P}PUBLIC_GITHUB_TOKEN" + if git grep -nI -e "$P" -- .; then + echo "::error::the token must never be exposed with a public prefix" exit 1 fi echo "no public token variable"