KnowNote is a desktop reading, note-taking and RAG tool. The interface is mostly chrome around content that people read for a long time: document text, notes, chat answers, settings.
This document is the single source of truth for UI decisions in this repository. It is written to be executable: every rule names a token or a Tailwind class. If you are an agent adding a page or a component, follow the recipes in Component recipes instead of inventing values. If a rule here is wrong or missing, change this file in the same PR as the code.
Non-goals: this document does not define product behaviour, branding (logo, hue of the primary colour) or i18n.
- Content is the subject. Chrome is neutral and quiet. Panels are separated by a 1px hairline and a surface step, not by shadows or colours.
- Neutral first, accent rarely. The accent colour marks focus and the primary action of a view. It is never decoration. Same-screen accent elements should be countable on one hand.
- Hairline over shadow. Shadows are reserved for things that float above the document (dialogs, popovers, menus, toasts). Panels never float.
- Density over airiness. 32–36px rows, 44px panel headers, 14px UI text. Density is what makes a knowledge tool feel fast.
- Four states, always. Every interactive surface has a defined
hover,selected,focus-visibleanddisabledexpression. Missing states are bugs, not omissions.
Direction reference: Notion's content feel, Linear's information density, Raycast's desktop chrome (near-flat dark surfaces, hairlines, almost no shadow).
Implementation: components/notebook/NotebookLayout.tsx and the three panels it
composes. Workspace shell owns the geometry; this section is
the layer above it — what each zone is for, how it is entered, and how the loop
between them stays continuous.
KnowNote is not a menu of features. It is one loop:
import → read → annotate / cite → ask → inspect the source → return → excerpt into a note
| Zone | Surface | Owns |
|---|---|---|
| Library | left panel, list state | what is in the notebook: sources, and the way in |
| Reading / Chat | left panel reading state + centre panel | the working surface: the document is the anchor, the conversation is beside it |
| Notes | right panel | output that stays linked to its source |
The citation is the connective tissue between the three. A [n] in an answer
opens the source at its page/block (Cross-panel requests);
a selection in the reader becomes an excerpt in a note that links back to the same
anchor (shared/utils/excerpt.ts). No other surface needs to know how the panels
talk to each other.
Mind map, quiz and Anki are secondary utilities, not part of this loop. They stay reachable — from the Notes panel and from their own windows — but they are never promoted into the three zones or into the loop above. This is #65's explicit non-goal, recorded here so the next IA change does not relitigate it.
Reading does not get its own column. The left card switches between the source list and the reader, and the switch is explicit in both directions:
- A source row opens the reader in-app, for every format. PDF renders its
pages (
PdfSourceReader); every other format uses the text fallback. There is one entry (handleSelectDocument) and one reading contract, so "read from the Library" and "jump from a citation" cannot drift into two navigation models. - "Open in system application" is an explicit action in the reader header, never the meaning of a list click.
- Back (Back button or
Escape) returns to where the reader was opened from. From a citation or a note excerpt, focus returns to that element; from the Library list, focus returns to the list; a reader with no origin (deep link, restored session) hands focus to the chat composer. Returning to the exact origin is the accessible behaviour — not a hard-coded "Escape focuses the composer".
The source row is the primary control of this switch, so it is a real <button>
with hover and focus-visible. Two of the four states are unreachable for
it, and are therefore not invented: selected cannot be shown because the list is
replaced by the reader, so no row is visible while its source is open; disabled
can never apply because reading a source is not gated on anything. The surface
that carries the full hover / selected / focus-visible / disabled set is
the citation chip, not the row.
- Open a citation:
Tabto the chip, thenEnter/Space. Chips are real buttons, so this is the platform behaviour, not a custom keybinding. - Return:
Escape(or Back) returns focus to the chip that opened the reader. - Toggle side panels:
Cmd/Ctrl+[expands/collapses Library andCmd/Ctrl+]expands/collapses Notes, exactly like the panel-header buttons. Reopening restores the remembered width. Citation navigation is separate: it only reveals Library, never collapses it. The centre composer is reached throughFOCUS_CHAT_EVENT(the reader's back fallback). - Close notebook:
Cmd/Ctrl+D, with the unsaved-note guard. Existing Escape and Cmd/Ctrl+W defaults migrate to this binding; other custom bindings stay.
The chip for the citation the reader is currently showing carries aria-current
and a selected fill. That state is derived from uiStore.focusedSource
(sourceAnchorsEqual(citationToSourceAnchor(citation), focusedSource)) — there is
no second selection store to keep in sync.
Every zone defines its four states. This table is the contract; a missing state is a bug (rule 5).
| Zone | empty | loading | error | offline |
|---|---|---|---|---|
| Library | Empty — no documents, or no embedding model with a link to settings |
spinner while the list loads; a row shows indexing while ingesting |
a failed ingestion surfaces on its row; a source whose file is gone fails when opened | local-first: listing sources needs no network |
| Reading | a text source with no content renders an empty reader | reader spinner until blocks and bytes arrive | readerPdfUnavailable when the stored file is missing |
local-first: bytes are served over knownote-doc:// |
| Chat | Empty — "start a new conversation" |
streaming cursor; the send button is disabled while a turn runs | a retrieval failed line; a none line when nothing was used |
the composer refuses to send with no chat model (noProviderConfigured); the transcript still reads |
| Notes | Empty — no items |
generating row for an in-flight artifact; editor save state |
failed row for an artifact |
local-first: notes live in SQLite |
Offline is a model-connection concern, not a zone concern: zones read local data and never gate on connectivity. Only a chat turn and remote embeddings need the network, and both already carry their own unavailable state.
A turn's record carries its state (chat_messages.status), and the transcript
renders from that record — never from whether a stream happens to be open
(shared/utils/answerState.ts is the only place that maps one to the other). That is
what makes the answer that is arriving and the same answer after a restart the same
statement rather than two rendering paths that have to agree.
Four states explain themselves in one muted line beside the answer; none is an error card, and none replaces the text that arrived:
status |
the line |
|---|---|
truncated |
the output ceiling ended it — a completion with a known defect |
blocked |
the provider refused it; not the model's own decision |
aborted |
the reader stopped it |
failed |
generation broke, with the reason |
completed says nothing. A row written before the column existed says nothing
either: unknown is not a claim, and it must not be rendered as one.
The line is text-xs text-subtle-foreground, the treatment the retrieval lines
already use — a state of the answer, quieter than the answer itself.
Implementation: components/home/ and components/pages/NotebookListPage.tsx. Home
is a starting desk, not a dashboard. It has four sections, in the order they are
used, and each one is allowed to render nothing:
| Section | Grammar | Ordered by | Source |
|---|---|---|---|
| Greeting + prompt | text, text-xl |
— | local time |
| Search entry | input, h-12 |
— | notebooks + recent sources, filtered in JS |
| Recent notebooks | cards | notebooks.updatedAt |
the notebook store |
| Continue | list rows | the user's own activity | lib/recentlyOpened.ts + recentSessions |
| Recently added | list rows | documents.createdAt |
recentDocuments |
Rules:
- No hero banner. The greeting is one
text-xlline and a T2 prompt. Home is opened a hundred times; a banner is paid for on every one of them. The visual centre is the entry below it, not the words above it. - One grammar per kind of thing. Notebooks are cards, everything that is not a notebook is a row, and creating is a button. Repeating one container for every object is what makes a launcher read as a wall of identical boxes, and it hides the difference between a place you work and a file that arrived.
- "Continue" means where the user was, not what changed. The shelf is ordered by
updatedAt, so a notebook renamed last week outranks the one read an hour ago. Continue merges the notebooks the user actually opened with the conversation last written to, ordered by real recency, and caps at three. - No dead affordances. There is no "View all" link, because there is no Library page to point at — Home is where notebooks are browsed, so the shelf expands in place with a real disclosure. A section with nothing to show is omitted, not rendered as an empty box with a heading.
- One read, not a fan-out. Home's data arrives in a single
get-workspace-overviewcall (WorkspaceOverview): source counts, the newest sources across every notebook, and the last active conversation. One IPC per notebook would grow with the library on a page that is opened constantly. - Counts are rendered only when known. A notebook card takes an optional
sourceCountand omits the count rather than printing a zero it cannot vouch for.
HomeSearch filters notebook and source names in the renderer, over data Home
already has. It is not wired to retrieval, and the placeholder says so: search the
text of the library and ask a question across all notebooks is a different change,
because searchChunksFts is scoped to one notebook and every notebook owns its own
vector table. A box labelled "ask your knowledge" that quietly matched only titles
would be worse than the honest label.
The entry owns the Cmd/Ctrl+K binding on Home, so the hint it renders is a
shortcut that works. Results are a combobox / listbox pair with
aria-activedescendant; options prevent mousedown so clicking one does not blur
the input before the click lands.
A shelf card takes hover:-translate-y-px and hover:shadow-control, over 150ms.
This is the one place a surface-raised card may carry a shadow, and only while
hovered: it is a transient affordance on a control, not a panel floating over the
document. At rest the card is border + surface step, per Borders and
shadow. Never widen this into a resting shadow or a
shadow-elevation.
The app is a stack of opaque surfaces plus two translucent state fills. Higher in the stack means further from the window background, and lighter in both colour schemes. Each of the three opaque steps has one job, and a surface is only ever used for that job:
| Tier | Token | Tailwind | Light | Dark | Use for |
|---|---|---|---|---|---|
| floor | --surface-sunken |
bg-surface-sunken |
oklch(0.946 0.0045 91) |
oklch(0.1776 0.0015 91) |
The workspace floor: the window background and the gutter between panels. Also the recessed utility fill — a segmented-control track, a code block |
| chrome | --surface-base |
bg-surface-base |
oklch(0.9756 0.0026 106.45) |
oklch(0.2046 0.0015 91) |
Rails and bars: the library panel, the notes panel, the settings nav, every window title bar, a full-bleed page background |
| content | --surface-raised |
bg-surface-raised |
oklch(1 0 0) |
oklch(0.235 0.0015 91) |
What the user is reading or writing: the chat document, the reader, the editor, a notebook card, a dialog body |
| floating | --surface-overlay |
bg-surface-overlay |
oklch(1 0 0) |
oklch(0.285 0.0015 91) |
Layers above the page: dialog, sheet, popover, menu, select, tooltip, toast |
| state fills | --surface-hover |
bg-surface-hover |
foreground @ 5% |
neutral 0.93 @ 6% |
Translucent hover fill for rows, menu items, ghost buttons |
--surface-selected |
bg-surface-selected |
foreground @ 9% |
neutral 0.93 @ 10% |
Translucent selected/active fill for rows and toggles |
The three opaque steps are the floor → chrome → content ladder. In the
workspace it reads as: the p-2 gutter is floor, the library and notes panels
are chrome, and the chat document in the centre — the thing the notebook is
about — is content. That is the whole hierarchy; no shadow or colour carries
it.
Rules:
- Never invent a background colour.
bg-white,bg-black,bg-gray-*,bg-slate-*,bg-[…]and rawoklch()/hsl()in a component are all violations. If the surface you need is not in the table, the table is wrong — extend it here. The one narrow, deliberate exception is the four tool fills: a coloured card background on the Notes creation tools and nothing else. surface-hoverandsurface-selectedare translucent so that they adapt to whatever they are drawn on. Do not add alpha to them again (bg-surface-hover/50is a bug).- The ladder is monotonic:
sunken < base < raised < overlayin lightness, in both colour schemes. Ordering surfaces the other way (a panel darker than the canvas in dark mode) is the bug this ladder was introduced to fix. - Adjacent surfaces must differ in lightness; two neighbours at the same value mean you are expressing hierarchy through a shadow, which is not allowed.
- Inside a panel, nested containers (a bubble, an inline code block, an icon
tile) use
bg-muted— a quiet opaque fill that is recessed relative tosurface-raisedin both colour schemes. Do not nestsurface-raisedinsidesurface-raised.
The neutral ramp is warm. Surfaces and text share hue ~91 (a warm grey) rather than the zero-chroma grey this palette used to be, which is what makes a white panel read as paper instead of as a default browser surface. Text and surfaces remain two separate ramps — they agree on hue, nothing else. Do not mix a warm surface with a cool grey label, and do not introduce a second neutral hue.
Not a separate tier: the window title bar. It is the OS window frame rather
than a panel, so it is drawn bg-surface-base — the chrome tier — in all four
windows. It takes no step of its own; do not give it one.
Legacy aliases kept for compatibility while pages migrate: --background,
--card, --popover, --sidebar and --accent — with their -foreground
siblings and the remaining --sidebar-* tokens — are defined in terms of the
tokens above. New code uses surface-*.
--secondary / --secondary-foreground were dead tokens: they held their own
literals rather than aliases, nothing rendered them (Button and Badge
variant="secondary" both use bg-muted), and the light value was the only
blue-tinted neutral in the palette. They have been deleted; do not reintroduce
them — the secondary variant name is unrelated to a --secondary colour
and is spelled with bg-muted.
Implementation: components/notebook/noteTools.ts (the token on each tool),
assets/theme.css (values and Tailwind mappings), and
components/notebook/NotePanel.tsx (the only consumer).
These four tokens exist for the Notes panel's creation tools — create note, mind
map, quiz, Anki — which are the one place a card carries colour. They are not
a surface tier, not an accent and not chart colours: a decorative pastel fill that
lets the four action cards be told apart at a glance. The grid is 2 x 2, in the
order create note, mind map, quiz, Anki.
| Token | Tailwind | Light | Dark | Tool |
|---|---|---|---|---|
--tool-note |
bg-tool-note |
oklch(0.935 0.045 250) |
oklch(0.32 0.04 250) |
Create note |
--tool-mindmap |
bg-tool-mindmap |
oklch(0.935 0.045 300) |
oklch(0.32 0.04 300) |
Mind map |
--tool-quiz |
bg-tool-quiz |
oklch(0.935 0.045 150) |
oklch(0.32 0.04 150) |
Quiz |
--tool-anki |
bg-tool-anki |
oklch(0.935 0.045 85) |
oklch(0.32 0.045 85) |
Anki |
Rules:
- Scope is exactly these four cards. A fifth use — a badge, a tinted list row,
a status chip — is a rule change and belongs in this document before it belongs in
the code.
--chart-*and--primarymay not be borrowed for this either; the chart guard stays as it is. - Text and icons stay neutral. The icon stays
text-muted-foreground(T2, neutral) and the label staystext-foreground(T1,text-sm font-medium). Colour carries the tool's identity; the glyph and the word carry the meaning, so colour is never the only carrier of anything. - Low saturation on purpose. Chroma stays at 0.04–0.05 so a pastel tile never
competes with the
--primaryaction or a chart. They sit deliberately outside the surface ladder: light values are a step below--surface-base(a recessed tile) and dark values a step above it (a muted raise), so the card reads as a distinct tile rather than as a panel in either scheme. Both stay above--border, so the card's hairline is visible in both themes.
The title bar is the one piece of the interface the OS and the renderer draw
together: the renderer draws the bar, the OS draws minimize / maximize / close
on top of it. Both halves therefore read their geometry from
src/shared/utils/windowChrome.ts, and neither repeats the numbers. The
renderer half is one component, components/common/WindowTitleBar.tsx, shared
by all four windows — main, mind map, quiz and Anki.
| Value | Constant | Renderer side |
|---|---|---|
| Title bar height, 44px | TITLE_BAR_HEIGHT |
h-11 in WindowTitleBar |
| Control strip, 138px | WINDOW_CONTROLS_WIDTH |
reserved on the right, Windows and Linux |
| Traffic lights, 80px | MAC_TRAFFIC_LIGHTS_WIDTH |
reserved on the left, macOS |
Rules:
- The height given to
titleBarOverlay.heightis the height of the drawn window controls, so it has to equal the renderer's bar. A mismatch shows up as controls that are visibly not centred in their bar — that is what a 35px overlay under a 44px bar looked like. - Every window reserves the OS strips, not just the one that happened to get it right. Each window used to carry its own copy of this geometry and they had drifted to 100px, 128px and 0 for the same reserve: the controls sat over the content on Windows, and on Linux two windows reserved nothing at all.
- Both reserves are real measurements, not round numbers. Windows caption
buttons are 3 x 46px, so
w-32(128px) was 10px short and put the settings button under the minimize button. - A window whose content is full-bleed (the mind map canvas, the quiz view, the
flashcard list) adds
border-b border-borderto separate the bar from the content. The main window does not: its panels already sit in ap-2canvas gutter. - A window that overlays its bar on the content offsets that content by
TITLE_BAR_HEIGHT. Do not write the number again.
color-scheme is declared per theme in theme.css so that the surfaces the CSS
does not paint — scrollbars, caret, selection, form controls — follow the app
theme instead of the OS light default.
Implementation: components/layouts/ResizableLayout.tsx and DragHandle.tsx.
The three-zone workspace is the one part of the interface whose geometry used to live only in component constants. These are the values; change them here first.
| Value | Constant | Notes |
|---|---|---|
| Container gutter | p-2 (CONTAINER_PADDING_X) |
The canvas gap around and between the panels |
| Initial side widths | golden ratio of the free space | 1 / (2 + 1.618) each; the centre takes 1.618 / 3.618 |
| Minimum side panel | 260px (MIN_SIDE_WIDTH) |
A drag and a keyboard step both clamp here |
| Minimum centre panel | 420px (MIN_CENTER_WIDTH) |
The sides give up width first, down to their own minimum |
| Seam | w-3 (HANDLE_WIDTH) |
12px canvas gutter; the gutter itself is the drag target |
| Fallback widths | 320px / 360px |
Used when the container has no measurable width yet |
| Keyboard step | 10px / 50px |
Shift for the larger step; Home / End for the limits |
| Persistence | localStorage knownote:panel-widths |
Survives navigation, unlike the component's own lifetime |
Rules:
- The constraints live in one place. They are computed from the measured
container width in
ResizableLayout, never restated as a CSSmin-width— the two drifted apart, and the CSS copy was derived from a width that stopped being measured after the first render. - A drag owns the whole gesture. Listeners go on
window, the container rect and the constraints are snapshotted at pointer-down, and the commit is rAF-coalesced. A drag ends onpointerup,pointercancel,windowblur,visibilitychangeand onevent.buttons === 0in the move handler — that last one is what catches a button released while another window held focus, where neitherpointerupnorpointerleaveever arrives. pointerdowncallspreventDefault()and takes the drag lock (lib/dragLock.ts:user-select: none+ a forced cursor, ref-counted, Escape cancels). Without the lock, dragging past a panel's limit leaves the browser's native selection drag running and selects the text underneath.- The seam is a gutter, and its width is layout. The panels are separate
cards floating on the
surface-sunkenfloor; the gutter between them is the handle. That is whyHANDLE_WIDTHis subtracted from the width the panels may occupy, and why the handle paints nothing at rest. Painting it (bg-border) or shrinking it towardw-pxcloses the gutter and makes the cards read as adjacent surfaces sharing a divider. This is the one place a hairline is wrong — do not "tidy" it. - The seam is a real
separator:role,aria-orientation,aria-valuenow,aria-valuetext,tabIndex={0}, arrow keys. Panel resizing is not mouse-only. - Arrow keys move the seam, not the panel.
ArrowLeftshrinks the panel on the left of the seam and grows the panel on the right of it;ArrowRightdoes the opposite.Home/Endgo to the minimum and maximum of the value. The two seams report the same kind of number (the panel width in px) under the same key semantics — an earlier revision inverted one of them, so the same key raised the reported value on one seam and lowered it on the other. - Double-click a seam restores that panel to its default share. Persistence without a way back is a trap: one bad drag otherwise keeps a 260px reading pane forever.
- The centre's minimum is not absolute.
maxSideWidthfloors each side atMIN_SIDE_WIDTH, so on a window too narrow for both minimums the centre absorbs the shortfall rather than a side panel being clamped to something unusable. In practice the window's own 1000px minimum keeps the two from colliding, but the guarantee comes from the window, not from this layout. - A collapsed panel keeps its previous width in the session so expanding restores
it;
0is never persisted as a width.
MessageList follows the transcript only while the reader is already near the
bottom (within PINNED_THRESHOLD, 48px). Scrolling up suspends the follow and
reveals a circular, icon-only jumpToLatest control centred above the composer;
the follow resumes when they return to the bottom, or when the control is
pressed. A stream that force-scrolls per token makes re-reading impossible. The
follow itself is instant, never smooth, because it runs per token and an
animation queued that often never settles; the control is the one place a smooth
scroll is allowed, and only while no answer is streaming. Reduced motion turns
even that into an instant jump.
The distance to the bottom is scrollHeight - scrollTop - clientHeight clamped at
zero, compared against the threshold — never scrollTop + clientHeight === scrollHeight, which is false under fractional pixels and reflows. The numbers
live in stickToBottom.ts and are tested there.
The transcript's empty state and its message list must share one ScrollArea.
The scroll subscription runs once, so a viewport that only exists after messages
arrive can never be observed.
The follow is driven by the content's height, not only by the messages array:
streaming grows one message, and Markdown reflow, a highlighted code block, a
decoded image or an opened source disclosure change the height with no message
event at all. A ResizeObserver on the transcript keeps the follow alive in all
of those, and only while the reader is pinned.
ProcessPanel floats the composer over the transcript, so the transcript reserves
space for it: COMPOSER_GAP (24px) plus the composer's own measured height. The
composer's height is measured, not assumed — it grows with the scope row and
the auto-resizing textarea, and the old fixed reserve left a long question's answer
behind the input. A single ResizeObserver publishes the reserve as
--composer-reserve on the panel card; the transcript's padding-bottom, the
back-to-bottom control's offset and the scroll fade all read that one value. The
fade is exactly the reserve's height for the same reason: a taller fade dims the
last line of every answer, because the content scrolls under it.
Two rules for the composer and for leaving the workspace:
- Enter sends; only the Stop button aborts. Enter used to abort a running
generation while the composer was disabled during a stream, so the key you press
to send was the key that cancels — and the composer was unusable for composing
the next question while reading the current answer, which is the actual rhythm
of research. The textarea now stays enabled while a turn streams;
canSendis still false, so Enter is inert rather than destructive, and the Stop button is the single abort affordance. Shift+Enter is still a newline. - Sending always brings your own message into view.
MessageListexposes apinToBottomhandle andhandleSendcalls it, so the follow state cannot hide the message the user just wrote. - Leaving the workspace asks first when the note editor is dirty. Closing a
tab, switching tabs, the Home tab and
Cmd/Ctrl+Dall unmount it.NotePanelpublishes its dirty state touiStore(only it knows), andNotebookLayoutowns the singleUnsavedChangesDialogthat every one of those paths goes through. The in-panel Back button is not special-cased — it uses the same state. A navigation path that skips this guard is a bug.
Implementation: components/notebook/chat/AnswerSources.tsx, fed by
chat_messages.metadata (see message metadata).
Retrieval already knew which passages it used and threw the identity away: the
prompt kept a title and some text, and chunkId / documentId were dropped. The
answer therefore looked identical whether it came from the reader's documents or
from nowhere, which is the one distinction this product cannot afford to lose.
The region has three states, and they are three different statements — do not collapse them:
| State | Shown |
|---|---|
used |
A disclosure: "Based on N source passages" → the passages |
none |
One T3 line: nothing from your sources was used |
failed |
One T3 line: searching your sources failed |
| no status at all | Nothing — an older message is "unknown", not "ungrounded" |
Rules:
- The passage is quoted verbatim, never summarised or truncated into a paraphrase. It is the evidence; if it is too long to read, it is still too long to invent.
- Each passage sits in
bg-muted— a recessed container inside the panel, per the surface rules. The region is not aCard: cards inside panels nest, and this belongs to the message, not beside it. - Density is the disclosure's job. The collapsed state is one line; the passages are behind it.
- Only after the turn ends. Rendering an empty evidence list mid-answer would read as "nothing was used".
- The ungrounded and failed lines are T3: they must be readable, and they must not compete with the answer.
The structured part of chat_messages.metadata is typed (ChatMessageMetadata
in shared/types/chat.ts) and read only through
shared/utils/answerSources.ts. Two rules:
- Parse defensively, drop per entry. The column is an open JSON bag written by whichever version of the app produced the message, so a reader never assumes a shape. One malformed entry must not hide the passages that did survive: two usable sources out of three still show two.
- "Not recorded" is not "none". An answer from before this existed has no status; saying it was ungrounded would accuse a grounded answer. Unknown renders nothing.
The transcript (centre) and the library (left) are siblings, so a citation click
cannot pass a prop. It writes to uiStore and SourcePanel derives from it during
render — never in an effect:
const openDocument = focusedDocument ?? selectedDocumentTwo things this avoids, both of which were tried and rejected:
setStateinside an effect to consume the request: the lint rulereact-hooks/set-state-in-effectfails the build, and it cascades renders.- Clearing the store request from inside render: writing to a store during render notifies other components mid-render. The clear happens in the event handlers instead (list click, Back), which is why the derivation needs no cleanup step.
The arithmetic above — availableFrom, maxSideWidth, the golden-ratio split, the
stored-layout parser, and the key-to-width mapping — lives in
components/layouts/panelGeometry.ts, not inside the component. It used to be
inline, where the only way to exercise it was to drag a mouse, and it was wrong:
ArrowLeft grew the left panel and shrank the right one. That passed typecheck and
lint, and only a second reviewer found it.
test/panelGeometry.test.ts covers the constraint math, the stored-layout parser
(absent, corrupt, NaN, Infinity, zero) and the arrow-key direction on both
seams. Note what does not catch the inversion: an invariant like "the two seams
move oppositely" stays true when both are flipped together. Only assertions on the
absolute direction do.
prefers-reduced-motionis honoured intheme.css: transitions and decorative animation collapse, scroll behaviour goes instant. Spinners keep turning, slowed rather than stopped — a spinner is a status indicator, and removing it removes information, not motion. The!importantthere is load-bearing: a*selector cannot outrank Tailwind's utility classes.- The transcript announces completion, never tokens. A
role="status"region insideMessageListreports when a turn ends, with the node keyed so an identical message still re-announces. Putting the live region on the transcript itself would chatter on every token, which is worse than the silence it replaces. - The tab close control is a sibling of its tab, not a child. A real
<button>with an accessible name, next to therole="tab"rather than nested inside it as arole="button"span. The tablist uses a roving tabindex, so the old innertabIndex={0}added a second focus stop per open notebook and duplicated the Enter/Space handling the trigger already had.
Two border weights exist, and only two:
- Hairline divider:
border-b border-border/border-r border-border— panel seams, panel headers, list separators. - Control outline:
border border-borderonInput,Selecttrigger,outlinebuttons and popover/menu containers.
An element gets both a border and a shadow only when it floats. Rules:
| Layer | Border | Shadow |
|---|---|---|
Panel (surface-raised) |
hairline dividers only, rounded-lg |
none |
Card (surface-raised) |
optional border border-border |
none |
Overlay (surface-overlay) |
border border-border |
shadow-elevation |
| Small controls (switch knob, slider thumb, drag handle) | — | shadow-control |
| Buttons, inputs, badges, tabs, list rows | — | none |
shadow-elevation and shadow-control are the only shadow tokens permitted in
component code. The legacy shadow-sm/md/lg/xl/2xl utilities are aliases of
shadow-elevation and exist only so that un-migrated lines degrade to a
consistent value instead of a wild one; the design guard fails a build that
introduces them.
ring shadows (ring-2, focus-visible:ring-ring) are focus rings, not
elevation, and are always allowed.
Three values, no exceptions:
| Value | Class | Use for |
|---|---|---|
| 6px | rounded-md |
Controls: buttons, inputs, selects, menu items, list rows, tabs, badges with a label |
| 8px | rounded-lg |
Containers: panels, cards, dialogs, popovers, empty states, icon tiles, avatars-in-box |
| 9999px | rounded-full |
Pills, avatars, knobs, progress bars, counters |
--radius: 0.5rem in theme.css derives both scale values, so this is a single
knob for the whole app. Forbidden: rounded-sm, rounded-xl,
rounded-2xl, rounded-3xl, rounded-[…]. rounded-none is not in the list
either: a full-bleed element inside a padded container should get its parent
p-1 + rounded-md items instead of a square child.
Do not round one corner differently (rounded-t-lg is allowed only on a header
that is visually part of a rounded container).
Base rhythm is 4 / 8 / 12 / 16 (gap-1,2,3,4; p-2,3,4,6). Half steps
(px-2.5, py-1.5, gap-1.5) are tolerated only when aligning text against a
16/20px icon; do not introduce them anywhere else.
| Element | Height | Padding |
|---|---|---|
| Panel header | h-11 |
px-3, content gap-2 |
Title bar (WindowTitleBar) |
h-11 |
px-2 main · px-3 overlay |
| Title-bar icon button | w-7 h-7 |
— |
| Title-bar tab | h-7 |
px-3 |
Toolbar icon button (Button size="icon") |
size-8 |
— |
Button (default) |
h-9 |
px-4 |
Button (sm) |
h-8 |
px-3 |
Button (lg) |
h-10 |
px-5 |
| Input / Select trigger | h-9 |
px-3 |
| List row, compact | h-8 |
px-2 |
| List row, comfortable | h-9 |
px-3 |
| List row, multi-line (title + meta) | — | px-2 py-2, rounded-md |
| Sidebar nav item | h-8 |
px-2 |
| Card / panel body padding | — | p-4 |
| Dialog padding | — | p-5 |
| Section gap inside a panel | — | space-y-3 / divide-y divide-border |
| Page gutter | — | px-6 py-4 |
Rows are separated with divide-y divide-border or a border-b hairline, never
with margin gaps.
The title bar is its own density tier: it is the only place allowed to step
below size-8 / h-8. TopNavigationBar runs 28px tabs and 28px icon buttons
so the strip reads as window chrome rather than as content, and it keeps the
window controls comfortable in the 44px bar. That tier stops at the title bar —
a 28px control inside a panel is a violation, not a precedent.
Fonts (bound in theme.css, loaded by index.html):
--font-sans: Inter for UI and prose, --font-mono: Fira Code for code, IDs
and keycaps. --font-serif: Lora is reserved for long-form article titles; it is
not currently used and must not be introduced without a stated reason.
Three text levels, and only three:
| Level | Class | Use for |
|---|---|---|
| T1 — primary | text-foreground |
Titles, panel headers, list primary line, body of documents and answers, form values |
| T2 — secondary | text-muted-foreground |
Descriptions, list secondary line, help text, placeholders, inactive icons |
| T3 — tertiary | text-subtle-foreground |
Timestamps, counters, keyboard hints, disabled labels — anything that must be readable but must not attract the eye |
Do not express hierarchy by inventing a new grey (text-gray-500), and do not
use opacity on top of a level (text-muted-foreground/70).
Sizes:
| Size | Class | Use for |
|---|---|---|
| 11px | text-[11px] |
Keycaps, tiny counters (rare; prefer text-xs) |
| 12px | text-xs |
Meta line, timestamps, badges, table headers, code |
| 14px | text-sm |
Default UI size: buttons, labels, list rows, inputs, prose in chat and the editor |
| 16px | text-base |
Dialog titles, empty-state body (only where 14px reads cramped) |
| 18px | text-lg |
Page titles, empty-state titles |
| 20px | text-xl |
Focus content: home page title, flashcard face |
| 30px | text-3xl |
The notebook title in the workspace header, font-semibold tracking-tight. One per screen |
| 36px | text-4xl |
A single focus readout (quiz score). One per screen |
Weights: font-normal for body, font-medium for interactive labels, panel
headers, section titles, titles and the selected state of anything.
font-semibold and above are reserved for two things: headings inside long-form
prose (.markdown-content h1–h4, .ProseMirror h1–h3, all 600), and the one
focus readout per screen. A prose heading is never font-bold: an h3 inside an
answer must not out-weigh the panel header above it. Never use weight alone to
express selection — pair it with bg-surface-selected.
Line height: UI text uses Tailwind defaults. Long-form reading surfaces
(markdown.css, noteEditor.css) use 14px / line-height: 1.75.
Measure. There are two widths, because a workspace and a reading column are different things.
A long-form reading surface is capped at --reading-measure (72ch) and centred:
<div className="mx-auto w-full max-w-[var(--reading-measure)] px-4 py-6">The note editor (NoteEditor.tsx, p-4) is that surface. Widening a panel adds
margin around the column; it must never lengthen the line — uncapped, a 1000px
centre panel sets prose at roughly 130 characters per line.
The chat transcript is not a reading column. Its canvas — the notebook header,
the empty state and the message list in MessageList.tsx — is max-w-5xl
(~1024px), the same width as the home page, so a wide centre panel is actually
used. Inside that canvas an answer is capped at --answer-measure (88ch) rather
than by --reading-measure: an answer carries tables and code as often as prose,
and a paragraph at 88ch is still inside a comfortable band. A user message keeps
its narrower bubble. The composer (ProcessPanel.tsx) is centred on the same
max-w-5xl canvas, so the input lines up with the transcript it answers.
Both tokens are defined once in theme.css; take them as variants rather than
hard-coding a ch value or a pixel width.
The source reader does not read --reading-measure yet, and the reason is a
contract rather than an oversight: TextSourceReader measures its highlight
rectangles once from the laid-out range, so a column that re-centres on resize
would strand the highlight. Make that measurement resize-aware before capping
that surface.
--primary is the only accent. It is allowed in exactly five places:
- The primary action of a view (
Buttonvariantdefault). - Focus indication (
focus-visible:ring-2 focus-visible:ring-ring). - The selected indicator of a nav/list row: a 2px bar
(
before:/absolute inset-y-1 left-0 w-0.5 rounded-full bg-primary) or a filledbg-primary text-primary-foregroundicon chip. - Inline links: the
Buttonvariantlink, and prose links inside long-form content (.markdown-body,.note-editor). - Progress and slider fill (
bg-primary), where the colour is the data.
Everywhere else — toolbars, icon buttons, headers, cards, badges, tab labels,
progress — the UI is neutral. A blue icon in a neutral toolbar is a violation.
--destructive is allowed only on a destructive action and its confirmation
dialog, and --success only on the outcome of a graded result (correct answer,
passing check). Neither is decoration, and neither may be the only carrier of
state: pair it with an icon or a label. --chart-* are for charts, graph nodes
and mind-map topic colouring, and are used as small data dots rather than as
text-bearing fills (they sit at one lightness in both themes, so no single text
colour reads on them).
State expressions (use these literal forms, they are the contract):
| State | Expression |
|---|---|
| hover | hover:bg-surface-hover (rows, menu items, ghost/outline buttons; see Raised card for opaque cards) |
| selected | bg-surface-selected + text-foreground + font-medium; optional 2px primary indicator |
| focus-visible | focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background (inputs use ring-1 with border-ring) |
| disabled | disabled:pointer-events-none disabled:opacity-50 |
| transition | transition-colors for fills; transition-[transform,opacity] for popovers. No bouncy or long animations on chrome. |
A row that is both hovered and selected keeps the selected fill; do not stack
hover:bg-surface-hover on bg-surface-selected elements. The one exception to
the hover form above is an opaque surface-raised card, where the token has to
be composited rather than swapped — see Raised card.
Tabs has exactly two documented forms, and they express selection differently:
- Segmented control — settings, filters, mode switches. Track
bg-surface-sunken, selected segmentbg-surface-raised, no shadow. The thumb has to cover the track, so selection is a surface step. Both steps are monotonic in dark mode, whichbg-muted+bg-backgroundwas not. - Tab strip —
TopNavigationBar's open notebooks are document tabs, not a mode switch. No track (bg-transparent border-0 p-0 h-auto), and selection is the ordinary translucent fill:bg-surface-selected+text-foreground+font-medium, with a restingborder-transparentbecomingborder-borderwhen active so the label does not shift by a pixel.
Small atoms keep the control radius even though it is proportionally rounder:
a 16px checkbox is rounded-md, not a new radius value.
Dark mode is a first-class theme, not a filter. Requirements:
- Every surface, border and text token has a dark value defined in
theme.css; a component must never branch on the theme itself. - The surface ladder is monotonic in dark mode too (see Surfaces).
- Text levels keep their contrast ordering: T1 > T2 > T3. Do not ship a dark value that reads below 3:1 against its surface.
- Colour is never the only carrier of state (selection, error, warning) — pair it with weight, an icon, or a border.
- Both themes must be usable on every page; a page that "looks off in dark" is a blocker, not a follow-up.
Do:
- Separate panels with a hairline and one surface step.
- Use
bg-surface-hoverfor hover,bg-surface-selectedfor selection. - Keep a screen to one accent action.
- Put reading content on
surface-raisedwith T1 text and generous line height. - Reach for
text-smbefore anything else; go smaller only for meta.
Don't:
bg-white,bg-black,bg-slate-*,text-gray-*,border-[#…], rawoklch(...)in a component,bg-card shadow-mdpanels,rounded-xlcards,shadow-smbuttons or inputs,rounded-smcontrols.- Gradients, glassmorphism (
backdrop-blurexcept on a floating toolbar over scrolling content), glow effects. One sanctioned exception: the scroll fade between the transcript and the floating composer — a functional fade, sized to the measured composer reserve it covers so it never dims the last line of an answer (ProcessPanel.tsx). - Colour icons in neutral chrome; colour-coded sections chosen from
--chart-*outside chart/graph surfaces. - Express hierarchy with
scale-*transforms on chrome (allowed only inside mind-map / graph editors, where zoom is part of the interaction). transition-allon rows (transition-colorsis enough and avoids animating layout properties).
Copy these shapes. cn() (@/lib/utils) is used for merging class overrides.
Panel is the frame for a workspace zone. Which tier it takes depends on what
the zone holds — a rail is chrome, the thing being read is content:
- Rail (library, notes, settings nav):
bg-surface-base. - Content (chat transcript, reader, editor):
bg-surface-raised.
A rail and a content panel share the same hairline; what separates them is the tier under it. The hairline is one step lighter than it used to be, because the surface ladder now carries part of the separation the outline used to carry alone — do not compensate by reaching for a darker border, and do not add a second outline to a rail that already sits in the gutter.
// rail
<Card className="flex h-full flex-col overflow-hidden bg-surface-base">
// content
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-surface-raised">
<PanelHeader … />
<div className="min-h-0 flex-1 overflow-y-auto themed-scrollbar">…</div>
</div>There are two scroll controls and they must look like one. themed-scrollbar is
the native-scrolling variant above; a Radix ScrollArea
(components/ui/scroll-area.tsx) draws its own bar instead, because a surface
that needs to read or set scroll position (the chat transcript) cannot use a
plain overflow container. The primitive's bar is deliberately the same control:
8px track, a 4px --muted-foreground pill that darkens to --foreground on
hover. Change them together, never one without the other.
Implementation: components/ui/panel-header.tsx.
<div className="flex h-11 shrink-0 items-center justify-between border-b border-border px-3" />A panel header is chrome. It carries the panel's own controls and nothing that
belongs to the document — in the chat panel that is the two collapse toggles, and
its centre is an empty <span /> that exists only to keep the centre a drag
surface. Do not put the notebook title back in it: the title is the document
header's job (see Notebook header), and the window's tab strip
already carries the name at all times.
The content below a panel header starts at top-11, the header's own height.
Nothing sits in between; a top-14 left a 12px dead band under every header.
Implementation: components/notebook/chat/NotebookHeader.tsx, rendered as the
first block inside the transcript's scroll area (it is passed to MessageList as
header, not positioned above it).
<header className="pt-6 pb-2">
<button className="block w-full cursor-text rounded-md text-left text-3xl font-semibold tracking-tight break-words">
{notebook.title}
</button>
<p className="mt-2 text-xs text-subtle-foreground">
{sources} · {updated}
</p>
</header>- The title scrolls with the answer, like a page title, instead of sitting pinned above the scroll area. Pinning it would change the boxes the composer reserve, the scroll fade and the follow-the-answer observer are measured against; scrolling it costs nothing and reads as a document.
- The title is a
<button>, not a span with a click handler, and its affordance iscursor-text— no resting border, no hover fill. Attext-3xla hover block would read as a control sitting on a document. - Metadata is one T3 line:
documentscount and the relative updated date (lib/relativeDate.ts, shared withNotebookCard). Counters and timestamps are exactly what T3 is for. - There is no subtitle.
notebooks.descriptionexists but nothing in the app can author it, so every notebook carries the same creation-time filler. Printing a sentence the user never wrote is worse than an empty line; add it when the field is editable.
Implementation: components/common/WindowTitleBar.tsx. Every window uses it;
none hand-rolls the geometry. left and right take content (the reserves are
applied around them), and center takes the remaining width.
// main window: the tab strip is the centre, settings sits next to the controls
<WindowTitleBar className="gap-0.5" right={<SettingsButton />} center={<TabStrip />} />
// secondary window: the bar floats over full-bleed content
<WindowTitleBar
overlay
className="border-b border-border"
center={<span className="text-sm font-medium text-foreground">{title}</span>}
right={<Actions />}
/>Children that live inside a WebkitAppRegion: 'drag' bar must set
WebkitAppRegion: 'no-drag' on themselves, or they cannot be clicked.
Separators, not gaps:
<div className="divide-y divide-border">
<button className="flex h-9 w-full items-center gap-2 px-3 text-sm text-foreground transition-colors hover:bg-surface-hover" />
</div>A list inside a chrome panel (the library, the notes list) is not a table. Rows
are separated by a 4px gap and identified by a hover fill on a rounded-md
block, never by a hairline; groups are separated by 12px and labelled with a T3
section heading.
<div className="p-2 space-y-3">
<section className="space-y-1">
<h2 className="select-none px-2 py-0.5 text-xs font-medium text-subtle-foreground">
{label}
</h2>
{/* one row */}
<div className="group flex items-start gap-0.5 rounded-md transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
<button className="flex min-w-0 flex-1 items-start gap-2 rounded-md px-2 py-1.5 text-left">
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm text-foreground">{title}</span>
<span className="truncate text-xs text-subtle-foreground">{meta}</span>
</span>
</button>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={…}
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100"
/>
</DropdownMenuTrigger>
</div>
</section>
</div>Rules:
- A heading is rendered only when there is more than one group. A heading over the only group restates the panel header.
- Secondary actions live in one
···menu, revealed on hover or focus. A row in a rail never carries a permanent column of buttons — that is what made every source row read as a toolbar. Retry, re-index, open and delete are items in the same menu, applied in that order with the destructive item last, below a separator. - The hover-revealed control is always laid out, with
opacity-0rather thanhidden/conditional rendering, so revealing it cannot reflow the title. - The row button and its menu trigger are siblings, never nested. A button inside a button is unreachable by keyboard and is the same defect the tab strip already fixed.
- The row button keeps a real hover fill (
hover:bg-surface-hoveron the wrapper). The revealed control useshover:bg-surface-selectedso it stays visible on top of it. - A source row leads with what the source is. The metadata line is
<kind> · <n> chunks, where the kind comes fromlib/sourceKind.ts(sourceKindOf), not fromdocument.type—filecovers PDF, Word and Slides, and "202 chunks" on its own is machine vocabulary, not a fact about the source. The leading glyph isKIND_ICON[kind]in the same module's vocabulary, drawn from one library at one stroke weight and left neutral: the kind is carried by the glyph and the word, never by a tint (a colour icon in neutral chrome is a violation). - The kind is derived, never stored twice.
sourceKindOfis the single reader ofmimeType/ path / URL, it is total (an unknown file isfile, not a guessedtext), and it is asserted intest/sourceKind.test.ts. It is deliberately not shared withSourceReader'sisPdf: that predicate answers "which reader renders this?", which is a rendering contract, and folding the two together would let a display change pick a different reader. - There is no persistent selected row in the library. Clicking a source replaces the list with the reader (see Library and Reading are one zone, two states), so there is no state in which a selected row is on screen; do not add one back. The notes rail does have a real selection and uses the Selected row recipe.
<button className="flex h-9 items-center gap-2 rounded-md bg-surface-selected px-3 text-sm font-medium text-foreground" />Dialog, popover, menu, toast:
<div className="rounded-lg border border-border bg-surface-overlay shadow-elevation" />Implementation: components/notebook/ProcessPanel.tsx. The chat composer is the
one floating layer that lives permanently on the page rather than over it.
<div className="relative bg-surface-overlay rounded-lg border border-border focus-within:ring-2 focus-within:ring-ring shadow-elevation">
<ScopeSelector … />
<Textarea className="min-h-[56px] max-h-[280px] py-3 pl-4 pr-14 resize-none border-0 bg-transparent focus-visible:ring-0" />
<Button size="icon" className="absolute right-2 bottom-3 rounded-full" />
</div>- Opaque, and no blur. It is a floating surface with a hairline and
shadow-elevation. It does not takebackdrop-blur— the functional scroll fade below the transcript already covers the content that passes under it, so a blur here costs GPU and returns nothing. - One line when empty.
min-h-[56px]keeps it reading as an input; it grows with the content tomax-h-[280px]. - Its height is measured, never assumed.
ProcessPanelwrites the measured height to--composer-reserve, andMessageList's bottom padding and the fade both read that variable.COMPOSER_RESERVE_FALLBACKinstickToBottom.tsis only the pre-measurement value; if the composer's geometry changes, that constant is the one to re-derive (it is the scope row + the one-line textarea + the wrapper padding). - The send control is a pill inside the composer, not a sibling below it.
Implementation: components/common/NotebookCard.tsx.
A card is opaque bg-surface-raised, so hover:bg-surface-hover does not
work on it the way it does on a row: it replaces the fill instead of adding to
it, and in dark mode surface-hover over surface-base lands on almost the
same lightness as surface-raised — an invisible hover. Composite the token as
an overlay so it stays visible in both themes:
<Card className="group relative cursor-pointer overflow-hidden">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-surface-hover opacity-0 transition-opacity group-hover:opacity-100"
/>
…
</Card>That is the only sanctioned use of an extra overlay element; do not reach for it
on rows, menu items or buttons, where hover:bg-surface-hover is still correct.
Implementation: components/ui/empty.tsx. EmptyMedia variant="icon" uses
bg-muted text-muted-foreground, never the accent; the container is a
rounded-lg dashed hairline, no shadow. Empty state copy is chrome, not
content: mark the container select-none so a mouse drag cannot select it.
In a narrow panel the dashed outline is visual noise around an already empty
area, so a panel-level empty state opts out with border-none
(DocumentList). The primitive keeps its dashed hairline — that opt-out is the
exception, and it belongs to the panel that asked for it, not to Empty.
Implementation: components/notebook/NotePanel.tsx, over the four
Tool fills. The Notes panel's creation grid is a 2 x 2 of these,
rendered as the first block inside the panel's content scroll area so a short
window scrolls the cards and the item list together. The panel header keeps the
Notes title only — it carries no tool buttons.
<div className="grid grid-cols-2 gap-2 p-2">
<Button
type="button"
variant="ghost"
className="group relative h-auto w-full overflow-hidden flex-col items-start justify-start gap-2 whitespace-normal rounded-lg border border-border bg-tool-note p-3 text-left hover:bg-tool-note"
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-surface-hover opacity-0 transition-opacity group-hover:opacity-100"
/>
<span className="relative flex flex-col items-start gap-2">
<Icon className="text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{label}</span>
</span>
</Button>
</div>- Density:
p-3card padding;gap-2between icon and label and between cards. Height is content-driven (neverh-9): a wrapped English label ("Generate Anki Cards") sets the row height in a 260px panel. - Radius:
rounded-lg(8px), the container radius — a card is a container even though it is a button. Borderborder border-border; no shadow. - Label never truncates:
whitespace-normaloverridesButton's defaultwhitespace-nowrap, which would clip the label at the panel minimum. The icon sits above a left-aligned label (flex-col items-start), not beside it. - Hover is composited, not swapped. The tool fill is opaque, so
hover:bg-surface-hoverwould replace it; the sanctioned overlay from Raised card adds the translucent fill on top instead, and each card's matchinghover:bg-tool-*keeps its opaque fill instead of theghostvariant's default hover fill. - Focus and disabled come from
Button:focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-backgroundanddisabled:pointer-events-none disabled:opacity-50. - No selected state. These are actions, not a mode switch: pressing one runs and returns (a blank note opens the editor; the other three generate an artifact), so no card stays pressed. That state is unreachable and is recorded here rather than invented.
Implementation: components/ui/field.tsx — Field, FieldSet, FieldLegend,
FieldGroup, FieldContent, FieldLabel, FieldTitle, FieldDescription,
FieldError, FieldSeparator. Prefer these over hand-rolled label/input/error
stacks: FieldError already carries the text-destructive text-sm treatment and
the role="alert" wiring.
- Label: T1
text-sm font-medium(LabelandFieldLabelboth default to it). The label is not a T2 meta line — it names the value below it. - Control:
h-9 rounded-md border border-border bg-surface-base px-3 text-sm, matchingInputand theSelecttrigger. (h-8is thesmbutton height and the compact row height, not an input height.) - Error:
text-sm text-destructive, fromFieldError.
Never signal an error with a red border alone.
- Pick the tiers: which zone is the
surface-sunkenfloor, which panels aresurface-basechrome (rails), and which one is thesurface-raisedcontent. - Pick the panel header height (
h-11) and the row density (h-8/h-9). - Build with the recipes; pick radius from the radius table, never a literal.
- Add the four states to every interactive element.
- Add the empty state using
Emptybefore adding padding or placeholders. - Check both themes, and check that the only accent on the screen is the primary action and the focus ring.
If any step needed a value that is not in this document, add that value here in the same PR.
npm run check:design (scripts/check-design-tokens.mjs) reads the renderer
sources — no build, no dependencies — and fails on:
| Rule | Fails when |
|---|---|
radius |
a radius utility other than rounded-md, rounded-lg, rounded-full and their t/b/l/r forms |
shadow |
an elevation utility other than shadow-elevation, shadow-control, shadow-none |
palette |
a colour utility from the raw Tailwind palette (bg-slate-100, text-gray-500) or an arbitrary colour (bg-[#…]) |
text-level |
alpha stacked on a text level (text-muted-foreground/70) |
chart-scope |
--chart-* as a utility class or as var(--chart-N), outside CHART_SURFACES in the script |
raw-colour-in-style |
a colour function (hsl(, rgb(, oklch( …) inside an inline style object — including hsl(var(--card)) |
css-radius |
a raw CSS border-radius outside 0.375rem / 0.5rem / 9999px |
chart-scope and raw-colour-in-style were added after a rule-shaped defect
reached main twice: chart colours on list rows and a hsl(var(--card)) scroll
fade. Neither was catchable before, because --chart-* is a semantic token rather
than a raw palette colour, and this guard only looked at utility classes — not at
inline style objects. CHART_SURFACES is the only allowlist, and it names one
file: the mind-map node, which is a graph node.
test/designGuard.test.ts covers the allowlist predicate and the rule list. It
exists because the first version of that predicate compared a forward-slash
pattern against a platform path: on Windows it never matched, so the one file it
was written to exempt was the one file it flagged. Linux and macOS CI passed;
the Windows job failed. A test asserts both separator styles now, so the same bug
is caught locally on any platform.
Known gap, stated rather than implied: a raw hex in an inline style
(style={{ background: '#fff' }}) still passes. That is deliberate — the one
current use is the mind-map PNG export, which needs a fixed background rather
than a themed one (MindMapPage.tsx). Anything that wants a literal colour
should be raised as a rule change, not smuggled through.
npm run check:design -- --list prints the rules and the allowlists. The check
runs in the Verify workflow, before the build matrix.
What it does not enforce, and therefore relies on review: the surface ladder (which surface a panel uses), accent discipline, spacing and density, and the four interaction states. Those are the rules a reviewer must hold the line on.
en-US and zh-CN move together (CONTRIBUTING.md), and the check is parity:
neither file may carry a key the other lacks.
One exception: plural forms. English declares key_one / key_other and
Chinese declares key alone with no suffix, which is the correct i18next shape and
not a missing translation. This matters when pruning: a literal search for a key
misses plural forms entirely, because t('ankiCards', { count }) never renders the
string ankiCards_one in source. Prune by hand for those, or keep them
unconditionally.
Two prunes have already found the same artefact: each locale file carried a block of English-only keys for surfaces that do not exist (an editor menubar, a notes / trash / tags feature). They were unreachable, and they made the app read as half-translated. Delete dead copy rather than translating it.