The canonical name for every part of the interface, rendered live with the app's own
stylesheet. When we say "the quote block" or "the hand-card", this is the
shared vocabulary. Each tile shows the element, its name, and where it's defined
(web/frontend/src/ui.jsx export or a CSS class in index.css).
Use the toggles to see any material set, in light and dark, under any accent.
This page is generated. Its entries come from
web/frontend/scripts/glossary/catalogue.js and from the
glossary declarations beside the components themselves, and its rules
section from src/tokens.js — so nothing here is a number somebody
retyped. Run make glossary after a UI change; CI fails when it is stale.
The frame around every logged-in screen (App.jsx · Shell): a sticky topbar on desktop, replaced wholesale by the fixed bottom-nav at ≤768px (see Mobile shell below).
/mark.svg image mark — /mark-dark.svg when the theme resolves dark — sized to match the nav icons, + wordmark), the nav-toggle tab strip, then the four shell controls in a fixed order: + Add · Search · ? · the avatar chip. The first three read the current route — Add opens on a book on Library and on a quote against whatever work you have open, Search lands scoped to the list you came from, ? opens that screen's help — which is why they can be one control each instead of one per page. The phone bar carries the same four in the same order. Sticky on scroll. At ≤768px the topbar is display:none and .mobile-topbar takes over..tab-label spans hide, leaving icon-only tabs. It renders twice — once in the topbar, once in the bottom-nav — with CSS showing only one at a time.border-radius:32%; more geometric in film), not a circle. Clips an uploaded avatar; otherwise shows the initial. It opens Profile directly. It opened a dropdown until 1.4.1 — Profile, User management, Log out — and the drawer repeated the same three rows, so "my account" was a menu of screens rather than a screen; all of it is a section of Profile now, and the chip is one tap.account
.menu-item — index.css.menu-item survives, shared by MoreMenu and the date picker. (Rows shown here stand in for the sections.)App.jsx with its own stroke settings, which is how the app came to draw a magnifier, an open book and a tray-download twice each — once in the nav at stroke-width 2.0 and once in the shared set at 1.85 — and to draw the Library tab as the same open book the "currently reading" cover badge wears, on screens that show both at once. Library is spines on a shelf now; every other nav glyph is an exported Icon* from the shared vocabulary, at the shared weight. Stroke is currentColor so the active-tab tint flows through. An unknown tab key returns null on purpose — a tab added to a nav list and not to the switch should render a bare label, not crash the shell — which is why icons.test.jsx asserts every key in all four lists has one..tab buttons where the active one wears a hand-drawn accent pill (paper) or an accent tint + glow (film). The .tab-label span is also what NavToggle wraps its labels in — hiding it at ≤768px is how the primary nav goes icon-only.html[data-scroll='screen'], published by useScreenOwnsScroll) so the hero can stay put while the quotes move. The hero column is --hero-w wide — 300px, 340 from 1440 — and appears only from 1180px; below that its content folds into the stream, because a 300px column beside nothing is a margin. Both columns are edge-faded scrollers, and --detail-pad is at least the fade's own 1.6em so a cover's drop-shadow is not clipped by the mask. The stream caps at 880px for measure, which is what makes the page's --container-max unnecessary here.The phone chrome: nav moves to a fixed bottom bar, pages grow sticky top bars, filters move into full-screen sheets, and every hit target grows to 44px. All of it is gated on one media query. (Demos below are forced visible/static — in the app these pieces only render on a narrow viewport.)
env(safe-area-inset-bottom). (Shown static and forced visible — it only displays at ≤768px.)env(safe-area-inset-top) for the notch. The container drops its own top padding so this bar sits flush with the screen edge.Mikhail Bulgakov
translatorRichard Pevear · Larissa Volokhonsky
window.confirm() calls — and a reader met three of those on one screen. A panel is a list of rows that may push another list of rows; that is one shape, so it has one implementation.ch, because it holds text.history.state; every close — ✕, the ← key, the scrim, Escape, a row that answers its question — goes back through history.back(). So the phone’s Back gesture and the header’s own key are one code path and cannot disagree, and a panel dismissed by ✓ can never leave an entry behind for Back to re-open. The depth is read from the popped state rather than decremented, because a browser coalesces a multi-step go(-n) into one popstate — a handler that popped one level would strand the rest of the stack open with no entries left.useFormHost registers the form with whatever chrome is above it, wears that chrome's formId on its <form>, and reports a reason it is not ready yet; the chrome draws one type="submit" form=… key bound to that id, greyed with the reason as its tooltip. Until 3.2.0 the only chrome that did this was FormModal, so a form moved onto the panel stack lost its save key silently — nothing thrown, nothing logged, and the <form> simply had no id. A panel with no form in it still draws nothing: a list of rows has nothing to commit and must not offer to. The reset when you walk the stack happens during render rather than in an effect — a child's effects run before its parent's, so an effect keyed on the depth fires straight after the form registers and undoes it.<h1>; inside Checks it is a section under that page’s <h1> and takes an <h2>. A page with two <h1>s is not a style problem — it is a document with two titles, and a screen reader reads it as two documents. One component rather than a conditional at each call site, so the two pages cannot disagree about what "embedded" changes the first time one of them grows a fourth header.dismissOnScrim={false}: a filter sheet loses nothing to a stray tap beside the card and a half-written quote loses everything. Never rendered on desktop. (Shown static at a fixed height — the real sheet covers the viewport.)hand-card hc-r2 panel of menu-items; danger items are tinted var(--error). Closes on outside click or item pick. (Shown open, inline.)useScreenBar({ actions }) takes a builder, not a list, and the builder is never published through the subscription the sub-line and the dock keys use. A menu bar’s rows carry state, and a published list would be stamped by its ids — the ids do not move when the sort does, nothing re-publishes, and the menu goes on ticking the view you left. The shell holds a pointer to the current builder and calls it at the moment the ⋯ opens, so nothing can be one render behind.embedded and both entitled to publish, so the registry is a Set and the menu concatenates in mount order. A single slot would have let whichever rendered last win silently, and the other half of the page would simply have looked like it had no actions.menuitemradio with aria-checked and a ✓ at the far end — a plain menuitem with a tick drawn on it is a decoration a screen reader never mentions. A verb is a plain menuitem, tinted var(--error) when it destroys something. The keyboard handler reads [role^=menuitem], because a selector naming only menuitem steps over exactly the rows that are a choice..help-btn ring, a hairline in --line, and a bare glyph between two solid accent controls reads as something bolted on afterwards whatever it does. Help is the shell’s own row, appended after whatever the screen published, because it is never the screen’s to offer and several screens publish nothing at all. (Shown open, inline.)label and it renders the same icon + .btn-label pair a worded button does, so it honours Button labels in both directions. danger and ok tint it; ok exists because its absence was being worked around — the Add sheet’s ✓ wore the 34px family’s colour class to go green, and one family borrowing another’s stylesheet is how two families stop being two.CloseButton and the quote tools. Variants: ok, danger, boxed (a resting outline, for the cover cluster that has no field row around it to be the affordance) and two latches, active and busy, which are classes rather than hover states because a latch has to survive the pointer leaving.field-icon-btn tactile, hand-written at 46 call sites across 13 files, the only primitive in the app that was never a component. The copy-paste had held well: all 46 carried an aria-label, all 46 were wrapped in a Tooltip, and exactly one had drifted (a Home button was missing tactile, so it did not press when you pushed it). The drift is not why it became this. A class string cannot make a decision — IconButton gained an opt-in label so the 44px family could honour Button labels, and this family could not opt into anything because there was nowhere to put the opting. Forty-six buttons sat outside a preference that claims to govern the app, not by a decision but by never having been asked.label prop. 34px exists precisely because there is no room for a word beside the glyph; adding one would collapse the distinction between the two families rather than complete it. So the app has two sizes and two rules — 44px can be named and opts in, 34px is nameless and puts its name in the tooltip and the accessible name, both of which the component guarantees. A test asserts exactly one <button> in the SPA wears the class, so the 47th hand-written copy is not a paste away. The one non-button that wears it is named too: a file picker has to be a <label> around an <input type="file">, so it borrows the look on purpose.iconStroke spec (24px, currentColor, stroke-width 1.85, round caps, aria-hidden). IconGrid / IconList / IconTable reuse ViewIcon at 15px, the one deliberate second size class.♥, ▲, →, ✓, ⚠, ↗ — which is the platform’s drawing rather than the app’s: it changes with the reader’s font, sits off the baseline every other glyph shares, and is the one picture this page cannot document. Every one of them now draws an Icon*, and the two shapes the set had no answer for became these. IconArrow takes a dir because four rows point four ways and four drawings of one idea is three too many.IconShare and IconUpload were both a tray with an arrow in it, differing by about a pixel and a half of arrow, and appear in the same rows — a quote card offers share, the tag manager offers upload. Share is the node graph now and shares no geometry with a tray. IconExport and IconMetadata were the same three strokes at coordinates half a unit apart, sitting two buttons apart on the Metadata console, one pulling data in and one pushing it out; Metadata is an arrow landing inside a record card now, because that is what fetching metadata does. IconChevron was promoted out of CoverPicker.jsx, where it had been the only glyph in the app defined outside ui.jsx.test/dom/icons.test.jsx asserts it instead: every exported glyph is compared with every other, both exactly and with all coordinates stripped, and identical geometry fails the suite.IconQuote was a bare pair of quotation marks spanning 13×10 of the 24 grid, beside an IconBooks spanning 17×15 and an IconReel spanning 17×17. Nothing about it looked broken; it looked small, which is a complaint nobody can act on until it is measured. It is a square speech bubble with the marks inside now, filling the box like its neighbours — and the bubble is not only packaging, since a line somebody said is exactly what the three screens it fronts have in common. The marks stay filled inside a stroked bubble: outlined quote marks read as the digits "66", which is why they were filled to begin with. The same test file now measures each nav glyph's footprint and fails when one drifts out of the set's range..filter-chip-mobile), ghost buttons, inputs, and hearts/stars — which keep the 44px hit height but narrow to 34px inside cards so a heart + five stars + links still fit a ~310px card column. Bottom-nav tabs squeeze their padding so five icon tabs + brand + chip fit a 320px screen.MOBILE_SCREEN_QUERY = "(max-width: 768px)", exposed as a live hook (useIsMobileScreen) and a one-shot check (isMobileScreen). It deliberately follows the browser's layout viewport, not the device — "desktop site" mode on a phone gets the desktop UI. Every JSX-level mobile decision (sticky bars, sheets, icon buttons) gates on it, matching the CSS breakpoint exactly.The recurring text roles.
fonts.js rather than a search for every place a family name was written down. Each row sets its own text, which is the whole design of the screen: a type list that puts “the quick brown fox” in every face answers no question anybody has, and it cannot show the Bengali row at all, whose entire point is a script no specimen sentence contains.--font-display-weight and four more — set beside every font-family: var(--font-X), 69 rules and 63 inline styles, so a modifier lands where its role is used and nowhere else. Set once at :root they would inherit into everything. Off is inherit, not normal: a heading already at 600 must not be flattened to 400 by a role nobody touched. Where an element already sets its own weight or italic the companion is dropped rather than left to fight it.fonts.js beside the style table it is about. font-variant-numeric: tabular-nums ships as Lining figures, which is the true thing behind the request. Small caps and all caps are absent from the Bengali and Devanagari rows, which have no case at all.The paper/film surface everything sits on (HandCard — ui.jsx). variant 0–3 picks an uneven radius; colorBar adds the left highlight bar.
The annotation card on a book's detail page — a hand-card with a color-bar holding the quote, meta, note, tags, marks and an optional sticker seal the text flows around. The list offers tiles / list / table views via the ViewToggle, and adding starts from the dashed add-annotation tile at the top (or the sticky-bar + on mobile). (Dialogues use the film-strip form below; a standalone quote uses the same card with a different meta line — see the tile after this one.)
The margins, wider than the text itself, are where the reader answers back.
CH. 3 · P.142the whole thesis in one line
WorkListScaffold’s leading slot — a filter slot. On a phone it renders inside the Filters sheet, so the three boards were invisible on the device this app is designed for first; and the whole row is gated on hasItems, meaning the current board is non-empty, so opening an empty board removed the control that got you there, with the choice persisted so a reload did not rescue you. A filter narrows what you see within a container; a board decides which container you are in. Naming that difference is the entire fix.ON DELETE RESTRICT so a forgotten move is an error rather than a lost quote. The one thing it cannot do is delete your last board while quotes are on it. Hiding is explicit and never inferred from emptiness — a board you have just made is empty, and vanishing at that moment is the same trap this entry exists to undo — and it loses nothing, because a hidden board’s quotes are still under All quotes, which is pinned, is not a board, and cannot be renamed, hidden or deleted.kind is plain or proverb, set by the reader on a toggle rather than inferred from the name — rename a proverb board to Grandmother and it is still one; call a plain board Proverbs and nothing changes about it. Both of those are the correct answer, and they are why 0037 added a column instead of a name match, which would have broken exactly the reader 0036 promised not to break. A proverb board asks for its languages at creation (multi-select, and the box beside them takes any language, not just the three the starters ship in), because the list is what turns the quote form’s Language box from free text somebody has to spell consistently into a short list. Stored on the board rather than derived from its quotes: deriving would be free and never stale, but it can only describe a board that already has quotes, and the board is empty at the moment the question is asked. Those languages are also what the optional per-language sections group by — a grouping rather than folders, since a folder is a place a quote lives and a proverb already lives on the board. speech is deliberately not a kind: a speech quote uses the same fields every other quote does, so it would be a label with no behaviour behind it.<div> above the scaffold, with a comment explaining that WorkListScaffold had no back slot. On a desktop that is right — a book’s page does the same, with room to spare. On a phone it was an entire row spent on one back arrow, with the board’s name, its count and its filters in the row beneath, while a book’s page has always put all four in a single MobileDetailBar. The fix is the missing slot rather than a stylesheet tweak: the scaffold takes an onBack, and when it has one on a phone it draws that same bar — so a board and a book are now the same page shape, with Filters under the thumb. (The seat beside it was Export until 2.3.0 and is now the way to the other boards; Export is in the screen’s own ⋯.) The sections a board can be grouped into were already the shared GroupHeading every other screen uses, so per-language sections behave exactly as grouping by author or decade does.Give me blood, and I will give you freedom.
· Burma Radio broadcast · 1944 · Burma · radiometa + form · utteranceMeta + UtteranceForm.card-actions and .card-colors only reveal under .hand-card:hover, and .hand-card::before is what carries the material — a bespoke wrapper would look right and lose the aesthetic toggle. What differs is the meta line: where a book shows CH. 3 · P.142 this shows the occasion — speaker · occasion · when · place · kind · language, each dropped when empty (0053 replaced the free-text medium with a fixed kind; a card falls back to the old text while no kind is set). A quote with none of them (a proverb) shows no meta line at all.splitCredits divides it on your own separators, CreditFaces draws the overlapping portraits of everyone named, and each name is a PersonName opening that person's Person panel. Styled through SPEAKER_LINK — font:inherit;color:inherit plus an underline — so it keeps the mono voice of the line it sits in instead of arriving as a blue link mid-sentence, the same trick the film pages use for PLAYED BY. Why it is worth a tile: speaker became a people kind in 1.5.0 and the share image drew these faces from that release on, so a speaker you had enriched showed their portrait in the picture you exported and stayed inert text on the card you exported it from. The card, the group heading and the image now split the credit by the same rule, which is the whole point of splitting it in one function.a note in the margin
AnnotationForm in a hand-card, auto-scrolled into view. On mobile the sticky-bar + IconButton opens the same form.Each line of the quote is measured and shortened where the seal sits, so the text truly flows around the sticker instead of clearing it.
.flow-line. The seal is draggable — release persists its centre (width-normalised x/y) on the annotation. Falls back to plain text under reduced-motion or with no sticker. (Static mock shown; the app computes real line breaks.).sticker-swatch checkerboard.tippani:sharePortrait / tippani:shareImageTintdestination-out on an offscreen canvas — rather than the card colour painted over the photo. The card’s face is a vertical gradient, so a flat overlay would leave a seam exactly where the fade ends, which is the one place a viewer is already looking.color blend keeps the photograph’s luma and takes the quote colour’s hue, so the face stays a face. That is a CSS blend mode rather than a Porter-Duff operator, and a canvas that does not implement it ignores the assignment silently — leaving source-over, which paints a flat slab of colour across the person. So the property is read back and a source-atop wash is used instead when it did not take. Order carries the rest: the tint goes on while the buffer is still opaque, so it colours the photo; the mask goes on after, so the colour fades out with the face rather than surviving as a coloured rectangle.usePersistedState writes on mount: the old default had been stamped into local storage by the first render of the panel on every device that ever opened it, so flipping the literal alone would have changed the default for nobody.faces array as the small credit discs, and therefore the same Author / Actor / Speaker tick: unticking the credit takes the backdrop with it, so a card can never credit nobody while wearing that nobody’s face. With a backdrop the discs step aside entirely — 1.6.0 drew both, and a 34px crop of the same photograph beside a full-height version of it reads as a mistake rather than as identification. The attribution line reclaims the indent it was leaving for a cluster that is no longer there. The toggle hides itself when no one credited has a saved photo — a control that cannot change the picture is a question with one answer. Like the skin picker beside it, the choice is device-local: an export preference, not an identity one.share.faces / share.characterFaces from bookShare + movieShare · tippani:shareFaceKindpeople.image_path) and a character belongs to one work (work_cast.character_image_path) — and the card could only ever draw the first. For a line whose whole point is who said it, the performer is often the wrong one: V delivers the speech, and Hugo Weaving is a man in a photograph not wearing the mask. The control is hidden, not greyed, unless the work has a saved picture for both, which is the same rule the Portrait toggle follows and for the same reason — a toggle that cannot change the picture is a question with one answer. Book quotes get it too, and that is the half of this nobody could see: the server has been sending a book annotation’s character pictures for as long as they have existed, and no book surface ever read them. The swap happens once, in the panel, so faces keeps meaning the actor’s everywhere else and the drawing code learned nothing new. Device-local, like the skin and the backdrop beside it.*bold* _italic_ ~strike~ > quote ```code```
i beside a control whose meaning it depends on is a dot you must open to learn whether it was worth opening. Image has no syntax to describe, so its dot answers the other question — what this thing is — and the skin picker's “doesn't change the app” caveat became a dot rather than a grey aside.| Quote ▲ | CH / LOC | ♥ | |
|---|---|---|---|
| The margins are where the reader answers back. | CH. 3 · P.142 | ♥ | |
| A second row to show the hover tint. | CH. 5 |
.ann-table-wrap is the one sanctioned horizontal scroller — everything else must wrap rather than widen the page. Rows being edited (.editing-row) drop the hover tint. Headers sort via SortableTh (Controls).One managed vocabulary shared by books and movies — five styles × four colours (TagChip — ui.jsx, .tag-chip.tc-*.ts-*).
The hand-inked user marks — deliberately tilted, never machine-perfect (ui.jsx randWobble).
languageMarks preferencegrc and not el — and how much of a quote's original shows.Playful buttons play a random micro-animation on click (ui.jsx PlayfulButton).
c. 1500, 380 BCE and c. 380 BCE are all typable, and parseYearInput reads them at save. The tick is DERIVED from the phrase rather than stored beside it, so the two cannot disagree; typing the marker lights it, pressing it writes the marker. Disabled with no year, because c. alone reads back as nothing. An unparseable phrase takes a red edge instead of saving as no year.YYYY | YYYY-MM | YYYY-MM-DD), with a calendar button beside it. Where a caller passes the estimate flag — the two quote surfaces do — the tick is drawn as part of the field, and a c., ca, circa or ~ typed in front is read BEFORE the box strips non-digits, which is where it used to be silently deleted.var(--error).could not save — title is required
var(--error). Renders nothing when empty, so it can sit permanently in a form.no annotations match the filters
edit-fade, one-shot on mount — reload to replay). In table view an .editing-row drops the hover tint while its form is open.@container). The repo's only container query. Resize this card to see it split/stack.The unified tactile controls (ui.jsx). One Toggle and one Select share a single sliding accent thumb; filter chips wear the same accent material when active. Native title= and confirm() are replaced by an on-brand Tooltip and ConfirmDialog. Since 1.4.0 the Tooltip also answers on touch: a 500ms long press puts its label in a hint toast at the top of the screen (see Help & density), so a glyph-only control is never unexplained on a phone.
--sel-scrim ground.appearance: none on an input[type=checkbox] gives up the platform’s drawing — which is the one thing here that has to match the app, being a different size and colour on every OS — and keeps the keyboard, the label association and the announced role. The tick is drawn by a rotated ::before rather than a glyph, so it scales with the box rather than with a font the reader may have changed.<select>. Tactile trigger; the chevron flips when the panel opens.--sel-scrim, so a chosen filter reads as the same material as the toggles.aria-pressed carries it. Behind the in-depth quiz panel's question types, the Features card's four sections, and the review-scope chips those two borrowed the shape from.disabled. Both callers have a last-one-standing rule: the deck must keep one question it can ask, the app must keep one content section. disabled eats the pointer events a tooltip opens on, so the chip stays live, says aria-disabled and swallows its own click — and the caller prints the reason IN WORDS under the row, because a reader who cannot turn something off is owed the reason on the screen rather than in a bubble they have to know to ask for..hint-bubble / .tp-tip-wraptitle=. One bubble serves every input style — hover or keyboard focus on a pointer device, a 500ms press-and-hold on touch — and only what opens and closes it differs: the pointer leaving closes a hovered label, while a held one closes itself after 1.2s, because there is no "leave" on a finger that has already lifted. Shown forced-open here, above its control.pointerleave is the whole answer only while the thing under the pointer stays there — press a colour swatch and the picker re-renders, a panel opens over the control, the row reflows, and the leave event that was going to close it never arrives. The label then sits over the screen indefinitely, obscuring the control it was read to explain. So a click closes it, because you hovered to learn what the control does and then pressed it, and the click is the one moment we know for certain the pointer was there; and a three-second backstop catches the case where neither event comes, which is long enough to read a five-word label several times over. Both apply to hover alone: a keyboard reader has not asked for their label to vanish mid-sentence, and that bubble is closed by blur, which unlike pointerleave always arrives. The backstop gets its own timer ref rather than sharing the long-press one — a single ref for two unrelated schedules is how a hover cancels a press.left:50% + translateX(-50%), which cannot be clamped at all. Between them, Library and Settings could be panned sideways into blank space on a phone. The pill was also detached from its control, which answers "what is this?" without saying which "this" — and several 44px glyphs sit within a thumb's width of each other in these bars. The replacement is measured and placed in script: anchored to the control, flipped to whichever side has room, clamped on both axes. A fired long-press also swallows the click behind it, exactly as Material does, so holding Delete to find out what it does never deletes anything..info-dotconfirm(): a hand-card modal (title + optional body + Cancel / confirm buttons) over a dark scrim. Escape or a backdrop click cancels. (Card shown here; the real dialog centres over a full-screen overlay.)useConfirm. It returns { ask, confirmDialog } — render the dialog anywhere in the tree and the call site keeps the one line it had: if (!(await ask(question))) return. Thirteen destructive actions were still asking with the browser’s own confirm(), which is untranslated chrome in an app that ships in two languages — and does not exist in jsdom at all, so every one of those deletes returned early in every test that reached it and had never actually run in the suite.FormModal carries it — Edit quote, Edit dialogue, Edit tag, Edit staged quote — arranged through a context rather than a prop, so the pattern arrived without a single call site changing. A form inside a dialog finds its host with useFormHost, wears the id the host gives it, tells the host why it cannot be saved yet, and drops its own footer buttons. The same form rendered inline — the search modal’s editor, the capture surface — finds no host and keeps its footer, because there is no header there to borrow.<label> for the box, so a screen reader landing in the editor had announced nothing at all.type="submit" wired back by the HTML form= attribute, so onSubmit is unchanged and Enter in any field still saves — that attribute is what makes it the form’s default button, and a form with several text inputs and no default button does nothing at all on Enter. It greys out with the form’s own reason on it (a tooltip, so inside the five-word rule), because a header button cannot validate a form it does not contain.ViewIcon (also reused as IconGrid/IconList/IconTable).+’s offer of that kind, the scope chips on Search, and the row in the shortcut sheet. Nothing else moves — the route still resolves, a bookmark still opens, the review deck still draws on the section, and every row stays exactly where it is. Turn it back on and everything is where you left it, which is a promise rather than a hope precisely because parsePath and statePath are not feature-aware and a test says so.+ that offers anything — a broken screen rather than a preference, and the one state a reader could not click their way out of. The last standing switch is disabled with the reason in words beside it rather than in a title a phone cannot show, the server refuses the same set with a 400, and the read path corrects it — because a restored archive is where such a set would otherwise arrive from.false that row’s default. The rule is the zero value, not the word: hideLibrary / hideCatalogue / hideQuotes for the three that are on until somebody says otherwise, showAnthologies for the one that is off until somebody asks. A key defaulting to true would have to be added to the four whole-struct literals the Go suite compares with !=, and an older client sending a partial set would read as asking for that section to change. The polarity travels on the row itself (off: true in SECTIONS) rather than in a second list of the inverted ones, so visibleSections and the card’s writer each branch on it once.--hl-1..4 · catName/catColor/catHidden prefsyellow|blue|pink|orange remains the value in every table, in every Markdown export and in the import rule that reads a missing colour as yellow. Everything here is presentation, which is why a rename cannot break a round trip — and why the Go suite has a test whose entire job is to prove a Markdown export is byte-identical before and after one.color carries a CHECK (color IN (…)) on five live tables — annotations, dialogues, utterances, staged_quotes and tags — and SQLite cannot alter a CHECK. Going from four to six in 1.7.1 therefore meant rebuilding all five, four of them foreign-key parents whose children cascade, three of them backing external-content FTS5 indexes with live sync triggers. tags is the one migration 0018 explicitly refused to touch, warning that the rebuild would take the join rows with it; it could not be left out, because tags.color is validated by the same allowlist the quote colours use, so widening one and not the other turns a green tag into a 500 on a valid request.PRAGMA foreign_keys is a no-op inside a transaction and every migration runs in one. Sixteen mutations hold the rebuild in place, and one of them earned its keep by surviving: removing the FTS index rebuild changed nothing, because an external-content index keeps its own entries. That is the signal a line is either unnecessary or untested, and it was the latter — what it actually repairs is an index that had already drifted, which is what the test asserts now.| Title ▲ | Tags |
|---|
<th> carrying the active sort's ▲/▼ arrow and aria-sort; useSort holds the {col, dir} state and sorts rows via per-column value functions. Shared by the table views and the tag/sticker manager tables.tp-link toggle so the poster beside it keeps a stable height.<mark> with an accent-tinted, unevenly-rounded wash, used to mark hits in Search results.recallStatus() on the server: remembered (a full ring with a centre dot), forgetting (the ring open at the top), probably forgotten (a quarter left, and a cross) and not tested (a broken outline — a card the Daily Quiz has never asked about). Hover names the state and gives the one number that matters at that moment: how long it keeps if it is holding, or that it is already owed a look if it is not.item_recalls.counted, migration 0066) rather than deriving it, because whether an answer counted depended on a setting you can change tomorrow.reviewKindOf: a highlight carries its book, a film line its film, and a standalone quote has neither, which is its kind). It asks the server on every open rather than reading the card underneath it, because the Daily Quiz moves the schedule without the list hearing about it, and a panel whose whole subject is when did I last remember this may not be the one surface showing yesterday's answer.IconQuizSkip — the same glyph the Skip in quiz button wears in the selection bar and the card menu — so the mark on a card is a picture of the act that put it there. Drawn on the CREDIT line, beside the words that locate the quote, while the recall mark it qualifies leads the card’s action row. The two answer one question between them: the mark says how the recall stands, this says nothing is going to ask. Without it the mark on an excluded quote reads due now about a card the deck will never serve. They were side by side while the recall mark was a 9px disc; it is a glyph now, and a glyph belongs with the other glyphs — what stays here is the one of the pair that is a FACT about the quote rather than something you can do to it.review_excluded alone — put one highlight of a skipped book back and it wears nothing, because the quiz genuinely will ask. The work's flag still picks the wording: Skipped with its book (or film, or show) rather than Not in the quiz, because that names where the decision was made, which is where undoing it for the whole book lives — and warns that the next highlight saved there starts excluded.review_excluded on all five search hit shapes. That parent flag is ONE name, work_review_excluded, shared by both quote kinds, and the server's parity test is what decided it: spelled book_review_excluded beside movie_review_excluded it read exactly like book_title beside movie_title and would have passed review as one, but every card would then read book_x || movie_x — and dropping one of the two is a mark that is right on books and silently absent on films. Search had been the one place a quote arrived without its colour, for the identical reason, and a mark that showed on every board and not in results would have been that bug again with a different field: invisible on any one screen, because each screen is internally consistent.quiet variant drops the tooltip and the focus stop, for the two places the mark sits inside a <button> — the work tile and the search hit. A focusable element nested in a button is invalid HTML and browsers disagree about which control a tap belongs to; and Tooltip binds its own long-press, which would have swallowed the tile's long-press-to-select on precisely the corner the mark occupies. The aria-label stays either way and folds into the button's name. On the tile it sits in the count row rather than over the artwork: three overlays already compete for those corners, and keeping the cover unobscured is why the shelf state is a bar and not a badge.fetching links · 31/50
setStates and the shelf chips render, omit it and the row closes up, which is how Quotes (no shelf, no genre, no series) sits on the same scaffold without a wall of nulls. Screen-specific controls arrive through leading and trailing, each with a mobile twin, because the desktop row is inline and the phone's is a labelled stack inside a MobileSheet with a live result count. Filtering is client-side on all three — Quotes moved off its server-side ?speaker= to get here, and gained the fix that comes with it: the speaker list is built from every row, so choosing a speaker no longer collapses the list of speakers to the one you chose.creditNames/credit/setCredit trio rather than an actor-shaped one, because the Library will want the identical control for authors and only the noun differs. The interesting half is not the dropdown. The rule this board follows is that no filter ships without deciding what it means to a search — the board publishes its chips as a search seed, so a filter is also a promise about what pressing Search will do. Two answers were available and they are not the same set. movies.cast_json holds the whole fetched cast; dialogues.actor holds the credit on each line you saved. The list row derives actors from the LINES, and that is forced rather than chosen: actor: in search reads d.actor, so a board built on the cast would filter to one set of films and seed a search that answered with another — a filter whose meaning changes on the way to the search box, silently, in the direction of MORE results, which reads as the search being broken. It is deliberately not in BOARD_ONLY_FACETS, unlike tagged and noted. The facet is dialogue-only server-side, so the Movies section of the results comes back empty — and that is the right answer rather than a compromise, because the client groups dialogue hits under their films, so a search seeded from a Catalogue filtered to one actor lands on the same films with the lines that put them there. Dropping the chip at the boundary would have thrown that away and searched the whole catalogue.onOpenPerson the label becomes a button opening that person's panel — so an author heading in the Library and a speaker heading on Quotes are the same doorway the name on the card is. The buckets come from groupWorks, which splits a credit dimension into the people it names (a book by two authors appears under both) and files everything else under a residual bucket. Quotes names its own residuals — No speaker, No kind, No place — because a proverb has none of the four and a bucket called "None" would not say which.groupWorks: that one orders buckets by label, which is right for a shelf of authors and wrong for all four of these. Missing values sink rather than float, always: a quote with no location is not "location zero".quoteBody prefers rather than obeys, because a setting that empties every untranslated card reads as a bug that has eaten the library. The table honours it too, which is the one view where the translation was never drawn at all.{ id: 'nav' } in the second, which the shell swaps for the boards key above — only the shell knows which sections are switched on and only the shell can change tab, so the screen names the seat rather than building it. That is how the Library, the Catalogue and the Quotes page keep their filter and still get the way out; it is also what fills the second seat on Metadata, where a reader who is not an admin has one verb and used to have a blank beside it.+ at the end of its row both open it: every id the medium has, filled or not, then the stored links, then the box. Its one ✓ saves the edited ids and a pasted link in a single request and counts both; it is greyed while it would write nothing. A work with no links lands straight on the box, with no empty list in between./name/<id>/ and not /person/ or /people/ before they could paste anything — three words for one idea, and the app has known all three since it started fetching portraits. So the add surface also takes the id: pick the provider, type the id, and the address is written and shown before it is kept. Every pattern is copied from internal/metadata/people.go rather than derived, so a link typed by hand and a link fetched from TMDB are the same string. The list offered is the record's own: an ASIN goes to the author page and never to /dp/, because an ASIN names a product and would file a book under the person; and a studio or a publisher is offered IGDB's company space instead of the four person ones, because nobody has an IMDb /name/ page for Electronic Arts..com is a real page for a different edition — wrong in the way that looks right. The paste box below is unchanged and is still the way in for all of them.providerMarks.js. Twelve brand hues in one panel would be the loudest thing on a screen made of paper, and a links panel that phones a dozen companies to draw itself is exactly the request this app promises not to make.range fires a change for every step it crosses, so writing through on change means one save per step and a preference that briefly held four values on the way to the one that was wanted — which is why this is a component rather than a bare input.readout given by the caller replaces it — for stops whose meaning is a word rather than a number. The Metadata screen's per-language rows are that case: four states saying how much of the original a card shows.Settings.jsx and now lives here, because a second screen needed it. A control drawn on two screens is one function both call, not a line each — which is the repo's directive and the reason the two would otherwise have drifted the first time either changed.showWord), and that is where a reader learns that this drawing means a work and that one means a skip. Where the row is tight — a card caption, a console row already carrying a tick box, a cover, three action glyphs and a chip row — the glyph stands in for the word. The second only reads because the first taught it, which is why they are one component and not two.showWord decides whether the noun is painted, never whether it is said. The whole tally carries one aria-label of "27 skipped", because the figure and its noun are one fact — met as two, they arrive either side of a fraction’s slash.tone="warn" takes the danger colour on the figure and its glyph, since a red number beside a grey drawing says the number is the warning and the noun is neutral. A count of things the reader owns is never red.a-count-wears-its-glyph.test.js is the list of which those are and why — a measure in "characters" that means letters, a confirm dialog’s sentence, an <option> that can only hold text.--type-ui-17, an optional InfoDot beside it, and a right slot for whatever the card acts on itself — a toggle, a count, a button. The dot goes through the heading rather than beside it, so the paragraph a card used to spend three lines on is one glyph on the line the reader is already looking at, and the card's first content is content.Settings.jsx and eight cards used it. The ninth — metadata sources — moved to its own screen, and a component two screens import is a shared component: importing it back out of Settings would have pulled that whole route's chunk into another one to draw an <h2>.data-scroll-x (or -v) as start, end or both, and removes it when nothing overflows — a gradient left permanently on would promise content to a row that has none, and one fade that lies makes every other fade in the app a maybe. A ResizeObserver on the box and its children, re-seated by a MutationObserver when the child list changes, is what keeps it true; all of it coalesces into one frame and is silent when nothing moves.overflow alone is a touch-only affordance — a plain mouse has no sideways gesture — so the box also follows a press, with a drag past 3px swallowing the click that ends it, so pulling a row along never opens what you let go on. The listeners sit on the box and use pointer capture rather than on the document: nothing in the app listens at rest. (This demo sets the attribute by hand so the fade is visible standing still; in the app the hook computes it.)Authors and actors are clickable everywhere (people.jsx). Clicking a name opens a redirect link menu of that person's external reference pages — IMDb · TMDB · TheTVDB · Wikipedia · Open Library — auto-fetched on first open; bio/photo details sit behind a secondary view. The Metadata tab's People console manages the links in bulk.
{kind, name, person} — because the panel is reached by id; where the chip has no record, POST /people/ensure files one and the press lands on the same screen. The click stops propagation so it works inside clickable cards.tp-link that carries a glyph. Two things it settles, neither of which a call site can: the icon set is drawn at 24px against 12.5px link type, so the svg is sized here in em and rides whatever size the link is set to; and the underline moves onto the words. Text decoration cannot be cancelled by a descendant, so a rule drawn under the glyph as well reads as a strikethrough across the whole control — which is why this pattern wraps its label in a span.usePersonOpener is the single router every onOpenPerson is given: a name with a record opens the record, and a name without one GETS one — POST /people/ensure files the row and its role and hands back the id. An older modal, reached by kind + name, used to take that second case; it is deleted, and a press that cannot be served now says so rather than opening anything. That routing existed in exactly one screen until 3.1.0: eighteen other call sites handed the credit their raw modal setter, so this screen was not missing but unreachable from all but two places, which looks the same from the outside.tp-chip tp-chip-btn anchor per recognised provider. Shows a "—" microcopy when nothing is saved. With marks it draws each supplier's ProviderMark instead of its name, which is what the People console's row uses: eight suppliers spelled out was most of a row spent on a vocabulary the marks already say, and the word survives as the mark's accessible name and its tooltip. There is no Links column any more — the row's second content line is where they sit.DELETE /people/{id} refuses a record a work still credits; the row is sent the credit count and gates on that, so the glyph is never a press that 409s under a confirm promising the bin. The row action takes one glyph across both its words: fetch and refetch are the same act — go and get this person's photo and links — and the label flips only because the row already has some, so two drawings would claim the acts differ. Bulk fetch wears IconMetadata, the same arrow-landing-in-a-record the covers console uses, and Re-verify wears IconFetch, matching the works bulk bar — both go and ask a provider, which is what that glyph now means and what IconRefresh used to mean alongside two other things. This metadata is what the Person panel behind every clickable name draws; actor lookups need a TMDB key (Settings). Rows stay listed even when no longer referenced so stale metadata remains manageable.work_cast and six routes to edit it and said in its own header that the screen was deliberately pending; 0049 and 0050 added the character image and somewhere to keep it, and POST /cast/{id}/image was written so “a client may call this for every chip it is about to draw”. No client ever did — so a library could hold a full cast with a TheTVDB art URL on every row and show the reader neither. Opening this panel is what finally asks: it fetches the character pictures that have a provider URL and no file yet, one at a time and capped at twelve, because twenty rows would mean twenty outbound connections from a self-hosted box the moment somebody opened a panel.save with the same registry instead. Without that, correcting a character name and pressing the tick closed the panel and threw the name away, under a control that had just said it saved. Both names are controls, and each leads to its own record. Until 2.3.0 only the actor was a link, so both names led to the actor: the character was flat text and the only route to its picture was working out that the small round face to its left was a button. 2.3.0 made the character name open this row’s picture editor; it now opens the character’s own page, scoped to this work, which is what a reader pressing a character’s name is actually asking for. The picture editor has not moved — it is what the face beside it opens, which is where it always was. A row nothing has linked to a characters record yet keeps the picture editor on its name, because a link to a page that does not exist is worse than the affordance it replaces.work_cast.origin in four states, of which the middle one is the reason it is four and not three. A provider row is the supplier’s, untouched. A corrected row is the supplier’s and then edited by you — it keeps the supplier’s mark, because that is still where it came from and a refetch still owns its billing and its ids, and its label says you corrected it. A reader row is yours, in the accent. removed is a tombstone and is never in the list.<datalist> on the argument that the browser’s own list is strictly better here — it filters, it does not steal a phone’s keyboard, it cannot refuse free text. Two thirds of that is still true. What the argument missed is that <datalist> specifies almost nothing about presentation: desktop Chrome opens it only after a keystroke, so a reader who had typed nothing saw nothing and had no way to learn the list existed. For a memory aid that is the whole of its value — the box you open in order to be reminded of a name cannot require you to remember it first. The chapter fields keep the datalist, because none of that applies to a number you were about to type anyway.Tactile faces come from real texture tiles (grayscale, blended behind content) and the switches don't just look different — they move differently. Switch the Aesthetic/Theme above to see each surface change.
--grain-card::after dither that breaks up 8-bit banding in the low-contrast gradient.--grain-accent.btn-sticker also stopped being the one accent control that ignored the aesthetic: it wore leather, an ink border and a −0.5° tilt under BOTH skins, so Settings, Profile, the work-detail pages and the tour showed tilted leather while every other screen showed level rubber — from one component.--sel-scrim — dark scrim over textured accent surfaces (.12 light / .2 dark) so the light active label reads.
--thumb-ease / --thumb-dur — the slide: firm settle on paper, springy overshoot on film, rigid + quick for the nav.
--press-a / --press-a-dark / --press-r — the press-bloom on .tactile / .tp-btn (radius + alpha of the depress ring); the nav sets these to 0 for no give.
--grain-accent (130px) fabric/rubber on any accent-filled control · --grain-card (300px) paper/metal on a card face · --grain-shell (320px) wood/metal on a shell panel · --grain-shell-sm (185px) the same tile on a bar · --grain-scene (420px) the backdrop. The three shell values stay different on purpose — the tile is a picture of a real surface, so a full-viewport backdrop and a 999px pill want genuinely different scales or the pill shows one blurry plank — but they have names, so the next surface picks a role instead of a number.
--on-accent / --on-accent-dark — what rides ON an accent fill; the two hexes were restated verbatim in fifteen rules, fifteen answers to one question that agreed by coincidence. --hl-1..4 — the four highlight colours, which existed in four places (this stylesheet, ui.jsx, StagingPage and StatsPage). Two remain and a test asserts they agree: a canvas cannot read a custom property, so the share image needs a real hex, and drift there means the blue you see and the blue you share are different blues. .tp-scrim / --on-scrim — the modal dim, previously written inline at ten call sites across nine files.
--raised fills sitting on top of textured cards; the card and accent variants are deleted, because the card face is .hand-card's own gradient and the accent face is the ::before tile over the accent fill, and neither ever wanted a utility class.::before, wood.webp (paper) · metal.webp (film).nav-toggle's thumb wears.z-index:-1 pseudo-element: the two sheet surfaces were the only shell chrome that was position: static, and are relative now..grain-overlay is a fixed layer at z-index: 60 — above everything, multiplying over every glyph, every quote and every input on the screen. It is 5.5% opacity and it is the whole point of the design; for a reader who has asked their operating system for more contrast it is 5.5% of noise standing between them and the text, and neither of the two ways a person says so was honoured anywhere in the file.tex-paper) or brushed metal (tex-film) tiled behind the callout; dark-combo flips the blend to screen for dark pairings. Its tile size derives from --grain-card and its dark opacity now matches .hand-card's — a preview of a card was previewing it at a different grain strength, which is exactly enough to be invisible alone and wrong side by side..reveal + the useReveal ref fade-rise in as they scroll into view. Both are disabled on mobile — ancestor transforms un-stick the sticky bars — and under reduced-motion.Covers (books) and posters (movies) share one local store and one placeholder.
poster-card after a class that does not exist anywhere in the source, which is its own small lesson about hand-written documentation.actionsFor, which stayed quote-only after bulkActionsFor grew a work branch in 1.11.1. So the bar could Fill gaps, skip in the quiz, edit and delete a book with exactly ONE thing selected, and the tile that one was selected from could do none of them — the precise asymmetry the registry exists to make impossible, invisible for three releases because every test walked item → bulk and never the other way. There is a test in both directions now.useBulkOps — the same hook the bar calls, with one id instead of forty — because two implementations of “skip this in the quiz” is how a card and a bar come to disagree about an act whose result the card then draws. Delete asks once and does not ask you to type, unlike the bar: the bar’s phrase guards a number you can misread over one shared Undo, and here the subject is the cover you just right-clicked. The dialog names how many quotes travel with it, because a cover gives no hint that twelve are attached. Favourite is absent, and that is a constraint rather than a choice — a work’s ♥ is a field of the full-state PUT, and a board holds the LIST row, which carries no description, no ISBN and none of the other credits; setting it from a tile would send the short row back as the whole book..cover-tile:hover .cover-lift). Hover this tile to see it.large variant, and a hero variant that fills its sized wrapper at 2:3 for the detail header. On mobile the .book-detail-hero stacks — the .cover-wrap centres at 140px above the text.none · ✎ that opened a grid the row could not preview; this is one object instead — the picture at a usable size, its real pixel size stated underneath, and the verbs that change it in a 2×2 grid at 36px, so it reads as one thing rather than a thumbnail that happens to sit near a toolbar.naturalWidth/naturalHeight of the bytes the browser loaded, which are the stored file's because /covers/{name} is a plain http.ServeFile — the day that route learns a ?w= variant, this line becomes a confident lie about the exact number a reader uses to decide whether to replace their cover.--error, and the floor is COVER_MIN_W — the same 500px the server's lowResCoverWidth uses to decide whether a refetch replaces stored art, and the same test Metadata's low-res gap counts. The design pack proposed 400×600; a height half would have inked a 600×500 cover red with nothing on the server willing to change it. cover-floor.test.js reads both sides so the two cannot drift.0×0 px and inked — it is not usable either. A picture still loading, or one the page is forbidden to draw (a pasted URL from a host img-src 'self' excludes), states nothing: it has a perfectly good size this page cannot read, and zero would be a plain lie about it..ph hatch, and they are not interchangeable. Only the rectangle has a caller in the app today — the portrait half lands with the person panel, so this page is where it is seen at all.The dialogue list and the login card are built as a strip of film (switch the aesthetic to Film to see them best).
"Here's looking at you, kid."
RICK · 00:33:12overflow-wrap:anywhere) instead of widening the page. Unlike the strip it renders in both aesthetics — and since 1.6.0 it renders differently in each, with the card material, the dither and an aesthetic of its own: contact-sheet card stock under paper, a lit panel with the amber hairline under film. See card grain above.Two full-viewport decorative layers behind/above every screen (App.jsx).
Where a work stands with you. Drawn under the cover, never over it — the artwork stays whole, which is the entire reason it is a bar and not another badge on the poster.
--accent is deliberately not used for reading — it sits a few degrees from --error, and a bar you have to squint at to tell reading from abandoned is no bar at all. radius matches the artwork above (8 for posters, 0 for a book's own clipped card).null prints nothing at all rather than zeroes: a hero that flashes “no quotes yet” before the quotes land tells you the book is empty, briefly and wrongly, on every visit.minusQuote covers the one path that cannot wait for a reload — a single delete, which the board already decrements optimistically — and it subtracts what that row contributed rather than blanket-decrementing, so the breakdown cannot drift a little at a time.tone): there is no page class to inherit, the Catalogue's credit line sets amber inline, and a terracotta total under an amber credit reads as two unrelated systems on one card.Wishlist
nothing quoted yet
12 bookswishlist chip that already exists. Nothing moves, nothing is stored, and there is no membership that could disagree with the count printed on a cover — which is the same reason the wishlist has no column: a second source of truth for something already computable. A book leaves the folder by itself the moment you save a quote from it.A translator and an editor, beside the author. Both live in internal/changelog.
And a second paragraph, under the same bullet.
max-height: 62vh; overflow-y: auto, and on a page that is a bare nested scroller with no fade and no way out.::before rather than list-style, so a two-paragraph entry keeps one mark at the top instead of a marker floating beside its middle.dangerouslySetInnerHTML anywhere in it. inlineMarkdown handles the three forms the file actually uses — bold, code, links — and shows anything else verbatim, which for a changelog is an honest failure: you see the asterisks. Links are refused unless they are http(s), by rule rather than by trust, because the rule costs a line. The multi-paragraph structure is preserved by the SERVER parser, since a naive line-splitter flattens a bullet's continuation paragraphs into it or drops them — silently, so the log just quietly says less than the file does.tr. and ed. in the mono voice, small, ahead of the faces.publisher in MOVIE_FIELDS — WorkDetails.jsx · migration 0042people rows — kind studio and kind publisher — both wear a logo where a film shows a director's face, and both chips open the company's own page: its founding, its links, and every work it is credited on. The publisher used to be plain mono text after the studio, and this entry used to explain why: it had no row, no logo and no page, and 0037's bar is that a new person kind must carry BEHAVIOUR rather than a label. 0042 named the condition for changing its mind — "if a publisher ever gets a logo and a page, it can become a kind then" — and it now has both, resolved by the same IGDB /companies lookup a studio uses, because that endpoint does not distinguish them: one company record, and which of the two it was on this game is a fact about the credit. PUB. survives as the label beside the chip.involved_companies is a set of flag pairs in no meaningful order, and a label that owns the studio it published through is routinely entered as developer and publisher — which is how "the first row flagged developer" resolved to EA while BioWare sat further down the same array flagged developer alone. The company with the narrower claim wins. It narrows an answer and never blanks one: a studio that publishes its own game is named in both fields, because both are true of it. Wikidata's P178/P123 get the same rule, and its logo is read off the entity the name came from — otherwise the icon and the credit beside it would describe two different companies.media_type filter where the studio's must: movies.director holds two facts split by medium and movies.publisher holds one; and it is outside the search index, because a fourth column on movies_fts means the drop-and-rebuild dance 0029 is the record of, bought for a field with one reader.-380 reads as 380 BCE, because a minus sign in front of a year reads as a countdown. CE is left unmarked — saying "1954 CE" about a novel is pedantry, and the era is worth naming only when it is the unusual one. The field also reads what it writes, so 380 BC, -380, c. 380 BCE and circa 380 BCE all arrive at the same year. c. is an estimate and display-only: a text written over a century does not have a publication date, it has a contested guess, and marking it never moves the work on a shelf or a chart..tl-dots / .tl-dot — index.cssYYYY, YYYY-MM and YYYY-MM-DD sort correctly against each other lexically. "I read it in 2019" is a real answer, and padding it to January 1st invents a precision nobody has.01:12:40 says nothing without the episode it is 01:12:40 of. Shows only, and null is the only "unset" — season 0 is a real season, the specials strand, so S0E1 has to be storable and distinguishable from "no episode recorded". A season with no episode is fine; an episode with no season is refused, because it cannot be ordered against a numbered season. Sort order is season, episode, timestamp, id — with specials first and un-episoded lines last. Two occurrences of a recurring line in different episodes are two quotes, not one, since the occasion is part of what identifies a line.Every import parses into a holding area and stays there — indefinitely, across sessions, books and films mixed together — until it is explicitly approved.
p.142 minus 5 is p.137; a range moves at both ends; leading zeros and thousands separators survive; clock values convert through seconds and come back with their original padding. Detection is by value, not by field. Formulae chain, and reset restores every row's as-imported snapshot — so one applied by mistake is undone rather than lived with.The app used to explain itself in standing prose — Settings card copy, drawer subtexts, microcopy under controls. Good writing, and a lot of it, and it is why some screens read dense. The rule now: a label (what this control is) and state (what is true right now) stay on the page; an explanation (why it exists, what the trade-off is) moves into an info dot. And every screen carries a ? that lists its own controls.
html[data-labels="off"] — see Button labels for the mechanism, which this reuses rather than restating.keepLabel, and therefore no has-btn-icon, or it would be crushed to a glyph's width with its words still inside). It is the default scope and the way back from a narrowed search — not something to have had to learn a glyph for. A collapsible chip also carries its own Tooltip: with the words clipped, the bubble is the only label left, and a phone is exactly where the clipping happens.icon prop renders a .btn-icon glyph and wraps its words in .btn-label, and one CSS rule under html[data-labels="off"] clips that span — so a whole row collapses to glyphs with no JavaScript at any call site.aria-label has to be bolted on and then kept in sync with visible text that might change. Every glyph also still names itself on hover, on focus, and on a 500ms long press.keepLabel is the opt-out and works by having no rule at all — primary submits and destructive confirms take it, because a glyph is something you have to have learned already and "delete everything on this server" is not a thing to find out by trying. Note which condition sets has-btn-icon, the class that squares a collapsed button to 44px: it is icon && !keepLabel, marking a button whose words can disappear rather than one that merely has a glyph. Set on the wrong condition — as it was, briefly — a keepLabel button carrying an icon is crushed to icon width with its words still rendered inside it.html[data-labels="off"] .btn-label is not scoped to .tp-btn. A chip row that collapsed on its own measured width, or behind a media query of its own, would look identical the day it landed and disagree with every button in the app the day either was touched. All a chip needs of its own is the shape: 34px rather than a button's 44, because a chip is 34 at rest and squaring it taller would make the search screen's scope row stand higher than the controls beside it — and 44 inside the phone block, where the chips are already at thumb size and a 34×44 glyph is not a square. keepLabel works on a chip as it does on a button, and search's All takes it: the default scope and the way back from a narrowed search is not something to have had to learn a glyph for..info-dot<button>, not a hoverable span: on a phone there is no hover, which quietly made "move it into an info dot" a non-answer on the device this app is built for. A transparent ::after grows the tap target to 30px without moving any neighbouring text, and the click preventDefaults — these sit inside rows and labels that are themselves clickable, and asking for help must never also trigger the thing behind it. Second chip shows the open state.ISBN
.info-pop / .info-pop-anchored / .info-pop-centred--caret-x) because several dots often sit within a few pixels of each other and "which one was that" is a real question. On a phone it is a compact centred card over a scrim — a 40px anchor on a 360px screen gives no useful direction, and the finger is already covering it. Repositions on scroll in any ancestor, not just the window, since these live inside scrollable cards and sheets..help-btnIconButton and .tp-btn's minimum so it never reads as the odd control in a row. It opens the help panel for whichever screen you are on, resolved from the route (see helpScreen in App.jsx). It was drawn per screen until 1.4.1 — eleven call sites, in each page's own header — which made it a property of eleven pages instead of one thing in one place, and on a phone it competed for the single row a page title also needs. Two exceptions remain, both because the shell bar is not there: the work-detail screens put it in their ⋯ menu (that phone bar already carries a back arrow, a filter, a + and a ⋯ , and a fifth 44px control would leave a book title about eighty pixels to live in), and the full-screen Profile page carries its own. In the phone bars it drops its ring and lets the glyph carry the affordance..help-list / .help-row / .help-row-icon; copy in help.jsxhelp.jsx) rather than beside each component, so a control explained in one place cannot contradict itself in another — and each screen's list has the shell's own controls (☰ · + · Search · the bottom bar · the avatar chip) appended, so the phone bars are explained wherever you happen to ask. Full-screen on a phone, a centred dialog on desktop: unlike a single-sentence info dot, a twelve-row list earns the whole viewport..inline-field / .inline-field-value / .field-icon-btn--faint rather than an empty row. Two details that matter: Enter does not save when the editor is a token list (Enter is how you add a genre, and committing on it would close the row on your first tag), and a failed save keeps the editor open with your text intact — closing first is snappier and throws away what you typed the moment the request fails. Discs are 34px on desktop, 42px on a phone. Rows holding a name or a title (nameCase) capitalise the first letter of each word as you type, so the row saves the string you can see — and only ever promote, never lower-casing what you typed, so "McEwan" and "eBay" survive where the genre rule would return "Mcewan" and "Ebay". Re-case a word by hand and the field stops correcting you for the rest of that edit, which is what keeps "bell hooks" typeable. A row can also read as something other than its stored value: the TMDB / TheTVDB id rows show a link to the record they name rather than a bare number, while still editing as one — they spent a release read-only, on the reasoning that an id is written by picking a match, until it became clear a title search cannot tell two films of the same name apart and typing the id is the only way to say which you meant. The IMDb id joined them in 1.14.2 and is the odd one of the three: nothing is FETCHED with it, because IMDb has no public API. It is stored as tt0111161 rather than as a number — the leading zeros are part of it, so a numeric column would give back a URL that 404s — and it accepts a pasted URL, because copying an address bar is how anybody actually gets one. A TMDB fetch fills it in from the external_ids appendix that rides along on the call the credits already needed, so it costs no extra request; a re-sync that finds none leaves a hand-typed one alone, since a supplier is the authority on what it knows and never on what it does not..merge-row / .merge-check / .merge-old / .merge-new.reading-badgeGood ${morning|afternoon|evening}: three strings for 365 days. Now a pool chosen from what the device knows — its clock (six buckets, because "Good evening" at 23:50 and at 17:05 are the same sentence for very different moments), its date, and its IANA time zone. All local: no locale asked of the server, nothing sent anywhere, no network call. A date earns a place only if it is the same Gregorian month and day every year or computable from an exact rule (Easter's computus; "the fourth Thursday in November") — so no Diwali, Eid or Lunar New Year, which move yearly and differ by country in the same year. National days beat international ones on a shared date (25 December is Quaid-e-Azam Day in Pakistan), and commemorations say Marking …, never Happy..drawer::before / .mobile-dock::before — index.css.hand-card: isolation:isolate plus a z-index:-1 ::before, so the grain sits above the surface's own background and below its content, and the blend flips to screen in dark mode where multiplying a grey tile just makes mud.One selection mechanism over five kinds of thing, entered three ways, acting through one registry. The tick and the ring are the whole of the visible mode — there is no button to get into it. There is now one to get out of, and it had to exist: the mode outlives the picks, so emptying the selection no longer tears the bar off the screen.
You cannot buy the revolution.
CH. 13 · P.301
.is-selecting) — the cards you have not picked are half the answer to “what am I about to act on”. It no longer stands permanently on a phone, which it used to: there was nothing to select with then, and a hollow circle on every cover for a mode nobody had entered is a lot of furniture for an affordance the long press now provides.:focus-within reveals are gated behind (hover: hover), because a tap leaves focus on the card it landed on — so a phone was keeping the dot lit on the card that had been long-pressed after everything was deselected, until a reload, which is the only thing that drops the focus. Nothing was selected and one card said it was. .is-selecting now tracks the mode rather than the count, which makes the pairing the rule: the ticks are up while the bar is up, and dismissing puts every one of them away together.count > 0, so taking the last card off tore it off the screen mid-task — “these four are the wrong four” cost a fresh long press to undo. It now renders on the mode: Deselect all empties the selection and leaves the bar standing, reading no books selected with every action disabled, and the × ends the mode and takes every tick on the board with it. Escape does the same, so the way out is not something you have to find. Zero is spoken in words rather than shown as a bare 0 — the badge reads 0, dimmed and disabled, and its accessible name is no books selected, so the reason is carried where it can still be heard once the words are clipped; the actions are disabled rather than hidden, because a row that changes shape at zero is a row you have to re-read. The kind is kept at zero too — drop it and an empty selection of books would render the quote actions, since isWorkKind(null) is false.⋯.actions.jsx (where: ROW | OVERFLOW), not here, and a test asserts the count is exactly three: a fourth fits on a desktop and pushes the count off the screen on a phone, silently. The three that fold away have one thing in common — each needs something MORE from you before it can run. Tags need a keyboard, the seal needs a picture chosen, Delete needs a phrase typed. Standing open in the row, the tag field was the widest control in the strip and was open on every selection whether or not anybody meant to type into it; it asks in a dialog now.⋯, opening the same form the card's own ⋯ opens. Pick a second and it is gone rather than greyed. Set fields is its mirror — two upwards only — so a selection never shows two ways to change the same fields, and neither is ever a dead control. THE COUNT IS THE CONTROL (1.13.0), and the bar finally obeys Button labels. It was built entirely from glyph-only primitives — IconButton and MoreMenu, neither of which rendered a .btn-label span — so Show had no name to reveal and Hide had none to clip. The one row in the app that ignored the preference in both directions, on the surface with the least room, where it matters most. Nothing failed: it looked right on a desktop, which is where it was built. Both primitives now take an optional label and render the same two spans Button does, so the existing collapse rule does all of it.⋯ stays nameless on purpose: More beside three dots is the same thing said twice.-webkit-touch-callout: none on the card, meant a finger could not select half a sentence out of a quote — in a note-keeping app, which is the one gesture a phone has for reaching into text. And long-press-to-select is what every photo grid, file manager and mail app already does; a toolbar toggle is a thing you have to be told about..card-text marks the quote and the note — set by ExpandableText, FlowQuote and HandNote rather than by each card, because those are the prose everywhere they appear, and a card that forgot the class would be a quote nobody could copy from with no visible sign of it. A cover tile carries no .card-text at all: a poster is a picture, so every press that is not on a control belongs to the card.user-select off the card body behind (hover: none) and (pointer: coarse), so the highlight is never drawn on a phone or tablet, and the hook drops any live range at the instant the press fires, which covers a hybrid laptop (it answers hover: hover, so the CSS never applies) and any browser that latches a word before the timer. Gated on the pointer rather than the width on purpose: a tablet at 900px has the gesture and a narrow desktop window does not, and the answer must not change when somebody drags a window. The clear runs only after a press has fired — never on a tap, never after a drag, and never on the quote, where no timer is armed at all — because eating a selection somebody made deliberately is the worse bug..is-menu-target: the same accent ring a selected card wears, because the two are the same statement (this is the card being acted on) and a second look for one idea is a second thing to learn. An outline rather than a border so nothing reflows, and not transitioned — the menu opens instantly, and a ring that fades in over 180ms arrives after the decision it exists to inform.Choose one field and one value. Every selected record gets it; nothing else is touched.
overwrites 3, with 2 different values
/books/bulk has taken every field a work has for as long. The bar simply never passed the callback, and the action reads available: … && !!ctx.setFields — so the menu item was absent, nothing errored and nothing logged. The registry test asserts the action EXISTS and the bar’s tests assert what the bar SHOWS, so neither could see it..tp-warn takes the accent rather than the error red: nothing has gone wrong, this is what the button would do. Empty is a clear and stays offered — a bulk editor that can set a series but never unset one sends you back to forty forms for the mistake it just helped you make. It is offered over SEVERAL and never over one, where the work’s own form is strictly better.media_type is NOT NULL and the server reads a blank as Film, so “(none)” under the words “Empty clears the field” converted every selected show and game into a film — the loudest possible edit made by the quietest possible control, under a sentence that was false. Such a field now offers no blank, promises no clear, and will not apply until a real value is picked. The overwrite warning is not an answer to that on its own: a reader who has just been told the field will be cleared reads it as the cost of clearing.Every delete is recoverable for thirty days. The row is really deleted — a JSON snapshot of its whole subtree is parked instead — so nothing else in the app has to know the bin exists. Since 1.11.2 it is a page at /bin rather than a card in Settings, reachable from the Settings tile and from nowhere else.
deleted 1 Aug · 40 quotes · picture kept · due to go 31 Aug
/bin is a real route, so it bookmarks and survives a refresh, which is what a page buys over a modal. routes.test.js asserts that asymmetry in the direction that could silently stop being true: bin is in ROUTE_TABS and in none of the four nav lists.snapshotContents flattens a payload to the quotes a reader would recognise rather than shipping every column of every row, and a page instead of a card is not a reason to hand over a database dump because somebody clicked a chevron. The expiry is printed as a date rather than a countdown because the purge clock runs on server time and only while the server is up: an instance switched off for a week has not spent a week of anybody's thirty days, so “gone in 3 days” would be a promise nothing here can keep.What a page left in a quote that nobody typed. A page at /cleanup, reachable from the Settings tile and from nowhere else — the bin's shape exactly, and for the bin's reason. Eight rules are run over every quote, note and translation on every visit; nothing about a finding is stored, and the only thing written down is a “no”.
Doubled space · Quote
call» «me Ishmael
→call» «me Ishmael
reference-mark and pronunciation are in that state on purpose, because a letter followed by digits is a footnote index in conscience12 and a name in Apollo11, and two slashes with text between them are a fraction or a URL as often as they are IPA.A colon in the search box turns the words before it into a field. The syntax lives entirely on the client — the server has never heard of a colon — because a grammar both halves parse is a grammar that drifts, and the drift shows up as a query that renders one way and matches another.
.token-menu skin, same arrows-and-Enter as the tag fields on every edit form — and choosing a value lifts the token out of the box into a chip, exactly as TokenInput lifts a typed tag into a pill. The only new idea on the screen is the colon.author:Le Guin unreachable: the space after Le ends the token and the draft stays author:Le forever. Since choosing a value lifts the whole thing out immediately, a draft that runs to the end costs nothing.deth would offer death above dethrone, when you can always type one more letter to reach the former and cannot type your way out of a list that reordered itself.colour:doubt and the wire carries colour=blue; narrowing runs on the name, so typing blu finds nothing — the storage word is not what is on screen, so it is not what is searched. book: does the same with a title and an id.q stopped being required. With neither, it is still refused: that is not a search, it is a request for the whole library.note:, series: and year: are things a reader writes in a note, and “author: unknown” is a phrase somebody could well be searching their own library for. A backslash before the colon (note\:) keeps them plain: no dropdown, and the words are searched exactly as they read, because the backslash comes back off on the way to the server. Only the colon of a known field is affected — a backslash anywhere else is a character the reader typed and means to find. Without it those phrases would be unsearchable, and unsearchable silently: the box would open a dropdown and the words would never reach the query.colour:doubt colour:joy under an all-AND rule means “has two colours”, which nothing does — so that query returns nothing forever and reads as broken rather than as empty. Under an OR rule it means “either”, which is what you would say out loud.tag:stoicism tag:death must intersect: narrowing by a second tag is a real question in a quote library, and OR would widen it — the opposite of what pressing a second chip is for. One rule cannot serve both, so the rule is a property of the facet.author:Gaiman author:Le Guin under AND would be the colour failure again — nothing is by both. A co-written book still turns up for author:Gaiman alone, because the match is a substring of the joined credit rather than an equality.colour:doubt asks for doubt-coloured things and a book is not one, so the Books section comes back with nothing. Ignoring it instead would put the whole library under a heading claiming the results are doubt-coloured: every row real, nothing raised, an answer to a question nobody asked. For the same reason an unknown field name is refused — a dropped facet returns a wider set that looks exactly like a correct answer.tag: and note: are properties of the quote — sending one as the other would empty the books section and a search from a filtered board would come back with nothing.preventDefault still runs, propagation is stopped for the opted-in control, and the handler is called last, once the event is safe.Every glyph the interface draws, and the one rule that decides whether it is drawn or solid.
iconStroke: no fill, a 1.85 stroke, round caps, on a 24 box. Swapping the whole set to a filled pack would double the ink on every screen to fix a problem the app does not have. A key — tick, plus, close, chevron, three dots — is a pen mark with nothing inside to fill; a letterform — translate, the question mark — becomes a blob at 19px. Neither qualifies for the exception below, and icons-fill.test.jsx fails a glyph that fills without naming its reason.users 0.98 — thirteen tabs at thirteen sizes. Each is cropped to its own ink and sized so the long side is 0.82 of the box: uniform scaling, nothing stretched, the drawing still the pack’s.manual, which the database treats as a real answer. Nothing at all means the field has no row, which is "we do not know" and is not the same as "nobody has touched it".<img>. Every mark is an opaque black shape painted over a background-color, so it wears the row's ink — --faint here — instead of arriving in its own brand colour. A dozen brand hues in one panel would be the loudest thing on a screen made of paper, and one file serves both themes. They are vendored, never hotlinked: a panel that phones a dozen companies to draw itself is exactly the outbound request this app promises not to make. Origins and licences: docs/wiki/Provider-marks.md.PUT /books/:id, PUT /movies/:id). Without it the tag lies the first time anybody corrects anything.work_cast.origin had held the answer since 0048 and no screen had read the column. A row can be corrected — seeded by a supplier and then edited by you — which is neither of the two above it: it keeps the supplier’s mark, because a refetch still owns its billing and its ids, and a note on the tag says you corrected it. That note reaches the tooltip and the screen reader rather than the row itself: a cast list is twenty rows deep and a word repeated on each is a word nobody reads.IconReading is solid because it marks what a work is (the shelf chip, the help entry beside it). "Read it again" is something you do, and it was borrowing the state mark, so a menu of five stroke verbs carried one solid glyph that read as a badge rather than a button.Constants and ink roles, read from src/tokens.js rather than restated here. A number typed into a screen instead of taken from there is a bug, not a decision — and tokens.test.js fails when one of these stops matching the stylesheet.
44px999px9px1.4px1.6px15px 9px 14px 10px / 9px 15px 10px 14px20px12px26px1.6em45deg--ink--soft--faint--accent-ui--on-accent--error--ok
Hand-maintained copy, generated CSS. The <style id="appcss"> snapshot is refreshed by
node scripts/glossary-css.mjs (and --check fails CI when it is
stale), so the samples are always styled by the stylesheet the app actually ships —
that snapshot going quietly out of date was this page's oldest failure mode. The entries
themselves are still written by hand; feeding them from
web/frontend/src/help.jsx is what the roadmap's
help & density section still has open.
The Shelf and Pending-imports
sections use inline styles rather than app classes, so they survive a refresh untouched.