How this is builtGroundworkHow this is built
19 / 26 written chapters

I13 · Part 2 · How it runs

The design system

Nine themes and eight fonts as CSS variables, two page frames, and the checks that keep them readable.

26 min read1 diagram18 sections
18On this page
  1. Tokens, not components
  2. Nine themes
  3. Eight fonts
  4. The first paint
  5. The pickers
  6. Page frames
  7. Topic pages
  8. The topic shell
  9. The workbench pages
  10. The mock interview pages
  11. The review page
  12. The progress page
  13. The Git landing, privacy and the pages that fail
  14. Modals
  15. CSS modules and globals.css
  16. The hand-drawn look
  17. Hover, tooltips and motion
  18. The checks

Tokens, not components

There is no component library here. The design system is a set of CSS custom properties in app/globals.css, two attributes on the <html> element that choose which set applies, and a few shared React components that read them. A theme is a CSS rule block, and so is a font. Changing either is one setAttribute call, and the browser recolours or re-sets the whole page without React rendering anything.

localStorage jsnotes:theme, :font theme-init runs before paint <html> data-theme, data-font globals.css token blocks Pickers dropdowns, swatches useThemeChoice save, then set Every rule var(--ink), var(--sheet)
Two paths set the same two attributes. The script handles the first paint, before React exists. The hooks handle every change after that.

Nine themes

The :root block in globals.css defines 78 custom properties: paper and sheet colours, ink, pencil, red and green, highlighter colours, sticky notes, code colours, diagram boxes, shadows, sizes, fonts, the editor's syntax colours, the categorical --c-* colours, the role colours and the interview book's --book-* materials (cream paper and dark ink, a cloth cover, a ribbon, a shadow, all kept in every theme and tinted by it). That block is the Paper theme, and it also matches [data-theme="light"], so a Paper preview inside another theme shows Paper. Its 71 colour tokens are the properties whose value is a colour (the colours and the shadows). Each of the other eight is a [data-theme="…"] block that restates exactly those 71 and nothing else, and sets color-scheme, so the native scrollbars inside panels and the form controls match. The page's own scrollbar is hidden on html, and the reading-progress bar in the header is the only cue to where you are on the page.

Anything computed from those colours lives once, in a shared :root, [data-theme] block: the -soft tints, the editor's --ide-* aliases such as --ide-fg: var(--ink) and --ide-hover, and --scrim, the black that the modules' backdrops (the modal, the drawer, the sheets and the whiteboard) mix with transparent. --scrim is declared on ::backdrop as well, because older browsers do not let a dialog's backdrop inherit from the page. The backdrops in the global rules still set their own tints. That selector matches every element that carries a theme, so a Kraft swatch inside a Night page computes Kraft's editor colours instead of inheriting Night's.

ValueShown asScheme
lightPaperlight
darkNightdark
kraftKraftlight
blueprintBlueprintdark
sepiaSepialight
forestForestlight
roseRosedark
monoMonolight
lavenderLavenderlight

Seven of those properties are roles, and every theme sets all seven, so the accent is blue on Paper, rust on Kraft and violet on Lavender. Role values are opaque hex, so the contrast test can check them. The shared :root, [data-theme] block derives a -soft tint of the accent and of each state by mixing 16% of it into the sheet, next to the --c-*-soft tints.

RoleUseReplaces
--primaryButtons, links, the active tab or nav item, progress fills, focus rings and selected statesGreen as an action colour, and the interview book's red --primary
--on-primaryText and icons on an accent fill—
--primary-softSelected rows and chips--hl-mint as a selected background
--markThe highlighter behind words, inline code and title underlines--hl-yellow as the highlighter
--success, --danger, --caution, --infoStates only: passed and failed tests, done and due, tips. Never decoration.--green and --red as states

Text on any -soft tint is --ink. --red and --green stay, as the categorical --c-red and --c-green for charts, topic chips and the whiteboard. A state uses --success, --danger, --caution or --info instead.

