# The admin design language The rules every admin surface follows. An admin built one widget at a time reads like it: five kinds of pill in one card, three left edges on one form, labels quieter than the chips they name, a thousand 1px lines on pure black going mushy at high DPI. Each rule below rules out one class of that. When a surface is built or touched, it follows this document; when a rule has to bend, this document gets amended rather than silently ignored. The public-facing app shares the brand tokens but not the composition rules: it is a reading surface, not an operating one. ## What this admin is not allowed to know about you **Ambry is self-hosted open source, and whoever is running it is not whoever wrote it.** Nothing rendered may assume otherwise. - **No placeholder, example or copy drawn from what one operator happens to be doing.** A real forthcoming title as placeholder text is accurate for a week and belongs to one person. A placeholder either teaches the _format_ of the field or it is absent; a worked example that has to be concrete (the naming template's preview) uses a book, an author and a narrator any library plausibly holds. - **No policy stated as a preference.** A feature that grades one operator's arrangement as right and another's as wrong is a feature that is wrong: all four file-placement policies are valid and mixable, so the file audit describes the distribution rather than scoring it. - **No copy that reads as the author talking to themselves.** No "we", no asides about what is planned, no reassurance that something is normal, and no archaeology: this document and the code both state the rule and the consequence that makes it right, never the history of arriving at it. ## Comments earn their place, or they go The code says what it does. A comment is for what the code cannot say: the decision behind it, the measurement that settles it, the failure it exists to prevent. A comment that narrates the line under it is noise, and a run of them is worse than noise, because it buries the two that were load-bearing. ## 0. One theme **The whole app is dark-only, by decision**: one theme, half the styling overhead. Every color class is an **absolute value** — there are no `dark:` variants anywhere and no theme machinery; both root layouts carry `color-scheme: dark` so native controls follow. Don't write `dark:` prefixes: Tailwind's dark mode is set to the class strategy with no `.dark` element in the tree, so any `dark:` class that sneaks in is a visible no-op instead of an OS-dependent surprise. ## 1. Elevation, not borders Hairlines on pure black read as harsh and "mushy" at high DPI, where horizontal and vertical 1px lines don't even render the same weight. So the admin separates surfaces by **background contrast and spacing**, and (almost) never by border: | Level | Color | What sits on it | | ------ | ---------------------------------------- | ------------------------------------------- | | Ground | `zinc-950` (body) | page titles, section headings, helper prose | | Block | `zinc-900`, `rounded-lg p-4` | one decision, one queue item, one card | | Well | `zinc-800` (or `/60`) | inputs, evidence rows, chips, nested panes | | Hover | one step lighter (`white/5`, `zinc-700`) | interactive wells | Rules that fall out of this: - **No 1px container borders.** A box is a fill. If an edge must be drawn, it is ≥2px: a `ring-2 ring-inset` for selection, a `border-l-4` rail for state, a `border-l-2` indent guide. Dashed 1px borders are allowed only on ghost escape hatches, where looking faint is the point. The header's scroll-spy seam is the other exception, and it is not a container edge — see §3. - **Dashed means "drop here" or "not chosen" — never "here is a fact".** Dropzones and ghost hatches wear dashes; a read-only file list is a plain card (`<.file_list>`: mono, muted, the common directory printed once). A fact display in the dropzone costume promises a drop it cannot take. - **Section headings carry no underline.** `text-xl font-bold text-zinc-100` plus the 56px section gap is the divider. - **Inputs are filled, not outlined**: `bg-zinc-800`, transparent border, soft brand focus ring (`focus:ring-brand-dark/20`). - **Shadows are for layers that float** (sticky bars, menus), not for cards. ## 2. State rails — where the decisions are Every **decision block** (a `decision_row`, a series row, the work/recording identity clusters, the library-root chooser, an inbox queue item) wears a `border-l-4` rail that says its state at a scroll: | Rail | Meaning | Condition | | ---------------------- | -------------------------------------------------------- | --------------------------------------- | | `border-red-400/70` | blocked — nothing proposed a value, or the files changed | not settled, and nothing to choose from | | `border-amber-400/70` | the machine couldn't settle it; look here | not settled | | `border-blue-400/70` | the machine settled it and nobody has looked | settled, not curated | | `border-brand-dark/60` | you've been here | curated | **Four tiers, not two, and the fourth is the point.** Lime-versus-amber answers "is this settled" and throws away the more useful question, which is _did a human ever look at this_. A freshly matched import is entirely blue, and greens accumulate as the operator works through it, so the form is a map of where they have been. Amber and red are what still need them; **blue is a legitimate end state**, because the machine was confident and you do not have to look if you do not want to. The goal of an import is no amber and no red, never "all green", or every import becomes a chore of clicking chips that were already right. **Green is one-way.** It records that a human looked, which is a fact about history and cannot be un-done; a control that turned it back to blue would assert something false. What must always exist instead is a way back to the machine's _value_ — reverting a field leaves it green, because you looked and decided the machine was right, which is exactly the distinction the tier buys. Green is set by changing a decision, never by merely touching the UI: unfolding something, scrolling, or opening a form marks nothing. **A card whose children disagree reads worst-first**: red if any child is red, else amber if any is amber, else green only if _every_ child is green, else blue. That keeps a scroll honest — you look for amber, then decide whether you care about blue — and it is what makes an item's progress from all-blue to all-green mean anything. Evidence blocks (record lists) get no rail — they inform decisions, they aren't one. The records _cards_ on the import form are decision blocks, not bare evidence (each carries the level's identity state), and they wear the same four tiers as everything else. One card must never rail a state another card shows in a different colour. The rail is the _only_ thing that encodes settledness at block level; don't also dim, collapse, or re-order. **One vocabulary, and the rail is it.** Two settledness systems with different words for the same states (per-decision "settled" and "needs confirming", per-level "confirmed", "trusted" and "unsure") describe one thing twice, and a Confirm button beside them changes nothing but its own badge. Identity and field decisions are the same shape (options, a choice, settled, touched-by-a-human), so they wear one encoding. **Importing is the confirmation**: pressing Add accepts every blue on the item. **Evidence before the decisions it feeds, within a section too.** §9 puts the evidence panel above the edit forms, and the import form's sections follow the same order internally: the records card first, then the identity and field decisions below it. A section that asks "already have it?" above its records has the operator deciding before seeing. ## 2b. A finished row shows the result, not the process A queue row is about the work it is asking for, so while the item is open it wears the workflow: what matched, how sure the machine was, which files it came from, what is still outstanding. **The moment the workflow ends, all of that becomes history and the row becomes the thing it produced.** An imported inbox row is the audiobook — cover, title, series, credits, the library's own state badges — in the same shape the audiobooks list draws, because it is the same thing; the release folder and the match tiers are on the item's form, one worded action away. Two things follow. **A settled row is dated by the moment it settled**, not by the moment it was found: "Found" is the least interesting date a finished row has. And **the status badge goes**, because the row's shape and its date already say it — this is §2's "one badge, and it says what varies" arriving at its end state, where what varies is the _result's_ state (missing files, a recording still processing) rather than the item's. The row must still be able to state that its result is gone: a record that outlives the thing it describes says so plainly rather than falling back to the process view, which would describe a decision whose outcome no longer exists. **And that its result is no longer its own.** A third state sits between those two: the outcome still exists, but it belongs to a later record — an import replaced by another import of the same audiobook. Wearing the result there is the worst of the three, because it is not merely stale, it is _someone else's_, and it keeps updating: the row would go on showing a cover, credits and state badges that another record is responsible for. So the row goes back to describing **itself** — the release it holds, its own files, the date it landed — and carries a badge for the one thing that shape cannot say, which is that this was imported and then superseded. This is the documented exception to "the status badge goes": the badge is what stops an import-shaped row from reading as work still to do. It does not offer the result. A way to the record that _replaced_ it is the honest link, and the way to the audiobook is that record's to give. ## 3. Geometry **Two left edges, never three.** Every container has exactly two x-positions: the **box edge** (cards, inputs, record rows, buttons) and the **text rail** — box edge + 12px (`pl-3`), matching the text inset inside the inputs — where every bare-text element sits (labels, hints, helper prose, microlabels, eyebrows). **The rail belongs to a container, so ground level has none — with one deliberate exception.** A block on the ground (`zinc-950`) — a section heading, a line of helper prose — sits on the page's own edge; there is no box whose padding it is matching, and a `pl-3` there is a _third_ x-position 12px right of every heading around it. If a fact wants the rail, what it actually wants is a card. The exception: **ground helper prose sitting directly against a card or a run of cards** may wear `pl-3` to align with the railed text inside them (the import form's "This book isn't in these yet" line). Labels are not in this exception: a card names itself (§3b), so no label lives on the ground at all. **A card's own text sits on the rail relative to the card's padding edge.** A bare `
` puts its text at the padding edge, 12px left of
every sibling card's railed header, which is the easy mistake on a
single-sentence empty-state card. The text inside a card wears `pl-3` like
everything else.
**Text lands on the rail exactly, wherever it lives.** A control's own text
counts: a borderless pill pads `px-3`; a 1px-bordered box pads `px-[11px]`
so border + padding = 12 (the inputs' trick, worth a transparent border in
the borderless state so toggling doesn't shift the box); a card's leading
checkbox sits on the rail itself. Two pixels shy of the rail reads worse
than either edge — near-misses are what "feels off" is made of.
**Images are content, not containers.** A bare image (a cover preview, a
person photo, its empty-state placeholder) sits on the text rail like the
words around it. The box edge exists so a _container's_ padding can land
its inner text on the rail — an image has no inner text to land. An image
inside a chip or well follows that container's rules.
**Disclosure markers hang in the gutter.** A fold's summary text sits on
the rail with its chevron in the 12px gutter to the left — the
hanging-indicator pattern, always via `<.disclosure>`. Browser-default
summary markers are never used bare: Firefox draws them `inside`, pushing
the text off the rail; other engines pick their own widths.
**So a fold needs a container to hang that gutter in.** On the ground there
is no padding edge for the chevron to sit on, and `<.disclosure>` makes one
anyway: its summary lands 12px right of every heading around it, its chevron
on the page edge, and neither agrees with the 28px rail of the cards above
it. Three x-positions, arrived at by following the rule. **A ground-level
fold gets a card, and its summary becomes that card's label** — the chevron
takes the padding edge and the summary lands on the rail with everything
else. The duplicates report's "marked intentional" fold is the case.
**A long editable list scrolls inside its card; it does not fold.** The
chapter editor is the case: dozens of rows would swallow the form, but a
fold hides the content _and_ fights the autosave patches, while
`max-h` + `overflow-y-auto` on the rows container bounds the card's height
with every input still visible and in the DOM. Folds are for content the
operator mostly doesn't need (evidence panels, file stats), not for the
thing a section exists to edit.
**One label column for annotated rows.** Any run of "label: value" rows
(match lines, `Proposed`/`Results` rows, the source line) shares a fixed label
column via grid (`grid-cols-[4rem_minmax(0,1fr)]`), sized once per surface;
wrapped content stays in the value column. The width is the _widest label in
the family_ and nothing more: `Proposed` at 11px uppercase is a shade under
4rem, and anything wider leaves the short ones (`Results`, `Photos`) sitting
in a lake of air. The alignment is the point, so size the column honestly and
never give each row its own width: `max-content` in separate grids lines
nothing up at all.
**A proposal-chip row is one line or a list, never a partial wrap.** A row
that breaks just its last chip reads as an accident; either every chip
shares the line or every chip gets its own. No flow mechanism expresses
"stack only if they don't all fit", so the `fit-or-stack` hook measures the
chips' natural widths and commits the row to one of the two modes.
**The other axis: mixed text sizes on one line align by baseline, never by
box centre.** A label row (label + badge + provenance), a microlabel grid
(`Asked`, `Proposed`), the footer's buttons-plus-count: `items-baseline`
(flex and grid both support it), never a `pt-*` nudge — near-misses read
as badly on this axis as off the rail. What stays center- or top-aligned
instead: rows of boxes (adjacent controls share exact height, §7),
icon-only actions, image rows (a baseline puts the label on the first
image's bottom edge), and any row whose first line truncates —
`overflow: hidden` exports a synthesized baseline (the box bottom), worse
than the centre it replaces.
**Rhythm is 8 · 28 · 56** (`gap-2` / `gap-7` / `gap-14`): inside a field
cluster / between blocks / between sections. Queue cards stack with
`space-y-3`.
**Radius is a ladder, and it follows size**: `rounded-lg` for blocks and
floating panels, `rounded-md` for anything you click or type in (buttons,
inputs, selects, pills, chips, listboxes), `rounded-sm` for inline tint
spans and small images. Unwritten, this drifts per surface, and a primary
button next to a chip ends up visibly disagreeing about what shape a control
is.
**A date and its display precision are one composite fact, one control.**
`<.date_with_format>`: date left, precision select right, one shared well —
never two fields, and on the import form never two decision blocks whose
proposal could be _split_ across sources. A chip proposes both halves
("2015 (year only)") and accepting settles both; a claimed precision on a
day-specific date normalizes to full ("October 3rd is not a year in
disguise"), and a precision the operator sets deliberately is never
overruled.
**One control height: 40px.** `py-[7px]` + `leading-6` + a 1px border, which
is what `<.input>` is, so buttons carry the same padding rather than `py-2`
and the inbox's segmented filter bar (`p-1` + `py-1.5` segments) lands on it
too. §7 says adjacent bar controls share exact height; this is the number.
**The document is the scroller, and the chrome is sticky.** There is no app
shell: the body scrolls, the side nav is `fixed inset-y-0`, the page header is
`sticky top-0`, and the content clears the nav with `lg:pl-64`. This is not a
style preference: LiveView sets `history.scrollRestoration = "manual"` and
then saves and restores `window.scrollY` and nothing else, so an admin whose
scrolling happens inside `#main-content` cannot restore a position on back,
forward, or anything else. `#main-content` keeps its id and its `p-4`,
because the hooks and the arithmetic below name them.
**A sticky bar still has to be told about its containing block.** The offset
is the simple half: the viewport has no padding, so `bottom-0` puts a bar
flush with the bottom of the window. But a sticky box cannot leave its
containing block, so at the end of the scroll that block's own bottom edge
holds the bar up by `#main-content`'s 16px, which is what `-mb-4` on the
page's content wrapper is for and why it is required.
**Content passes behind the chrome, so the chrome is opaque and above it.**
The header paints `bg-zinc-950`, and there is one z-index ladder for the whole
admin, stated once on `layout_header/1`, in tens with the gaps left in on
purpose:
| z | what |
| --- | ------------------------------------------------------- |
| 10 | the clickable layer inside a card (a row's action rail) |
| 20 | a busy scrim over a single card |
| 30 | the sticky page footers (`sticky_slab_classes/0`) |
| 35 | a typeahead's popup |
| 40 | a page-wide busy scrim |
| 50 | the page header, admin and public |
| 60 | the drawer's scrim |
| 70 | the side nav drawer |
| 80 | a modal |
| 90 | the image lightbox |
| 100 | flash toasts |
Rows matter here because `index_row` is `relative` with no z-index of its own,
so its layers are **not** scoped to the card and compete directly with the
page's chrome. A bar with no z-index at all paints above a
row's card body and _underneath_ the same row's action rail, so its controls
flicker in and out of view depending which part of the list they cross.
**Anything pinned over the page needs a number, not a DOM position**: a tie
falls back to document order, and page content always comes after the
header.
Two rules fall out of the ladder. **A full-viewport overlay goes above the
nav, not beside it**: a nav that is `fixed` is a peer of everything rather
than a column the content sits next to, so an overlay numbered below it lands
under the sidebar. And **a scrim has to cover the control it is protecting the
operator from**: the import form's page-wide scrim is 40 rather than
`busy_overlay/1`'s own 20 precisely so it covers the sticky Save, and stays
under the header so the job indicator explaining the wait is still lit.
**The header's separator is a hairline, drawn only when there is something
above it.** `border-b border-zinc-900`, toggled by the `header-scrollspy`
hook against `window.scrollY` — the same treatment as the public header, and
the one place §1's no-1px-borders rule doesn't hold: this is not a container
edge, it is the seam where scrolled content passes under the chrome, and an
elevation step can't draw a seam between two things that overlap.
**A grid or flex item that can hold a long string needs `min-w-0`.** Its
automatic minimum size is the min-content width of its contents, so one
unbroken run of characters — an exception message, a path, a provider id —
makes the whole column refuse to shrink and pushes the page sideways on a
phone, by hundreds of pixels. **Truncating the string does not fix it**;
`truncate` clamps the
element, not its contribution to the ancestor's minimum. The item has to be
allowed to be narrower than its contents, and then the text inside it
wraps (`break-words`) or truncates as it likes.
Wrap rather than truncate when the string _is_ the content — an ellipsis on
a phone hides the end of the line, which for an error is the part that names
the failure. And prefer ordinary wrapping to `whitespace-pre-wrap` unless
the value really carries meaningful spacing: pre-wrap also preserves the
template's own newline and indentation around the interpolation, which
renders as a mystery indent on the first line.
**Corners share baselines.** A card's right rail aligns its first element
with the title line and its last with the content's last line
(`self-stretch` + `justify-between`).
## 3a. One row anatomy, for every list
List rows built independently inside an anonymous `:row` slot read like it:
several rail widths, several title weights, two places to put a state badge,
and a facts column that vanishes on a phone instead of folding.
`<.index_row>` is the anatomy, and each thing has exactly one home:
| Slot | What goes in it |
| ----------- | ------------------------------------------------------------------------------------------------ |
| `:cover` | `<.row_cover>`: a 64px image at the card's left edge; round for a face, `rounded-sm` for a cover |
| `:headline` | the title, `font-semibold`, and always the link |
| default | the credit stack and meta lines under the headline |
| `:badges` | state, top of the rail, on the headline's baseline |
| `:facts` | counts and boolean glyphs, nothing that reads as a sentence |
| `:action` | one entry per verb, worded, right-aligned |
| `:footer` | **system timestamps only**, at the card's bottom baseline |
| `:overlay` | a scrim that owns the whole card (`busy_overlay/1`) |
**The footer corner is a record of what the app did, and nothing else** —
Added, Imported, Joined, Found, Last seen. A duration and a publication date
are facts about the _work_, and in the column that says "Added 8/17/26" three
lines of date read as three timestamps.
**And what they are instead is a sentence, not a cell.** `record_meta/1` is
the last line of the content column, under the credits. Terse cells
(`8/30/22`, `14:04:02`) are the shape a spreadsheet wants rather than a
reader, and the full words cost nothing, because that line is a line either
way. **The rule that
falls out: `:facts` holds what you count and glance at, the meta line holds
what you read.** One helper for both flat views, so the audiobooks list and
the queue's imported rows cannot phrase it differently.
**Three fixed lines, then one variable one.** Title, authors and narrators
are what every record has, so a row is always those three; the series, the
publisher, the publication date and the duration are each only sometimes
there, so they share the fourth and it collapses to nothing when a record has
none of them:
> **The Way of Kings**
> Brandon Sanderson
> Read by Michael Kramer, Kate Reading
> The Stormlight Archive #1 · Published August 30, 2022 by Recorded Books · 32 hours and 43 minutes
**This moves the series out of the credit stack, and it is the one place that
diverges from §8.** The argument is density and not convenience: a series
that owns a line of its own makes every audiobook row taller to state a fact
most rows say in four words, and on a row a series reads as a locator
alongside the publisher rather than as a credit alongside the people. The
detail pages and the mobile app keep §8's stack intact: the divergence is
this surface's, and it stops here.
**The rail is `w-56` (224px), which is two buttons wide, and actions wrap.**
Four buttons stacked one per line is tall and ragged; two rows of two is
neither. 224px is measured, not guessed: the tightest pair in the admin is
Playthroughs (120px) beside Devices (86px), 211px with the gap, and every
other pair is under 183px. Three never fit, which is what makes a four-verb
rail a clean 2 × 2.
The constraint that falls out lands on the **labels**, so keep them inside
the budget the rail is sized for — twelve characters, "Playthroughs" being
the longest — and don't put two long ones side by side. The inbox's second
action is "Origin" rather than "Import record" for exactly this reason.
(`fit-or-stack` is **not** the tool here. It is for metadata chip rows, where
the number of chips genuinely varies per record; a rail's verbs are fixed by
the surface, so its width can just be right.)
**A verb that isn't available yet is disabled, not absent.** A verb that
appears only once a row qualifies for it changes the rail's shape and the
order of its buttons from row to row, moving the primary action under the
cursor as the operator works down the list. A disabled button in its own
place says "not yet" without rearranging everything around it, and it has to
say _why_ in its `title`, or it is only a dead button. Note that
`disabled:` variants are a `:disabled` pseudo-class and a `` can never
match one, so `row_action` spells the state out and drops the binding.
**The whole card is the link, and it is a real one.** The headline is an
`` whose `::after` covers the card. The two obvious alternatives each get
half of this: `JS.navigate` on the container is a big target but not a link,
so no focus, no middle click and no open-in-new-tab, while linking the title
alone is a real link with a 200px target. The pseudo-element also avoids a
defect neither can see: LiveView dispatches a click to the _closest_
`phx-click` ancestor, so an `` nested inside a `JS.navigate` row fires
both, and clicking one row action navigates to another. Everything clickable
therefore sits in a `z-10` layer above the pseudo-element; an overlay is
`z-20` above both.
**A row that goes nowhere is not a link and wears no pointer.** A
`cursor-pointer` hardcoded on every row invites clicks on dead space.
**The rail needs `self-stretch` or its `justify-between` is decoration.**
The card is `items-center`, so an unstretched rail is content-height and the
dates sit jammed under the actions instead of on the card's bottom baseline.
**64px is the row's height floor**, so no list's rows are visibly shorter
than another's on the same page of the same app.
**A row whose record has an image shows it, and one whose record has none
shows the empty box.** `<.row_cover>` is that box: an anatomy each list
writes out by hand is one a new surface can simply not have, which is how a
list ends up with no image at all while holding a cover URL. It is also where
a remote URL goes through the image proxy (§7) rather than at each call site,
so a caller never has to know which kind of path it is holding.
**Paging is the list's own sticky footer.** The header holds what _narrows_ a
list (search, sort, and the one button that adds to it); `pagination_footer/1`
holds what _moves through_ it. Chevrons in the header corner are wrong three
ways: the control for moving through a list sits as far from the rows as the
page allows, it says neither which page you are on nor how many there are,
and because the header does not move, clicking "next" leaves the scroll where
it was so the operator arrives looking at the bottom of the page they just
asked for.
**Sticky, not merely in the flow.** A bar at the foot of the rows and nothing
more only trades one kind of "far away" for another: a page is 50 rows, two
or three screens, so reaching page 3 from the top of a list would mean
scrolling to the bottom to find it. It wears `form_footer/1`'s costume, from
`sticky_slab_classes/0`, because it is the same object doing the same job:
the thing the page is _for_, at the end of the thing, reachable without
hunting. Both bars settle into place at the end of the scroll, and the
`sticky-footer` hook undresses them into ordinary cards when the page has
nothing to scroll.
The footer states the range and the total ("Showing 51 to 100 of 412", "Page
2 of 9"), its steps are worded like every other action (§3a), an unavailable
step is dead rather than absent so the bar keeps its shape at both ends, and
`list-scroll-reset` takes the page back to the top whenever the page, the sort
or the search changes.
**A page is 50 rows.** Ten makes a library of a few hundred audiobooks dozens
of pages deep, each reachable only by clicking through every page before it.
**A form goes back where it came from, to the record it just saved.** A form
that ends with `push_navigate(to: ~p"/admin/books")` lands on the front of an
unfiltered, default-sorted page one, whoever you were and wherever you had
been. `AmbryWeb.Admin.ReturnTo` is two halves: a list writes its state into
every row link, and the form reads it back and returns there, naming the
record so the list can scroll to it and flash it (`focus-row`). It anchors to
the **record**, not to a scroll offset, because those are different answers
the moment an edit changes the sort key: rename a book on a title-sorted list
and its row moves, so the pixel the operator left from belongs to somebody
else. A row that is gone or off-page focuses nothing and they land at the
top, which is the honest result. The "New" button deliberately carries no
list state: a record that does not exist yet cannot be on the page you were
filtering.
**This is not optional on a form that has a list behind it**, and a form that
skips it fails silently: saving works, the operator lands on the list, and
the row they just edited is somewhere on it. A form that does one half of
`ReturnTo` has done neither.
**Counted where the rows are queried, from the same filters.** A total
assembled anywhere else eventually describes a different query from the rows
above it. The flat views make this cheap when nothing is filtering: their
credit arrays are correlated subqueries in the target list and the planner
drops every one of them for a bare count.
## 3b. Forms are blocks, like everything else
An edit form is not a special case: it is a page of decisions, so it is a
page of blocks. `<.field_group>` is the unit — one `zinc-900` block per
_cluster_, being the fields that answer one question about the record ("who
wrote it", "where the files live"). Its label names the cluster and sits on
the text rail; fields that speak for themselves need no label, because the
block and the gap around it are already the grouping.
**A card names itself.** Every label — with its provenance flag, badges,
and hint — lives _inside_ the card it describes, at the top, on the rail.
Naming a container from the ground puts a heading over cards that already
name themselves, so ground level carries only section headings (``) and
helper prose. The documented exception: a disclosure fold names itself in its
`
` — only
for a form long enough that you need to find your way around it (the media
form has two; the book form needs none).
**Every label an `<.input>` renders is on the rail** (`pl-3`), because the
control's own text is. A label at the container edge over an input whose text
is 12px in leaves a form two left edges out of alignment with itself, which
is precisely §3's near-miss.
The image-stack thumbnails (`multi_image`) are the documented exception to
§1: overlapping images have no fill to separate them with, so they draw
their own 1px edge.
## 4. Type
| Role | Spec |
| -------------------------------- | ------------------------------------------------------------------------------------ |
| Page title | `text-2xl font-bold text-zinc-100` |
| Section heading | `text-xl font-bold text-zinc-100`, no rule |
| Field label | `text-sm font-semibold` (zinc-200), on the text rail |
| Body / control text | `text-sm text-zinc-300` |
| Hint / helper / meta | `text-xs`/`text-sm` `text-zinc-400` (`zinc-500` for the faintest) — **never italic** |
| Microlabel (`Proposed`, `Asked`) | `<.microlabel>`: 11px medium uppercase tracking-wider muted |
| Paths, queries, identifiers | `font-mono text-xs`, muted; a repeated common prefix is printed once |
Italics are reserved for actual titles of works. Emphasis is weight, not
slant. Digits that stack use `tabular-nums`. Body text is `zinc-300` on
`zinc-950` — pure white on pure black is glare, not contrast.
## 5. Color
Semantic only — color states a fact, never decorates. Lime = chosen /
settled / primary. Amber = needs attention. Red = blocker, with an icon,
normal weight. Blue = location/info. Everything else zinc. A file size is
not a location and a missing description is not a blocker — when a fact
has no semantic color, it's a gray chip or a muted glyph, not a borrowed
bright one.
Fills for meaning are **soft tints with colored text** (`bg-amber-400/15
text-amber-300`), never solid bright chips. The one solid lime is the
primary button and the chosen chip's source tag.
## 6. Pills and buttons — one costume per job
| Job | Anatomy |
| ------------------------------------- | ---------------------------------------------------------------------------------------- |
| Primary action | solid `bg-brand-dark text-zinc-900` (the loudest thing on the page) |
| Secondary button | filled `bg-zinc-800 text-zinc-200 hover:bg-zinc-700`, borderless |
| Quiet row action | `<.row_action>`: the `:sm` button costume, always worded, content-sized, right-aligned |
| Danger | `bg-red-400/10 text-red-300`, or red text revealed on hover |
| Status badge | `<.badge>`: soft tint + colored text, borderless |
| Count chip | `bg-white/10 text-zinc-300 tabular-nums` |
| Option chip — _chosen_ | `bg-brand-dark/15` + `ring-2 ring-inset ring-brand-dark/50` + ✓ + filled lime source tag |
| Option chip — _unchosen_ | `bg-zinc-800 hover:bg-zinc-700`; source tag bare muted uppercase |
| Option chip — _escape hatch_ (`None`) | dashed `border-zinc-600 text-zinc-400`, transparent |
| Evidence row — _ticked_ | well: `bg-brand-dark/10` + `ring-2 ring-inset ring-brand-dark/50` + lime checkbox |
| Evidence row — _unticked_ | `bg-zinc-800/60 hover:bg-zinc-800` |
If two pills on one surface do the same job, they wear the same costume, the
same height, and start on the same rail.
**Three tiers of action, and nothing else.** Loudness follows consequence:
| Tier | Costume | For |
| --------- | ------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Primary | solid `bg-brand-dark` | the one thing the page is for — Save, Import, Scan |
| Action | `<.button color={:zinc}>`, opaque raised fill, `size={:sm}` inside a card | Confirm, Split, Re-match, Ignore, Search |
| Add a row | `<.add_button>`, the faintest fill that still reads as a control | adding a blank row, the least consequential thing on a form |
**An action must be visibly raised, or it is a label.** A `bg-white/5` quiet
action computes _darker_ on a `zinc-900` card than the `bg-white/10` count
chips beside it, so the labels read as raised and the buttons as recessed and
Confirm looks exactly like a tag. Actions are an opaque fill one rung up from
what they sit on; tags and counts stay flat, muted and smaller, with no
hover.
**The add follows its list — below the container, on the ground** (§3b's
one grammar). The `<.add_button>` box sits on the container edge like every
other control, and its `px-3` lands the label on the text rail; nudging the
_box_ onto the rail puts its text at a third x-position nothing else shares,
which reads as misalignment on every form it touches.
**Removing a row is ✕; destroying a thing is a trash can.** A list row's
remove (`<.delete_button>`) is undoable until save, so it wears the import
form's ✕. The trash can is reserved for actions that destroy something
real — the file audit's deletes, an index row's delete — with red revealed
on hover (§6 danger).
**Row actions are worded, everywhere**, with no icon-only exception: a rail
holds at most four verbs (§3a), which is few enough that all of them fit as
words. `<.row_action>` is the one costume, a link when it goes somewhere and
a `role="button"` span when it fires an event, and the trash can survives
only where §6's danger rule puts it, on a row that is not itself a link (the
file audit's deletes).
**Actions are content-sized and right-aligned, never one fixed width per
rail.** One shared width across every list forces the longest label anywhere
in the admin onto every "Edit". Right alignment is what makes the edge read
as a column. How a rail lays out is decided by its verb count (§3a), never by
measurement.
**Every destructive confirm names what dies and what survives.** "Are you
sure?" says nothing, least of all on the action that deletes an audiobook's
audio files off the disk.
**Destroying a record is offered in exactly two places, and they are the same
two for every record type: the record's row in its list, and its form's
sticky footer** (`<.form_footer>`'s `:danger` slot, at the far end of the
bar, with the whole width of it between the irreversible thing and the thing
pressed every time). Decided per record type, this becomes several answers to
one question, and which one an operator gets depends on which record they are
looking at.
**The rule stops at delete, deliberately.** "Every action in both places"
would drag the workflow verbs along with it (Dismiss, It's out, Import,
Ignore), and those belong to the row, because they are what you do while
surveying a list rather than while editing one record. Delete is the
exception because it is the one verb an operator reaches for from either
position: pruning a list, and finding out on the form that this record should
not exist.
**A record that does not exist yet cannot be destroyed**, so the slot is
absent on a `:new` form rather than disabled. §3a's "disabled, not absent"
rule is about a verb whose _place_ must not move as the operator works down a
list of rows; a form is one record, and there is no list of forms for the
button to jump around in.
**What a delete says has one home**, `AmbryWeb.Admin.Deletion`. The words
have to be the same on both surfaces, and spelled per handler they are not:
every list ends up with its own way of saying a delete worked, and every
refusal is a heredoc beside the handler that raised it. The context is what
knows _why_ a record cannot go, so the reason atoms it already returns are
the whole vocabulary; a reason with no words is a crash rather than a shrug,
because a delete that reports "couldn't" without saying why is what the
refusals exist to prevent. There are two success verbs and the caller picks:
deleting a book destroys it, while removing a source or a library root only
stops Ambry looking there, and "Deleted" of a folder still on the disk is
this section's own confirm rule broken by the message that follows it.
## 6b. Toasts and ambient chrome
**A flash is a toast: top centre, quiet, and gone on its own.** Top _right_
is where the admin keeps the user menu and the job indicator, so a "saved"
notice there covers the two controls most likely to be wanted next; one that
waits to be clicked then keeps covering them.
- The rail is `fixed top-4 left-1/2 -translate-x-1/2` and
`pointer-events-none`, so an empty one is not an invisible lid over the
top of the page; each toast turns pointer events back on for itself.
- The box is the ordinary floating-layer fill (`zinc-900` + shadow, §1),
**not a coloured slab**. Only the glyph is coloured, per §8's rule that
the icon carries the severity so the words don't have to.
- No severity heading. "Success!" above "Saved." says nothing the icon
doesn't and makes a one-line toast two lines.
- It dismisses itself — 5s for info, 10s for an error, held open while
hovered, because a message that vanishes mid-sentence is the complaint
that replaces "they never go away". Dismissing must clear the flash
_server-side_, not just hide it, or it returns on the next navigation.
- **A toast that is a state rather than an event never times out.** The
connection notices are the live state of the socket; timing one out would
claim the connection came back.
- **Never put a display utility on an element hidden by the `hidden`
attribute.** `[hidden] { display: none }` comes from the UA stylesheet, so
an author-level `flex` (or `block`, or `grid`) on the same element
outranks it. A `flex` on the flash root makes both connection notices
visible on every navigation, for the moment between first paint and
`phx-connected` firing: too brief to read and impossible to miss. Put the
layout on an inner element.
**An ambient indicator renders its quiet state too**, and wears one costume
(`<.status_dot>`). The header's background-work widget shows a dim dot and
the word Idle when nothing is running: a widget that only appears when there
is news is indistinguishable from a broken one, and rendering the calm state
is what makes the busy state mean something. Where the state is genuinely two
questions ("is it busy" and "did something break") they get two marks,
because only one of them is a reason to stop what you are doing.
**A link that leaves the app says so before it is clicked.** Oban Web and
the Phoenix dashboard open in a new tab with
`fa-arrow-up-right-from-square` beside the label — the admin is a page you
leave open while you watch something, and taking the tab costs you that.
## 7. Controls
- **A remote image goes through the admin proxy.** Tracking protection blocks
hotlinks to provider CDNs, so a surface that renders a provider's cover URL
directly is blank in some browsers and not others, which is invisible to
whoever built it. `proxied_remote_image_url/1` leaves a local path alone,
so apply it wherever an image is rendered rather than only at the call
sites that happen to be remote today. `<.row_cover>` and `preview_src/2`
are where that lives.
- **Adjacent bar controls share exact height** — no exceptions.
- **Inputs are sized to their content**: dates `max-w-48`, format selects
`max-w-56`, names/publishers `max-w-md`, titles `max-w-xl`, URLs and
descriptions full width.
- Checkbox checked state is lime (`text-lime-600`), never the browser blue.
- Buttons never wrap their label; when a bar runs out of room the bar wraps
as a whole.
- Below `sm`, right rails fold into the card as a full-width bottom line;
below `lg`, the nav is a drawer with a scrim. Truncation drops meta before
content, and the candidate title is always the last thing to give.
## 8. Voice
Labels name what the operator decides, not what the system stores. Hints
teach in one sentence, lowercase-calm, no exclamation. Alerts say what's
wrong and what to do, with an icon carrying the severity so the words don't
have to. Headings are sentence case — "Orphaned audiobook files", never
"Orphaned Audiobook Files"; Title Case is for titles of works.
**One vocabulary.** The playable thing is an **audiobook**, never "media",
"recording", or "release" in anything rendered. The abstract work is a
**book**, never "work". A `RecordingGroup` is a **set**. "Edition" appears
only as a relation ("another edition of this book"). Code, schema and module
names keep their own words; the rule is for text an operator or listener
reads. The admin routes follow the words: `/admin/audiobooks`,
`/admin/sets`.
**Credits are the mobile app's stack**: title (bold), series
("Name #3"), bare author names — no "by" — then "Read by …" muted, with
size and color carrying the roles. (Index rows are the documented
exception: the series moves down to their variable line, §3a.) "Narrated by"
is not used as a verb; the noun "Narrator" stays. When one line must hold it
all (page titles, match rows, option labels), the joins are words: "The
Martian by Andy Weir read by R.C. Bray". Names join with commas.
**No em or en dashes in rendered text.** Rewrite the sentence instead:
two short sentences, a semicolon, parentheses ("Name (context)"), or a
comma. The only "—" left is the empty-value placeholder in table cells,
which has one home in `empty_value/0`. The " · " middle dot stays, but only
between metadata facts (file stats, record-row facts), never inside a credit.
**And it is a test, not a rule** (`AmbryWeb.VoiceTest`). A dash slips into a
provider's setup help, a token expiry warning, a match explanation or a
flash, written by somebody who has read the rule and is writing prose, which
is the failure a prose rule cannot prevent. The test reads the source rather
than rendering pages, because rendered strings live in contexts an admin test
never reaches, and a check that only covers the pages somebody thought to
test is no check at all. Comments, `@doc`s and non-`~H` sigils are exempt: a
release name is full of dashes and the patterns that parse one have to say
so.
**This is the shape to reach for whenever a rule here is about a string.** A
rule that has to be remembered while writing a sentence will be broken while
writing a sentence.
**Hints are one plain sentence**, two at most: no asides, no "not X, but Y"
rhetoric, no reassurance ("that is common and not a problem"), no mechanism
lectures, and no notes about what changed or what is planned.
Flashes say what happened, then what to do, in at most two short
sentences. Control labels that answer a question use a comma: "Yes,
another edition of this", "No, a different book", "None of these".
## 9. Curation is the import form, everywhere
The edit forms run the import form's model on records that already exist: one
mechanism for "ask the databases", never a modal per provider.
- **One card holds a search: the query, then who answered, then what they
said.** That order is the grammar, and `<.provider_outcomes_row>` is the
"who answered" line in every one of them. A surface that writes its own
outcome summary invents a second way of saying a provider failed, and the
copy it invents is where §8's rules get broken.
- **The evidence panel** (`<.evidence_panel>`) sits above the form —
evidence first, then the decisions it feeds — and starts **folded to one
line**: an edit form is mostly visited for reasons that aren't curation,
and instructions about records that aren't there yet are noise. One
search fans out to every capable provider (`Ambry.Metadata.Search`);
results are the same tickable `record_row`s and `Asked` outcome chips
the inbox uses, **scored and ranked the way matching ranks them**
(`Ambry.Inbox.score_records/3`, hinted by the record's own fields) so
the study guides sink. The recording level asks twice, like the import
form: recording-level providers directly, plus the audio editions the
work-level databases keep.
- **One list, ranked once, whoever answered.** Which database found a record
is a fact about the record and wears a badge on its row; it is not a
heading and it is not an axis to sort by. Grouped by provider, a run of
headings ranks each provider's guesses on a different yardstick and then
asks the operator to judge across them, when a provider either can produce
audio editions or it cannot and the ones that can are all asked. Anything a
heading would carry that the rows do not, such as which book a work-level
provider says a recording is of, becomes a fact on the row. Ranking is
`Ambry.Inbox.score_records/3` everywhere, matching's own scorer: an operator
who ranks provider records one way on the import form and another way on a
search page is being asked to learn two orderings of the same evidence.
- **Records identify themselves**: every record row wears its cover or
face as a small thumbnail — identification, visible _before_ ticking —
while the chips below stay the place a photo or cover is _chosen_. Tiny
images everywhere carry a corner magnifier that opens the full-size
image in the lightbox; disambiguating twins sometimes takes the art at
full size.
- **Provenance closes the loop**: accepted proposals and imports record
the provider _record_ behind each field (`"record"` in the provenance
entry). A later search that returns that record recognizes it — arrives
pre-ticked, wearing a "filled title · published" note — so "which record
did this come from?" has an answer years later.
- **Ticked records grow "Proposed" chip rows** under the fields they can
fill (`<.curated_input>` / `<.proposal_row>`), in the import form's chip
anatomy exactly. Accepting a scalar chip takes the value; accepting an
entity chip (author, narrator, series) **stages** a row that names the
entity, and nothing is created until Save: a chip that writes a record on
the spot leaves a person behind on every form the operator opens and
abandons. Evidence is session state, added and never replaced while the
page lives, gone when it is left.
- **Provenance is worn inline** on each field's header
(`<.provenance_flag>`): "from hardcover" muted for the recorded source,
"will record rreading-glasses" amber for an accepted-but-unsaved one,
with the lock beside it: the import form's "from …" idiom pointed at
`Ambry.Provenance`. Accepting records the provider unlocked; editing the
value afterwards makes it a manual edit again, locked.
- **A proposal that is a _merge_ previews in place, inside the thing it
changes.** Chapters are the case (`AmbryWeb.Admin.ChapterEditor`, both
forms): a ticked record's ASIN grows a chip, but the chip's value is
thirty rows changing at once — so clicking it renders the fetched titles
**into the rows themselves**, a proposed-title cell beside each title
input, with one Take/Cancel header at the top of the card. No second
card, no modal: the rows are the preview, and nothing lands until Take.
The one place a chip is two clicks, argued from consequence; scalar and
entity chips stay one. **Take exists only when the counts match** — a
list that doesn't pair one-to-one stays visible (the proposed column
shows where the lists diverge, which is what to fix) but is never
poured.
- **A control tells the form when it is left, not while it is being typed
in.** Typing a name and meaning it post the same string, so anything
downstream of a per-keystroke `change` reacts on the first letter — hardest
of all while the operator is searching for a record that already exists.
The picker moves its hidden inputs under the typing, so a save posts
whatever the box says, and raises the change on pick, create or blur, which
is where a native input raises it. Answering the same question with a second
hidden input carrying "the operator clicked Create" pushes the job onto
everything that stages a credit _without_ the picker, and a provider chip
crediting a new narrator will not set it, so their card never appears.
**A surface should read the state, not a flag beside it.**
- **A credit that names somebody new gets that person's card — literally the
inbox's card.** A picker may name a record the library has never heard of,
so an edit form can invent a `Person`, and a person made of a name and
nothing else is the asymmetry to avoid. So the edit forms render
`person_card/1` itself, never a redrawing of it:
`AmbryWeb.Admin.NewPerson` builds a `PersonDecision` and its `Credit` out
of the nested person changeset and the card's own `Evidence`. **A person is
a person on both surfaces** — same overlay while a search runs, same photo
strip at the size a face is seen, same bio box with its preview and chips,
same record list.
It lives in a **section of its own** under the credits that name them — the
inbox's `#people` anatomy down to the heading, a heading on the ground with
cards below — never inside the credits card, which would be a card inside a
card. It renders **only where the nested chain reaches a person nobody has
made yet**, which is never true of a credit pointing at somebody who
exists, and the credit rows are walked a second time (`skip_hidden`,
`skip_persistent_id`) because the pickers above already post those.
It is called **People** on both surfaces, never "New people" on one of
them: a card can link somebody the library already holds, so the section is
not only about the ones being made. A heading that narrows what its section
contains is a difference to justify.
The card hangs off the **join row** that holds the person — `author_people`
for a pen name, `media_narrators[i][narrator]` for a stage name — not off
the person itself, because that row is where "which human is this" is
answered. It is what lets the card offer the people the library already has
by that name and link one, and what lets a pen name carry more than one
human (James S.A. Corey), the same as the inbox.
**A ticked record about this human answers what nobody has answered.** An
import takes the best record's face and biography and leaves the rest one
click away; a credit that is merely _offered_ them leaves the operator who
ticked a record and saved with a person who has a name and nothing else,
which is the same asymmetry one level down. Records that spell the same
name arrive ticked (person search is recall-first, so the rest are listed
and left alone) and the best of them fills the boxes nobody has filled.
**Absent is unanswered; present-and-empty is an answer**: the card's
controls are inputs, so "no photo" and a cleared biography post as `""` and
are left exactly as they are.
**A card rendered more than once keys its ids by the card, not by its
address.** An import form's people are a grid (this credit, this human
behind it) so the address is unique there; on an edit form every card is
the first person behind the first credit of the only section, and the
hook-bearing elements collide. LiveView finds elements by id when it
patches, so a bio box vanishes when an unrelated part of the form changes.
Anything repeated takes its ids from whatever is actually one per copy.
One thing genuinely differs, and `input_prefix` is where it is said: the
import form saves on change and has no form of its own, so each of the
card's controls is a little `