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.
18On this page
- Tokens, not components
- Nine themes
- Eight fonts
- The first paint
- The pickers
- Page frames
- Topic pages
- The topic shell
- The workbench pages
- The mock interview pages
- The review page
- The progress page
- The Git landing, privacy and the pages that fail
- Modals
- CSS modules and globals.css
- The hand-drawn look
- Hover, tooltips and motion
- 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.
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.
| Value | Shown as | Scheme |
|---|---|---|
light | Paper | light |
dark | Night | dark |
kraft | Kraft | light |
blueprint | Blueprint | dark |
sepia | Sepia | light |
forest | Forest | light |
rose | Rose | dark |
mono | Mono | light |
lavender | Lavender | light |
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.
| Role | Use | Replaces |
|---|---|---|
--primary | Buttons, links, the active tab or nav item, progress fills, focus rings and selected states | Green as an action colour, and the interview book's red --primary |
--on-primary | Text and icons on an accent fill | — |
--primary-soft | Selected rows and chips | --hl-mint as a selected background |
--mark | The highlighter behind words, inline code and title underlines | --hl-yellow as the highlighter |
--success, --danger, --caution, --info | States 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.
| Value | Shown as | Headings | Body |
|---|---|---|---|
classic | Classic | Caveat | Kalam |
marker | Marker | Patrick Hand | Shadows Into Light |
sketch | Sketch | Architects Daughter | Reenie Beanie |
pen | Pen | Gochi Hand | Neucha |
chalk | Chalk | Permanent Marker | Indie Flower |
script | Script | Dancing Script | Handlee |
serif | Reading | Literata | Literata |
roboto | Roboto | Roboto | Roboto |
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.
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.
Finished reading?
0 of 26 chapters read in this series