The table is the intended use, and most of the site now reads the roles: the home page, review, progress, the interview book, the mock interview, the site menu, the modal and the shared page frame, the problems list, the whiteboard, the git guide, the architecture pages, the theme picker, the dropdowns, the code editor and the practice workspace, the topic covers and chapters, /level and /path. Their buttons, links, progress bars, active tabs and chapters, selections, focus rings, highlights and the notebook's margin line follow the theme, and their passed, failed, read, due and tip states use the state roles. Nothing sets its own --primary, and the green --ide-accent alias is gone.

What stays fixed is mostly what a chapter says in colour, so that it means the same in every theme, such as the callouts (the sticky notes, the red gotcha box and the green and yellow boxes), the diagrams and step-through visualisers, the dry-run and truth tables, the level tags and the interview book's cards and pills. The visualisers' current-step highlight follows the theme's marker. Some uses outside the chapters stay categorical too, such as the problem hint, the progress page's achievements, the home page's interview-story reds, the book's test, say and trap boxes, the editor's window dots, the problem levels, the lanes of the architecture map, and the whiteboard's ruled margin and laser. tests/theme-roles.test.ts finds every CSS module under app/ and components/, the global rules in app/globals.css and app/theme-bridge.css, and every .ts and .tsx file under app/, components/ and lib/, and scans them for a listed set of fixed green, red, mint and highlighter tokens, their -soft tints and their --ide-* aliases. It fails when one is used outside its file's categorical list, when a module declares its own role token, and when a module's declaration values hold a colour literal: a hex, rgb(), hsl() or newer colour function, or a named colour such as white. A new module or component is covered the day it is added, with an empty categorical list. In .ts and .tsx files the only listed tokens allowed are the fills of the two diagram arrow markers. That scan looks for the listed tokens, not for hex values, so the app icons, the error page and the whiteboard's paper tints still carry colours of their own.

app/theme-bridge.css maps these names onto the conventional ones Tailwind utilities expect (--background, --border, --ring and so on) inside an @theme inline block, so a Tailwind class and a hand-written rule resolve to the same colour in every theme. --primary needs no alias. The bridge points --ring and --accent at it and --destructive at --danger. Home, Progress and the site drawer set --accent locally to a topic's colour, which is why the accent role is not called --accent.

Eight fonts

lib/fonts.ts loads 15 families through next/font/google, which serves them from the site's own origin and exposes each as a variable such as --font-caveat. Only the default pair, Caveat and Kalam, is preloaded, with display: "optional", so a slow font never causes a layout shift. The other thirteen set preload: false and download only when a data-font rule starts using them.

ValueShown asHeadingsBody
classicClassicCaveatKalam
markerMarkerPatrick HandShadows Into Light
sketchSketchArchitects DaughterReenie Beanie
penPenGochi HandNeucha
chalkChalkPermanent MarkerIndie Flower
scriptScriptDancing ScriptHandlee
serifReadingLiterataLiterata
robotoRobotoRobotoRoboto

Six of the eight are handwriting. Reading and Roboto exist for people who find handwriting hard to read for long, and they are the reason the picker's heading says "Handwriting" but its options do not all fit the name. JetBrains Mono stays the --font-mono for code blocks in every font.

The first paint

The server cannot know a reader's theme, so the root layout renders <html data-theme="light" suppressHydrationWarning>. A plain inline <script> in the <head> runs the string in lib/themeInitScript.ts before the page paints. It reads jsnotes:theme, falls back to prefers-color-scheme, and sets data-theme. It reads jsnotes:font and sets data-font, defaulting to classic. The whole thing is wrapped in try, so blocked storage leaves the Paper theme rather than an error. suppressHydrationWarning tells React the attribute is expected to differ from the server's HTML.

The pickers

components/ThemeFontPicker.tsx exports two hooks. useThemeChoice() returns the theme, a choose function and whether the component has mounted. Before mounting it reports "light" to match the server. After mounting it uses the saved theme or the OS scheme, read through useSyncExternalStore so it follows a system switch live. An effect then writes the attribute. choose saves first and then updates state. useFontChoice() does the same for fonts. ThemePicker and FontPicker are compact dropdowns built on these hooks, and they render nothing until mounted, so they never flash the wrong value.

