Living document. This file says how MakerLab Tools should look and behave. The change that moves the code onto it — shadcn/ui on Tailwind 4, AI Elements for the chat, the admin IA — is specified in
docs/specs/2026-09-25-ui-system-design.md. The spec says what changes and in what order; this file says what good looks like and outlives the spec. Screenshots live inscreens/;screen.pngis the original concept render and remains the identity reference.Revised 2026-09-25: identity kept; reconciled with Tufte's density rules and the shadcn/AI Elements token mapping; patterns section added (§8). Phase 4: tiles, navigation, tabs and the ⌘K palette refined (§8.2, §8.12); queues and status lines added (§8.13, §8.14). Phase 5a: sort and group-by (§8.4), empty and error pages (§8.9), public pages, the tool page and one-time secrets (§8.16–§8.18), header controls (§8.12). Public polish: the frosted surface (§3, §5), one toolbar for every list with a phone Filters sheet (§8.4), the segmented control (§8.10), ⌘K and a search field on every page and a header that never moves (§8.12), queue controls that never shift (§8.13, §8.14), the dense tool page (§8.17). Phase 5b: the chat as built on AI Elements — the sheet, tool lines, manual citations, the composer (§8.11); focus return for things opened from many places and the frosted sheet and dialog (§3, §8.8); the assistant's launchers (§8.12, §8.15). 2026-09-26: the header fits every width from 390 to 1920 on the design's own breakpoints, and a table wider than its column scrolls inside itself (§6, §8.3, §8.12). 2026-10-05: a phone on its side gets the short bar — one 48px row, the links behind MENU — and nothing sticks there (§6, §8.1, §8.12). 2026-10-06: brand marks, the wordmark and the official logo (§7.1).
A digital extension of the workshop floor: precise, utilitarian, industrial- editorial. Square corners, monospace metadata, high-contrast type, a blueprint grid as the structural guide. It should feel less like a website and more like a well-made instrument panel — and, like an instrument panel, it earns its density: every mark on the screen is a reading somebody needs.
Two influences, one result:
- Architectural Brutalism gives the identity: 0px radii, mono labels, Safety Orange, tonal plates instead of boxes.
- Edward Tufte gives the discipline: maximise data-ink, words and numbers and graphics together, small multiples, sparklines, no chartjunk. Where the two disagree (glass, gradients, glows), Tufte wins; those flourishes are retired (§5).
- Data-ink first. No boxed cells, zebra stripes, vertical rules, shadows or decorative icons in data. Hairline rows at 10% ink; sections by whitespace and tonal shift.
- Numbers right-aligned, tabular, mono. Dates ISO (
2026-09-24). - One accent for action. Safety Orange = the primary action, where you are, and "waiting on you". Never decoration, never a status.
- Status = glyph + word.
● ok ▲ warn ■ bad ○ idle ◆ waiting on you – settled. Colour is never the only signal. - Say the numbers. Headers carry a facts line; menus show counts; a tile says what its number counts.
- Sparklines where a trend matters. Word-sized, bars for daily counts, last value in the accent, described in words for screen readers.
- Small multiples. Parallel things share one layout.
- Dense but calm. 13px tables, ~34px rows, 11px mono labels, 4px grid. Density comes from removing chrome, not shrinking type.
- Honest states. Zero is shown as zero; unknown says "could not be read"; empty names its cause.
- Square, flat, snappy. 0px radius, no shadows, 150–200ms linear motion.
Light is the default (a daily lab reference); dark is a first-class alternate.
The tokens below are CSS variables on :root, swapped under
[data-theme="dark"] and under prefers-color-scheme: dark when no choice is
stored. shadcn's semantic names map onto them (spec §6.1); components never use
hex values.
| Token | Role | Light | Dark | shadcn name |
|---|---|---|---|---|
--background |
page | #F7F4EE warm paper | #0F0F0F | background |
--surface-container-low |
general content areas | #EEE8DE | #131313 | muted |
--surface-container |
cards, sheets, menus | #FFFFFF | #1A1A1A | card, popover |
--surface-container-high |
hover, nested, neutral fill | #E2D8CA | #2A2A2A | secondary, accent |
--on-surface |
text | #171717 | #F5F5F0 | foreground |
--on-surface-muted |
secondary text | #59524A | #A1A1AA | muted-foreground |
--primary |
Safety Orange fills | #FF6B35 | #FF6B35 | primary |
--ink-on-primary |
text on orange | #0F0F0F | #0F0F0F | primary-foreground |
--primary-ink |
orange text, marks, focus | #B8431A | #FF6B35 | primary-ink, ring |
--secondary |
Cornell Crimson, heritage stamp | #B31B1B | #B31B1B | brand |
--outline |
hairlines | #CFC6B8 | #2A2A2A | border |
--outline-strong |
control boundaries (3:1) | #8A8171 | #6E6A64 | input |
--rule |
table row rules | ink 10% | ink 10% | rule |
--surface-frosted |
floating menus over a moving page | card 88% + blur | card 88% + blur | — (FROSTED) |
--status-ok |
● | #2B7549 | #5CC98A | ok |
--status-warn |
▲ | #8A5300 | #E0A23A | warn |
--status-bad |
■, errors, destructive | #B31B1B | #F0645A | bad, destructive |
Why two oranges. Safety Orange on paper is 2.6:1 — fine as a fill behind
black text (6.8:1), illegible as text. So orange fills stay #FF6B35 everywhere,
and orange ink on light surfaces is #B8431A (5.0:1). In dark mode they are the
same colour. On the light muted plate (#EEE8DE) the ink is 4.47:1, a hair
under AA: orange text belongs on the page or a card, not on muted.
Crimson is a stamp, not a signal. It marks heritage (the brand lockup), never
errors in dark mode (2.8:1 there). Errors use --status-bad.
The No-Line rule. Do not separate major page sections with 1px lines; use a
tonal shift (surface-container-low against background). Hairlines are for
rows, controls and the one bar under the admin section nav.
Frosted surface. A panel that floats over the page — the profile menu,
the ⌘K palette, the chat sheet, the Report a correction dialog — is a frosted plate
(FROSTED, src/components/system/frosted.ts): --surface-frosted (the card
colour at 88%) with backdrop-filter: blur(16px) saturate(140%), a hairline
--outline-strong border, no shadow. Where backdrop-filter is unsupported it
is the solid card. At 88% the page behind reads as texture, never as text, so
every text pair on it keeps AA. Use it for nothing else: a card on the page is
a plain plate.
Nested depth. Cards are plates machined out of the background: a
surface-container card on background, a surface-container-high hover. The
blueprint-dot pattern (1px, 32px, 3–8% ink) stays on the page background only.
| Role | Face | Size / line | Use |
|---|---|---|---|
| Display | Space Grotesk 500, uppercase | clamp(42–88px) / 0.92 | Gallery and tool hero only |
| Page title | Space Grotesk 500, uppercase | 26–30px / 1.05 | PageHeader |
| Number | Space Grotesk 500, tabular | 40px / 1 | Tile headline |
| Body | Inter 400 | 14–15px / 1.5 | Prose, ledes |
| Table | Inter 400 | 13px / 1.35 | Cells, review values |
| Label | JetBrains Mono 500, uppercase, 0.08em | 11px | Eyebrows, buttons, glyph words, nav |
| Micro | JetBrains Mono, uppercase | 10px | Column heads, captions |
Seven steps. Metadata is always mono ("UI plumbing": tags, timestamps,
column heads, the // CORNELL TECH lockup, // ADMIN / INVENTORY crumbs). The
fonts are self-hosted with next/font/local; a design that only works where the
fonts happen to be installed is not a design.
- Radius: 0. Every corner is 90°. (Enforced globally.)
- Shadows: none. Depth is tonal stacking. The old "ambient 64px glow" for modals is retired; a sheet or dialog sits on a 28% scrim instead.
- Retired flourishes: decorative glass, gradients on CTAs, pulsing status
dots. They cost ink and say nothing. The one exception is functional: a
floating menu over a moving page is frosted (§3), so the text under it
cannot be read through it. The crosshair corners
(
TechnicalFrame) stay on the gallery hero only. - Focus: a 2px
--primary-inkoutline, offset 2px, on:focus-visible, on everything interactive. Neveroutline: nonewithout it. - Motion: 150–200ms, linear or ease-in, colour and opacity only; nothing
bounces;
prefers-reduced-motionturns animation off.
- 4px grid: 4 · 8 · 12 · 16 · 24 · 32 · 48. Snap to it.
- Page gutter 16px on a phone, 32px from
sm. No horizontal page scroll, ever; a wide thing (the section bar, a code block, a table wider than its column) scrolls inside itself.e2e/header-stability.spec.tschecks the main routes at 390, 1024 and 1440. - Breakpoints:
sm 640 · md 768 · lg 1024 · xl 1280. Nothing else. One height query beside them: the short viewport, landscape and at most 500px tall — a phone on its side — for the header alone (§8.12). - Controls: 32px default, 28px in toolbars, 24px for row actions; touch rows ≥ 40px.
lucide-react, 1.5–2px stroke, 14–16px, currentColor. Icons label surfaces
and controls; they are not decoration and never appear inside data cells, where
a status glyph (● ▲ ■ ○ ◆ –) does the job in a tenth of the ink.
Two marks, two jobs (identity spec, amendment 2026-10-06):
| Mark | File | Where |
|---|---|---|
| MakerLAB wordmark (striped "Maker", bold "LAB") | public/makerlab-wordmark.png (siteConfig.wordmark) |
The header only (.brand-wordmark) |
| Official logo: the Cornell seal, "CORNELL TECH", "MakerLAB" | public/brand/cornell-tech-makerlab-logo.svg (siteConfig.logo) and its PNG (siteConfig.logoPng) |
The kiosk's top bar, the footer and About (BrandLogo); the link-preview card and QR labels draw the PNG |
- Both are one colour, drawn as a CSS mask in the text colour, so one file serves the light and dark themes. Never fill either with orange or crimson, stretch it, crop it or redraw it.
- The official logo is decoration beside words that name the lab
(
aria-hidden). Give it a height (h-10in the footer,h-14 sm:h-16on About); the width follows at its own proportions. cornell-tech-makerlab-logo-white.svgis for dark backgrounds outside the app, such as slides or a poster. Inside the app the mask does that job.- The old "MakerLAB@CORNELL TECH" files (
public/makerlab-logo-transparent.png,public/makerlab-logo-blackonly.png) are retired.
Each pattern names its component (src/components/system/*, ui/*,
ai-elements/*), when to use it, and what not to do.
// ADMIN / KEEP DATA FRESH crumb (mono, the // in accent) → title → one-line
lede → facts line (101 TOOLS · 86 PUBLISHED · 11 DRAFTS · 97 NEED ATTENTION)
→ actions on the right.
- Use on every working page (admin, account, projects, mcp).
- Don't stack a display heading above it (the old 88px "ADMIN"); don't put more than one filled button in the actions.
- Gallery and tool page keep their display heroes.
- Sticky chrome never hides what it scrolls to. The nav and status strip
stick (
--sticky-chrome-height); inside the admin, every heading, link, button and anchor target hasscroll-margin-topof that height plus 16px, so focusing the header's actions with Tab, or following a#link, lands them below the strip instead of under it. Content scrolled past by hand goes under the opaque strip, which sits above it (z-index: 20); nothing in the admin sets a z-index that competes with it. On a short viewport nothing sticks and--sticky-chrome-heightis 0, so the same rules hold (§8.12).
Whole tile is the link. Every tile has the same anatomy, in the same places:
label row (mono title left, icon right) → headline (40px tabular
number and the words for what it counts, on one baseline) → facts table →
optional 30-day sparkline with a 30 DAYS caption, pinned to the tile's
foot. Accent left border and accent number when the headline is work
waiting for a person; muted number when it is 0. Tiles side by side read as
small multiples.
- Use for the admin home: one tile per surface, grouped by job, only the
surfaces the viewer may open (
surfacesFor), each counted by its own loader so one unreadable table costs one tile. - Facts are a three-column table (
glyph | label | value), the same on every row: a fixed glyph column (empty when the row has no glyph), the label, and the number right-aligned in one tabular mono column. So every label starts at the same x and every number ends at the same x, glyph or not. Never indent a row with padding or an invisible glyph — the column is reserved on every row. A zero is a muted0, so the non-zero counts stand out. The table is at most 24rem wide, so on a wide tile the numbers stay near their labels. - Groups are bands (owner, 2026-09-25). Each group is a heading over a row
of cells, spanning as many grid columns as it has cells; groups flow into the
home's grid (
TileGrid: one column on a phone, two fromsm, four fromxl) and each is a CSS subgrid, so the groups in one band share their heading row and their tile row. At 1440 that is two rectangular bands —ADD EQUIPMENT | KEEP DATA FRESH(1 + 3) overQUEUES | PEOPLE & SETTINGS(3 + 1) — every tile in a band the same height, every heading on one line, no column left empty under a short group. At two columns a group takes the full width and a group's last odd cell spans both columns. One column on a phone, in the same order. - Half tiles pair. A surface with only a number or a state — People, the
Notion mirror, Projects with nothing waiting, a count that could not be read
— is a half tile (
size="half": 28px number, no trend). Consecutive half tiles in a group share one cell, stacked (side by side when the cell spans two columns), so two stand where one full tile would. A lone half tile takes a cell of its own and stretches to the row's edges. - Say what the number counts, in the unit and the facts, whenever another number on screen could seem to contradict it: the Inventory tile's "of 104 tools need attention" sits beside "Published — in the catalog 100" and "Drafts and archived 4", because the status strip's "100 tools in inventory" counts published tools only.
- Sparklines are readable at a glance: bars at 75% ink, a zero day a 2px stub at 40%, today in the accent ink.
- The link's name is the title; the number and facts are its description, so a links list reads "Inventory", not a paragraph.
- Accent means waiting work above zero — open tickets, researched items, new reports. A stock (tools needing attention, people) is ink, however big.
- Don't show a count that failed as 0 (say "Could not be read", and drop the facts and trend with it); don't add a sparkline to a stock that doesn't change daily; don't use tiles as a gallery; don't put the tile's counts in the section bar.
13px, ~34px rows, hairline row rules, no vertical rules, no zebra. Sticky header
(mono 10px). Numeric columns right-aligned tabular mono. Status column is
StatusGlyph. A 24px thumbnail at most. The row's reasons-for-attention as
words in warn ink (▲ No photo · No manual · 1 open). Row actions are
ghost xs and brighten on hover/focus. Keyboard: ↑↓/j k move, Space/x select,
Enter opens. Selection shows a sticky bulk bar (2 TOOLS SELECTED · [REFRESH RESEARCH (2)] · CLEAR). On a phone: a two-line list item per row.
- Use for any list of records a person scans, sorts or selects.
- Sort state is on the
th(aria-sort), never on the button inside it. The row's name is itsth scope="row". - Zero is a muted
0; a dash is "not applicable yet" (an import not read into rows). Neither is ever "None". - Selection survives filtering, and says so:
3 TOOLS SELECTED · 1 NOT SHOWN BY THE FILTERS. Select-all takes the rows shown. - Sticky header only when the page scrolls the table, on the page
background; a short table on a card is not sticky. A table wider than
its column scrolls sideways inside its own frame, never the page (the
inventory at 1024px); its header is sticky only while the table fits, which
DataTablemeasures — a header in a scroll box cannot stick to the page. The frame starts as a scroll box, so the first paint never widens the page, and nothing moves when it opens up. A short, narrow table (seven rows, two columns) may stay a table on a phone and scroll inside itself; anything longer or wider gets the two-line list. - Phone: one of the two, not both. The list and the table are never both in the document once the browser can say which shows, so a row's controls (a role select, a ban button) exist once.
- A table in a panel follows the panel. In the chat (360–440px on any
screen) the table's own width decides (
layout="container"), so the intake table is a list there on a 1440px desktop too, with its own select-all. - A row that cannot be selected says why: its box is disabled and described by the reason (an undecided duplicate); select-all skips it.
- Don't render a table as cards on a phone; don't box cells; don't put more than one line of secondary text in a cell; don't hide the count.
One toolbar for every list (the gallery, the inventory, people, import review, the queues, manuals), two rows:
[ ⌕ Search …………………………………………………………………… ] SHOWING 21 OF 101
[STATUS ▾] [CATEGORY ▾] [LOCATION ▾] [× CLEAR] [GROUP BY ▾] [▥ COLUMNS] [SORT ▾] [▦|☰]
Row 1 is the search across the page with the count as quiet mono text at its
end. Row 2 is the facets and Clear on the left; secondary (Group by, Columns)
and end (Sort, the view switch) on the right. With the default state nothing
wraps from 1024 to 1440; set values may push the right group under, right-aligned.
On a phone: the search, then one row — Filters (with how many facets are
set) and end — and the count on a small line under it; Filters opens a
Sheet holding the facets, Group by, Columns, Clear and "Show 12 results". A
facet menu lists values with the count each would leave, disables values
that leave nothing, and filters are written to the URL so a view is a link.
- A chosen facet names its value on the button (
STATE Draft ▾, read as "State: Draft"); its border moves to the accent ink. - A short fixed choice inside a row or a form (a role) is a
NativeSelect— a real<select>, the phone's own picker — bounded by--outline-stronglikeInput. A choice that has only one sensible answer is not a choice: say it as text (a token's expiry, "90 days, one semester"). - Sort and Group by (
ChoiceMenu) sit at the end of the bar and look like facets, but narrow nothing: no counts, no "Any", always a value; the value is in the accent ink only when it is not the default. The default sort is the list's own order ("Best match" while searching). - Grouped, a list is labelled sections in order — "not recorded" last —
each heading sticky under the top bar (mono label, the count at the end:
3D PRINTING › FDM ······ 4 TOOLS). Every section has the same layout and, as tables, the same column widths (small multiples); the sort applies inside each section. Items under a group heading drop a heading level. - Everything is in the URL — search, facets, view, sort, group — even on a
cached page (
useUrlSearch: defaults on the server, the URL after hydration). - Don't use native selects for facets; don't filter server-side on each keystroke; don't show an empty table without naming the filter; don't let the bar's end group push the page sideways on a phone (it wraps).
| Glyph | Tone | Means |
|---|---|---|
| ● | ok | settled and good: published, available, searchable, verified |
| ▲ | warn | needs a person soon: never reviewed, no manual, medium |
| ■ | bad | broken or urgent: failed, out of service, critical, quote not found |
| ○ | idle | nothing to do / not started: draft, queued, none |
| ◆ | active (accent) | waiting on you: proposed, researched, new |
| – | muted | archived, retired, decided |
Always with the word (visible, or sr-only in compact cells). Don't use a
coloured dot alone, a pill background, or Badge for status.
Glyphs only where they carry meaning (owner, 2026-09-25). A glyph is a flag for the eye; one on every row is noise, and a hollow ○ beside "Running" reads as a spinner. In a tile's facts, and anywhere a count is listed:
- ▲ warn and ■ bad mark a row only when its count is non-zero (No photo 3, High or critical 5, Failed 1); ◆ active marks work waiting on you when non-zero (Identified, not researched 2). A row that says it could not be read is ■ bad.
- A zero or neutral row has no glyph — the reserved glyph column stays
empty, and a zero is a muted
0. Neutral means a stock or a state that asks nothing of anyone: Published, Handled, Manuals on file. - In progress has no glyph. Running, Researching and a ticket in progress are being handled; the label says so, and a mark would claim they need someone. There is no in-progress tone, and ○ idle / ● ok are not used in facts (they remain for status cells, where every row has a status).
- The rule is enforced in one place (
factGlyphinsystem/Tile.tsx), so a caller cannot put a glyph on a zero.
One decision per card: LABEL marks [ACCEPT] [REJECT] on one line; NOW (muted)
│ PROPOSED (ink, orange rule) side by side, stacked on a phone; SOURCES as
verbatim quotes with host and ● verified / ■ not found; a note when it cannot be
decided here. Safety cards carry a bad-tone left rule. Decided cards fade.
- Use for refresh proposals, chat proposals, intake records, import rows — anything a person accepts or rejects.
- A new record has no NOW. The intake approve page is one card per field
group (names, safety and training first, description, where it goes, links),
all proposal, editable in place through
Field. A field waiting on a person's decision (training "staff to confirm") carries a warn rule and says why in its hint. - Tones: safety = bad rule;
warn= a decision still owed (low confidence, an undecided duplicate); settled = faded. The mono meta line under the label says who and when; a failed run's reason is aReviewDiagnosis(mono, bad rule), shown as recorded. - Duplicates are a
DuplicateChoice: ▲ the match in words, then the choices as a radio group of small buttons. Choosing saves, so the arrow keys never choose — each option is its own tab stop. A decision that cannot be changed here is shown as ● words, not controls. - Don't render values as HTML/Markdown (they come from web pages); don't offer Accept on unverified evidence; don't box each card (a rule is enough); don't pass a border colour to a card (it overrides the tone's rule).
Label above (mono 10px uppercase), control (hairline box, --outline-strong,
square), hint below (12px muted), error below in bad. One filled primary button
at the end; Cancel is quiet. A form that belongs to the page is inline; a
decision gets a Dialog.
- Don't rely on placeholder as label; don't disable Submit without saying why.
Dialog for a decision (confirm, report a correction, research again); Sheet
(side panel, full screen on a phone) for a workspace (tool editor, chat). Both
trap focus, close on Escape and return focus. Scrim 28% ink, no shadow; the
plate is frosted (§3). Opening one adds no scrollbar compensation — the
root always keeps its scrollbar, so nothing behind it moves.
- Focus goes back to what opened it. One opener is a Radix trigger
(
DialogTrigger asChild), and Radix returns focus there. A panel opened from many places (the chat: its button, the section bar, ⌘K, Report, the QR notice) remembers the focused element when it opens and gives focus back when it closes — Radix alone would return it to a trigger it never had. - Initial focus is where the work starts: the chat's composer where there is a keyboard; the sheet itself on touch, so a phone does not raise its keyboard over what the panel says first.
- Don't mark something
aria-modalthat isn't; don't nest dialogs.
- Empty: one sentence naming what is missing and why, plus the next action ("No tools match State: Archived. [Clear filters]").
- Loading: skeleton rows in the table's shape; a spinner only inline.
- Error: say what failed and that nothing was lost; failing toward stale is
shown, not hidden (Article 4). Row actions report inline (
RowStatus). - Pages: a 404 and an error boundary are pages in the system's frame
(
PublicPage+EmptyState; the error onetone="bad"with Try again), never the framework's bare default. - A missing image is an empty plate with the thing's initials in mono, never the browser's broken-image icon.
| Variant | Look | Use |
|---|---|---|
default |
orange fill, #0F0F0F mono label | the one primary action on a surface |
quiet |
hairline box, ink label | everything else (default) |
outline |
orange-ink hairline + label | a secondary call to action on public pages |
ghost |
label only, muted | row actions, toolbars, Clear |
destructive |
bad-tone hairline + label, at rest | discard, delete, ban — confirm inline |
link |
orange-ink underlined | inline navigation |
Segmented control — SegmentedControl (system/SegmentedControl.tsx).
Two to four mutually exclusive views of the same thing (the gallery's Grid |
Table): every segment always outlined in the control-boundary ink, equal
size, shared borders, the chosen one filled in ink (foreground on
background), not orange — it is a state, not the action. A named group of
buttons with aria-pressed, each its own tab stop, the focus outline above its
neighbours. Don't draw only the chosen half (the other looks missing), and
don't use it for navigation between pages (LinkTabs).
One-shot actions keep their state in the button — AsyncButton
(src/components/system/AsyncButton.tsx). For an action that runs once and is
over — Refresh catalog, Looks good, Re-process — the button is the status:
idle (the label) → pending (a small spinner over the label, which stays in
place invisible so the width never changes; disabled, aria-busy) → done
(a check and DONE for 1.5 s, announced through a live region, then the label
again) → error (the label again, with the reason on a RowStatus line beside
it). No sentence left standing next to the button, no toast. A button's label
names its object when a neighbour could be confused with it: Refresh
catalog (the cache) is not Refresh research (the surface).
Docked side sheet from the inline end (440px; full screen on a phone),
frosted, MAKERLAB ASSISTANT as a mono label with New chat and Close at the
end of its header row. Conversation (a role="log", so replies are read
out; sticks to the bottom while a reply streams; a square "scroll to latest"
button at the inline end) → Message: assistant text is unboxed prose; the
user's turn is a square secondary block; cards (ReviewCard, intake
table, import card) span the full width. Suggestions stack as sentences on
the empty state, each with a small icon. PromptInput at the foot:
attachments above the text, attach and dictate on the left, Send — the
sheet's one filled button — on the right; Enter sends, Shift+Enter is a new
line. Under it, always, one quiet line in the form-hint style (§8.7: 12px,
muted): "MakerLAB AI can make mistakes. Check anything safety-related with
staff." It is the text field's accessible description.
TO REPLACE THE RESIN TANK ON THE FORM 4:
1. Lift the front edge and slide it out (Replacing the resin tank [P. 42]).
◌ 📖 Searching the Prusa MK4S manual…
2 MANUAL PAGES ▾
- Markdown is the page's. The answer's lists, tables, code and headings are
drawn by the same rules as a tool description (
MARKDOWN_PROSE); raw HTML from the model is never rendered — a tag shows as the text it is. - A running tool is one line (
Toolheader): a small spinner in the accent ink and the words for what it is doing, in the order the turn does it. A finished call leaves no line; its result is the answer. - Evidence, inline and listed. A link to a page the manual search returned
is an
InlineCitation: the linked words, then a monoP. 42mark that opens the manual at that page, with a card (hover or focus) naming the manual, section and passage. Under the answer,SOURCESlists the pages it cited, one ruled row each. A page that was read but not cited is not listed. - Where it opens. Public pages: a square Safety Orange
>_block at the inline-end corner. Admin pages: no floating block (it collided with bulk bars) — the section bar'sASK THE ASSISTANTand ⌘K. - Don't use rounded bubbles or avatars; don't strip citations; don't show a tool's JSON to a student; don't open a floating card that can't fit the cards it contains; don't push the page aside under the sheet (the page would reflow — a layout shift).
- Public:
TOOLS · PROJECTS · ABOUT · REPORTin the top bar, then[⌕ Search tools… ⌘K]beside the language and theme controls (an icon button on a phone); status strip below (86 TOOLS IN INVENTORY · LAB OPEN 9AM–9PM). Report is the bar's one accent; Sign in is a hairline box in ink; the local-only Sign in as (dev) is muted and dashed and shortens toDEVbelowxl. The profile menu is a frosted plate (§3). - The bar fits every width, on the design's breakpoints. From
xlit is one row with the// CORNELL TECHtagline. Fromlgtoxlit is still one row, tighter (24px between groups, 20px between links), without the tagline, with a narrower search field. Belowlgit is the compact bar: brand and controls, then the links across the full width. No label in the one-row bar wraps, in any of the twelve languages. The language control is a 32px square showing its文Aglyph at every width, like the theme toggle (the open list names every language): a select is as wide as its longest option, and the name pushed the bar past 1280px in Spanish and Russian. (The compact switch used to sit at 860px, off the breakpoint scale, and from there to ~1180px the brand ran into the links and Sign in wrapped.) - The bar never moves. Every control has the same box on every page: the
page always carries its scrollbar (
overflow-y: scrollon the root — a styled scrollbar takes space, and Chromium ignoresscrollbar-gutterfor one), the active link changes colour and underline only, never weight or size.e2e/header-stability.spec.tsmeasures it with scrollbars shown, at 390, 1024, 1280 and 1440, and at 844 × 390. - A phone on its side gets the short bar (amendment 2026-10-05). The
owner, on a phone held sideways: the bar and the strip took most of the
screen. They took 159px of 390 — the compact bar's two rows and the strip —
and the bar stayed pinned. On a short viewport (landscape, at most 500px
tall; §6) the bar is one 48px row: the wordmark at 24px with the name beside
it (the name only from
md), then MENU, the account control, and the utility controls with the search field down to its icon. MENU is a disclosure button (aria-expanded, not amenurole): the links and Report in a frosted plate under it, one 40px row each, the current page marked by the accent rule at its start; Tab walks them, Escape closes it and returns focus to MENU, and following a link, pressing outside, moving focus away or turning the phone upright closes it. Here, and only here, the bar does move: nothing sticks. The bar and the strip scroll away with the page,--sticky-chrome-heightis 0, and section headings stick at the top of the screen — content is what a short screen is for. Every other width keeps its bar exactly as above, links centred fromlg; the tool page keeps its breadcrumb. - Admin: a section bar under the top bar on every admin page:
OVERVIEW ┃ INTAKE ┃ INVENTORY · REFRESH · MANUALS ┃ MAINTENANCE · CORRECTIONS · PROJECTS ┃ PEOPLE · NOTION MIRROR ┃ ▭ ASK THE ASSISTANT(the icon alone on a phone, the words still its name), dividers between jobs, the most specific current page underlined in the accent (an item's page marks its surface), only surfaces the viewer's permissions open, no counts. On a phone it scrolls inside itself. The one list behind the bar, the home and the palette issrc/lib/admin/surfaces.ts— a page added there appears in all three. - Admin navigation never waits on a hole. A click in the section bar, a
tab or a row link commits at once: the section bar stays, the page area
shows the one
EmptyStateloading line (AdminPageLoading), and the page streams into it. Every admin page reads the request at its root, so its prefetched segment is a shell with a dynamic hole; without a Suspense boundary inside the new segment that hole held the previous page on screen and, in production (Next 16.1 with its bundled React 19.3 canary,cacheComponents), the transition was sometimes never retried — the click did nothing until something else re-rendered the page. So every folder underapp/adminwith a page below it has aloading.tsx(enforced byapp/admin/loading-boundaries.test.ts), ande2e/admin-client-navigation.spec.tsclicks through every section against the production build. - Page header:
// ADMIN / GROUP(plus/ SURFACEas a link on an item's page) → title → lede → facts line → actions (AdminPageHeader). - Tabs that are pages (
LinkTabs): Intake'sQUEUE · IMPORTSunder one header. Links witharia-current, notrole="tab"— each tab is a URL; a tab the viewer cannot open is not shown. - One surface per job's page, actions in the header. Adding equipment is
one surface, Intake: importing a list is its header action (
IMPORT A LIST), not a surface, tile or palette entry of its own. A page's primary action sits in its header's actions — Inventory'sADD INVENTORY, Refresh'sREFRESH RESEARCH…(a dialog that picks the tools), Intake'sIMPORT A LIST. - ⌘K palette (
palette/CommandPalette, on every page from the header):PAGES(Tools, Projects, About, MCP),CATEGORIES(the gallery filtered to one, with its count),ADMIN PAGESandACTIONSonly for a role that opens them,TOOLS(display name, the official name muted, the category orDRAFTat the end). Every word typed must match; nothing fuzzy. A frosted plate./jumps to the page's own filter search.ASSISTANTis the last group, for everybody: "Ask the assistant" with nothing typed, "Ask the assistant: “…”" with the query as the first message — last, so Enter still opens the first tool or page that matches, and there even when nothing does. - Don't make a page reachable only through a hub; don't list a surface that
will refuse the viewer; don't put waiting counts in the bar; don't use
role="tab"for navigation.
Maintenance, corrections, projects and intake are one layout: FilterBar
(search + facets counted over every item) → the open work, one ReviewCard
each (status and priority as glyphs, who and when on the meta line, the
controls under the words) → ▸ SHOW 5 RESOLVED AND CLOSED, a disclosure
holding the settled work, filtered too.
- Open work is the page; settled is one click away, never gone.
- A filter that empties the open list names itself (
Nothing here matches "belt" · Priority: High) with Clear. An empty queue says what fills it and shows no filter bar. - Typed fields are a button until wanted. A card's click-to-save controls
(status, priority, assignee) are always there; a field somebody writes a
sentence into (a ticket's resolution) is
ADD RESOLUTION, or the saved words on two clamped lines withEDIT RESOLUTION, until pressed — then the box opens, focused, with Save and Cancel; Escape cancels; focus returns to the button. - The control row never shifts.
STATUS [Open ▾] PRIORITY [High ▾] ASSIGNED TO [Nobody ▾] SAVED ··········· [ADD RESOLUTION]— inline mono labels,NativeSelectandButtonat the same 28px, "Saving… / Saved" in a reservedSaveSlot, the resolution button at the row's end. The editor opens in its own full-width slot below the row (Save and Cancel left, in a row of their own); a refusal's sentence is the last line. A control whose label changes (Publish / Unpublish) keeps the wider label's width. - Cards may be grouped (intake by batch); the layout stays the same.
- Don't split a queue into Open / Settled tabs; don't box the cards; don't hide the controls behind a row click on a phone.
One inline line for an action's outcome, in the muted, warn or bad ink:
Saving… → Saved, a warning when a change landed minus a guarantee, the
refusal's reason. Beside a save-on-click control the short words go in a
reserved SaveSlot (so nothing moves when "Saved" appears) and the
sentences on their own RowStatus line. Always in the DOM as a live region, empty until it speaks.
"Saved" only for a write that landed: a control that saves on change is
disabled until the page has hydrated (useHydrated), a change to the value it
already holds sends nothing, and an answer that does not match the choice is a
failure, not a success.
No toasts (owner decision). A page that could not read its data says so with
EmptyState tone="bad", never an empty list.
- 390px is a first-class width: tables become two-line lists, before/after stacks, bars scroll inside themselves, sheets go full screen.
- 16px gutters; no horizontal page scroll; touch rows ≥ 40px.
- The chat launcher must never cover the one action on screen: it is not drawn on admin pages (where bulk bars live; the section bar and ⌘K open the chat), and on a phone the chat is the whole screen, safe areas respected.
Projects, about, /mcp, account and OAuth pages share one frame: a reading
column on the page background (880px; 560px for a single decision; the page
width for a grid of cards), PageHeader as the h1 (// MCP SERVER crumb,
title, lede, facts, the one filled action), then sections separated by
whitespace — an h2 in Space Grotesk and an optional muted lede.
- Prose is 15px at a readable measure, links in the accent ink.
- Markdown written by people or research renders through
Markdown(GFM, no raw HTML), in the page's tokens — never the chat's styles. - Don't put a page in a rounded card, stack a display heading on a working page, or box each section.
Facts, not panels, and only the facts the tool has: crumb // TOOLS › FORM 4
→ hero (a small image plate, ~16rem, beside the display title, the
official name in mono, one status line of glyphs and words, the lab's
Lab notes when it has any — an ink start rule, a mono LAB NOTES · FROM THE LAB'S STAFF label, one note a sentence and several a list — then the
description, Safety doc / SOP) → two columns on desktop: Safety, the
one tinted section (bad start rule, compact label/value rows), then Details
as a dense <dl> on the left; Documents & resources as a ruled list (the
kind as a mono word, the manual's Contents under it) and Physical machines
(DataTable) on the right → Maintenance history (date, status glyph,
title, unit — never who reported it) and Built with this across the page.
One column on a phone.
- Say a fact once: no "at a glance" card repeating the specs.
- Empty is absent: a row or section with nothing to say is not drawn — no "Contact MakerLab staff", no "No documents linked yet" box, no empty history. Safety always says what to do, falling back to the lab's standing guidance.
- Don't colour a chip for status (use the glyph line) or tint any section but Safety.
A secret shown once (a personal access token) appears in the same column as the form that made it, on a warn-ruled plate: ▲ "Save this token now. You won't be able to see or copy it again after you leave this page." above the secret, the secret in a copyable block, what to do next (an environment variable, then the client), and the same sentence again beside the button that dismisses it. Nothing else on the page holds the secret, and nothing the page offers to copy — a setup prompt for an assistant included — contains it: prompts name the environment variable instead.
- Copy setup prompt for your AI: a short prompt, one Copy button, sign-in
first; a token only from
MAKERLAB_MCP_TOKEN, never pasted into a chat.
Do
- Snap to the 4px grid and the seven type steps.
- Put the label top-left and the value bottom-right: a technical-document flow.
- Say the numbers in words beside the graphics.
- Use one accent, once per surface, for the action.
Don't
- Round a corner, cast a shadow, add a gradient, blur glass.
- Use Crimson for large surfaces or for errors in dark mode.
- Use orange text on paper (use
--primary-ink). - Encode status in colour alone.
- Invent a new table, badge, button or dialog: use the system component, or add
one to
src/components/systemwhen a pattern appears on a second surface.