components/AppearancePicker.tsx is the larger version used inside drawers. Themes are a role="radiogroup" of swatches, and fonts are a second radiogroup with an "Aa" sample set in each font. Each swatch carries its own data-theme, so it is a live preview of that theme's sheet, ink, --primary chip and categorical colours. The selected card's border and the focus outline use the current theme's --primary.

Page frames

Almost every page renders in components/frame/PageFrame.tsx: a sticky header with a labelled back pill, a labelled Menu pill (components/MenuButton.tsx) that opens the site drawer, the brand and the page title. Home, review, progress, privacy, the 404 page and the error page render it directly. components/topic/TopicFrame.tsx fills it in for a topic, with the topic's mark beside a title that links to its cover. A page that sets reading also gets the drawer's text size, narrator and print controls. Every topic cover, every chapter, outline or written, the level pages that render rather than redirect, the reading path, Git and this architecture section all use TopicFrame. Opening the drawer locks body scroll, focuses its first control, closes on Escape, and holds focus with the shared components/FocusTrap.tsx, which wraps @radix-ui/react-focus-scope.

The playground and the problem pages are tools, so they keep the full height of the window, but they wear the same header: components/frame/FrameHeader.tsx is the header that PageFrame renders, taken out so the tools can use it with a back pill that shrinks to an icon, no brand name and a row that wraps on narrow screens. The problems list is a page in PageFrame like the interview question bank. The whiteboard is the one tool with its own chrome and board menu. Every other page opens components/SiteDrawer.tsx from the same Menu pill. It is a portal with role="dialog" and aria-modal, laid out as four numbered sections in the home page's vocabulary: a Continue card from the one saved place in groundwork:resume with the level and streak as one quiet line, six Go to rows with aria-current on the current one, the topics and the two guides as a single-open accordion, and a closed Appearance fold holding the theme swatches and handwriting tiles (and, on reading pages, text size, narrator and shortcuts), plus a disabled Sign in placeholder and, in its foot, a Take the tour row. That row opens the first-visit tour: a lazy-loaded prompt and five-slide dialog built from the same stage kit, shown once on the home page and topic pages after the page has loaded, never on a chapter. The chapter sidebar is built from the same kit, components/ui/MenuKit.tsx, so both surfaces share the numbered kicker, search pill, dashed rows and highlighter. The seven type sizes are tokens on :root, with two more, a hero and a lead size, for the topic pages. Ctrl or Cmd plus K opens it with search focused from any page, except while typing in a field or the editor. It focuses its first control, holds Tab inside, closes on Escape, locks scroll, and returns focus to whatever opened it. The whiteboard renders through BareShell with header={false} and has its own board menu drawer, which embeds the same AppearancePicker.

Topic pages

The level picker, the reading path, the topic cover and the outline pages of unwritten topics are one family, built from components/topic/TopicKit.tsx: a numbered kicker, a hero with a display heading and one taped card, a dashed trail of numbered steps, flat dashed rows, single-open folds for the syllabus and a notes card. The page's accent comes from the topic's data, so a new topic needs no CSS. None of these pages uses the scroll reveal, because a reveal leaves blocks part-transparent mid-scroll and in a stitched screenshot.

The interview book (the landing, the question bank and every round) and this architecture section's index are the same family. A round is a chapter in the chapter kit, with a rounds rail, the questions as numbered sections and the answer inside the shared prose layer; the landing and the index reuse the topic kit's trails, stations and rows. Anything that depends on saved progress is hidden until the page has hydrated, so the label of a button never changes under the reader.

The topic shell

A topic's home is drawn by one generic shell, components/topic/shell/, and nothing in it names a topic. TopicShell takes plain props: the topic's name, a hero, the chapters grouped by level into stages, a list of tool links, an optional details panel and an optional notes fold. The hero has three shapes and every one carries exactly one primary action. An invitation leads with a friendly title, a line about what the quiz is, the honest size of the check, one big button and two quiet text links, beside a small three-stop preview of the stages where nothing is locked. A standing, for a reader who has progress, shows a level card (the level name in its stage colour and a progress ring that fills once on arrival) beside an up-next card that holds the one Continue button; a reader with only ticks gets a gentle prompt to check their level instead of a level badge. A plain hero, a title, a status line and an action, is what a topic with no placement passes.

JourneyMap replaces the plain list. Each stage is a card on a dashed, wavy trail (TrailLeg, the same dashed look as the path page, drawn in pure SVG and hidden from a screen reader) and a numbered node that is filled once the stage is read. A stage card is a native details, so it opens by keyboard, announces itself and needs no animation; its summary carries the stage name, a status chip (your level, up next, in progress, not started or all read), the one-line promise, a progress bar with its words (4 / 10 read) and, while closed, a peek at the next stop. Each stage takes one of the theme's colour tokens by position, so its tint is readable in every theme. ChapterRow and StateMark draw a row with one state mark, the state in words where it matters (read, checked, not checked, tested out, soon) and a hidden label otherwise, and the minutes and exercises in aligned columns that wrap under the title on a phone; the up-next row is tinted. ToolsRow draws four friendly tiles and DetailsPanel the closed panel. A link such as #pick-level, #beginner or #notes opens the fold it names.

The page stays on the theme tokens: flat cards, no tape and no tilt, the heading font for names and the reading font for rows, and the only motion (a card lifting on hover, the ring and the bars filling) is switched off for a reader who asks for reduced motion. Everything is hidden until the page has hydrated, as the old hero was, so a returning reader never sees the first-time hero flash. A topic adopts the shell with a thin adapter. components/topic/shell/model.ts holds the pure part, buildGroups, which turns cards and the optional done set, check states and plan into groups with their tone, status and peek, so a topic with no placement and no checks (JavaScript, say) passes its cards and levels and leaves the rest out. TopicHome is the first adapter and does the same for DSA with its plan, its three doors and its level picker; the server hands it the size of the placement bank, so the invitation can say how long the check is. If that bank is empty the hero falls back to the three equal doors. The shell replaced the chapters band, the full syllabus band, the practice band, the up-next card, the plan card with its own Continue and the Start button in the header, so the page shows each chapter once and has one way forward.

The workbench pages

The playground and the problem pages share one shell, components/practice/workbench.module.css, and the same PracticeWorkspace. The page is a paper background with a header and a grid whose padding is one token-sized gap, so the room under the header, at the sides and at the bottom is the same at every width. The playground fills the window at all sizes; a problem page fills it from 1001 pixels up and stacks below it, description first. The description panel is a card with a mono breadcrumb, the title in the heading font with the hand underline, chips for level and tests, and hints that arrive as taped sticky notes. The console's empty states speak in the home voice, and a passing submit stamps the page: a rotated rubber stamp, the words, and a pill to the next problem. The editor window, the output tabs, the status bar, the palette and the dialogs keep their global classes and are restyled in place on the same type scale, so nothing under 12 pixels appears in the chrome. Their colours stay on the --ide-* tokens, which are themselves aliases of the theme's sheet, line and ink.

The mock interview pages

The lobby and the debrief are scene pages built from the same kits as the topic pages: a one-screen hero with a composed stage, then numbered bands. The lobby's stage shows a sample exchange and one card that depends on saved state (the round left half-way, the last result, or a first-time note), which stays hidden until the page hydrates. The room is a tool, like the playground: from 1001 pixels up the page does not scroll, the header carries the round rail, the clock and End, and two panes (the interviewer and the answer, or the model answer and the rubric) scroll inside themselves. The coding round reuses the exercise layout. The whiteboard keeps its own styles in app/mock/mock.module.css, which holds nothing else.

The review page

/review is one scene page on the same kits. The hero puts the count of chapters due today in the heading, with a taped card for the chapter due first (or the one that comes back next), a numbers card and a sticky of the review gaps drawn from REVIEW_GAPS_DAYS. Everything that depends on saved progress is hidden until the page hydrates. The bands that follow exist only when there is something to show: the due list with I remember and Again, the schedule grouped by day beside a two-week forecast, the latest reviews, and an honest start for a reader who has read nothing. Clearing the list gives a stamp that turns in with transform alone, so nothing is ever part-transparent. The scheduling maths stays in lib/storage.ts.

The progress page

/progress is the same kind of scene page. The hero puts the level and the XP still to go in the heading, with a taped level card and meter, a numbers card, and a sticky for the nearest badge that is not earned yet. A level up shows as a stamp on the level card and is announced once. The bands that follow are what to do next (the review, the topic furthest along, the weak interview answers), the year of activity as a heatmap that scrolls inside its own card, one row with a meter for each topic, the interview book with the marks you gave, and the badge wall, where earned badges are stuck on and the rest show how far away they are. Every number comes from computeStats, earnedBadges and recentActivity in lib/gamification.ts, which this page only reads, and everything that depends on saved progress is hidden until the page hydrates.

The Git landing, privacy and the pages that fail

/git is a topic cover. The hero asks what Git is really doing, with the commit graph on a taped card, a numbers card from gitFacts() and a note that points at the first chapter, composed in the kit's banner layout. Below it come three ways in, the four parts as winding trails with a tick on every chapter you have read, and three honest notes. /privacy keeps its words and puts each section in a numbered band with the prose layer inside a paper panel, under a hero whose first card is the contents. The 404 page and app/error.tsx share components/frame/LostScene.tsx: a taped stamp card, a card of places to go and a note, with a retry pill on the error page and its reference code in a small mono line. app/global-error.tsx replaces the whole layout, so it carries its own stylesheet in components/frame/globalErrorCss.ts, with light and dark palettes whose contrast tests/global-error.test.ts measures.

Modals

components/Modal.tsx is built on the native <dialog> element, opened with showModal(). The browser supplies the focus trap, the Escape key, the ::backdrop and the inert page behind. The component adds a click-outside close and two dialogs: ConfirmDialog, with Cancel focused by default so a stray Enter does not delete anything, and NameDialog, a form with validation used for naming boards and files. The whiteboard and the playground's file dialogs use them.

CSS modules and globals.css

globals.css is about 7,400 lines. It holds the tokens, the prose styles every chapter uses, and the reading pages' layout. Everything that belongs to one page or tool is in one of 84 CSS modules, the largest being app/company-questions/hub.module.css at about 1,800 lines. The rule of thumb is that anything chapter HTML can contain goes in globals, because chapter bodies are strings that cannot import a module, and anything else goes in a module so it loads only with its page.

The hand-drawn look

Diagrams like the one above are inline SVG in the chapter's HTML. They use shared classes: .dg sets the body font and ink colour on text, .lbl and .sm set two text sizes, .ln is a 2.2-wide ink stroke, and boxes fill with --sheet, --sheet-2 or the --dg-box-* colours, so every diagram follows the theme. The wobble comes from .rough, which applies an SVG filter with the id wob. That filter is fractal noise (base frequency 0.022, three octaves, seed 7) feeding a displacement map of scale 2.4, and it is defined once, in components/chapter/DiagramDefs.tsx, along with the arrow, arrow-green and arrow-red markers. The topic reader, the series reader and landing, and a page frame with its reading controls on all render that one component. Because the seed is fixed, the wobble is the same on every load.

Icons follow the same idea. There is no icon package. app/whiteboard/icons.tsx maps 40 names to hand-written paths on a 24-unit grid, drawn with stroke="currentColor" at 1.8 wide and marked aria-hidden, and components/practice/TopIcon.tsx does the same for the playground's 16. currentColor means an icon is always the colour of the button it sits in.

Hover, tooltips and motion

A fixed hover colour that looks right on Paper disappears on Night. --ide-hover avoids that by being defined as color-mix(in srgb, var(--ide-fg) 12%, transparent): 12% of the theme's own ink over whatever is underneath. It was 10% until a later commit raised it for light themes, and --ide-hover-strong is 15%. Because --ide-fg is var(--ink), one definition works in all nine themes.

Tooltips are pure CSS. An element with a data-tip attribute gets an ::after whose content is attr(data-tip), drawn in the sheet colour on ink, and it shows on :hover and on :focus-visible, so keyboard users see it too. It appears after a 0.35 s delay and hides at once. The attribute appears 31 times across the playground, the whiteboard and the architecture index, and the playground's tooltips are off under 721 pixels and on touch screens, where a tooltip that cannot be hovered only widens the page.

Reduced motion is handled at two levels. A global @media (prefers-reduced-motion: reduce) rule sets transition and animation to none !important on every element and turns off smooth scrolling. Components that move things from JavaScript (the home page, the count-up numbers, the tilting cards and the confetti) check prefersMotion() in lib/dom.ts first.

The checks

No browser check of accessibility runs any more. An axe spec (@axe-core/playwright, the WCAG 2.0 and 2.1 A and AA tags, every page and state in all nine themes) used to run in CI and was cut with the rest of the browser suite. A table, a code block, a query result or the progress heatmap that is wider than its box still takes focus and gets a name while it scrolls, and drops both when its box grows wide enough to show it whole, but nothing checks that on a page now.

What guards colour is tests/contrast.test.ts, which works from the tokens without a browser. The ruled paper behind the reading pages is a gradient or a pseudo-element, so no tool could settle the contrast of that text from the rendered page anyway; the token pairs and the tinted-box pairs are checked instead.

tests/contrast.test.ts checks the tokens themselves, without a browser. It parses globals.css, finds every block that defines the editor's colours, and for each of the five syntax colours (keyword, string, number, operator, comment) computes the WCAG contrast ratio against the editor background and against the active line, which is --line-soft composited over it. Across nine themes that is 90 checks, and each must reach 4.5:1. A new theme that forgets a token, or picks a comment grey that is too faint, fails in Vitest before anyone sees it.

The same file checks the text and role colours in every theme. The ink, the soft ink and the pencil grey, then --primary and the four states, are each checked against the paper, the sheet and the editor's background --sheet-2. --on-primary is checked against --primary and the ink against --mark. That is 234 more checks at 4.5:1, and a theme block that leaves out a role fails with the theme's selector and the token's name. The six categorical colours get 54 checks at 3:1 against the sheet.

Some text sits on a tint that is mixed over the page, so the test mixes it the same way. A warning's red is checked on --warn-bg over the paper and over the sheet, and --warn-bg must be a tint of that theme's own red. The interview book's green is checked on --dg-box-green, and the ink, the soft ink and the pencil grey on the inline-code tint over all three surfaces. That is 108 more checks at 4.5:1.

What a new theme costs

Adding a theme takes one [data-theme="…"] block in globals.css that restates the 71 colour tokens, the seven roles included, and one entry in THEME_ITEMS in lib/storage.ts. The ThemeValue type and both pickers come from that list. A dark theme may also join the [data-theme="dark"] override selectors that swap the heading underline and the code background inside sticky notes and gotcha boxes. Nothing else in the code is edited. This chapter is the one hand-kept description: its theme table, its heading, and the check counts that multiply by the number of themes are updated by hand.

Missing a step fails a test that names it. tests/theme-contract.test.ts names the block and the token when a theme leaves out or adds a colour token or sets no color-scheme. It names the list entry that has no block, the block that has no entry, a value listed twice and a label that is not an icon and a name. It fails when Paper derives a colour that belongs in the shared block, or when a rule reads --scrim or an --ide-* alias that nothing declares. tests/contrast.test.ts fails a text, role, syntax or categorical colour below its ratio. What the approach still costs is 56 restated values per theme, chosen by hand. What it buys is that a theme switch is one attribute write and nothing in React.