Redesign Media Centre as a teletext broadcast information service

Replaces the neutral dark card-based theme with "Page 100": teletext's
locked eight colours at full saturation on pure black, numbered pages
reachable by typing the number (or the r/g/y/c fastext keys), and no
borders, boxes, or radius anywhere — grouping is carried by full-bleed
colour fields and row weight instead.

Chosen via the impeccable skill's replacement-world flow: seven visual
candidates derived from the audience's own world, a mandatory roll
assigned Ceefax/US-cable-guide teletext, six catalog challengers weighed
against it, and the user locked the assigned direction on the decision
page (seed aab4510e).

- New templates for all nine pages (home/watch/library/discover/
  requests/system/activity/login/settings) behind a shared _shell.html
  carrying the direction contract and _macros.html for the row/bar/tile
  vocabulary; deletes the old single-page dashboard.html and its
  dedicated stylesheets/script.
- static/css/base.css + teletext.css: the locked palette, a two-height
  type scale (Chivo Mono / DotGothic16), and the five disciplines the
  system is held to (no in-between tone, no enclosure, one shared bar
  ruler, trend only where real history exists, pages weighted by state).
- static/js/service.js: header clock, page-number jump, fastext keys,
  two-tap arm/confirm add, guarded live polling — replaces dashboard.js.
- app.py: nav_items now carry page numbers; fastext defaults added to
  the page-chrome context processor; version bumped to 2.0.0.
- DESIGN.md + .impeccable/design.json regenerated from the built world
  by the documenter pass; PRODUCT.md and CLAUDE.md updated to describe
  the new system in place of the retired one.
- Fixed a live bug found along the way: settings.html called
  url_for('dashboard'), an endpoint removed earlier in the branch, which
  would have 500'd the Settings page.
- Fixed a One-Ruler violation the finish review's recapture request
  surfaced: --bar-cells disagreed with the bar() macro's hardcoded cell
  count below the 760px breakpoint, so a fill could land mid-cell on a
  phone. Removed the mobile override; the ruler is now 20 cells at every
  width, matching the macro unconditionally.

Passed two inspection rounds (desktop + true-390px mobile, via an
iframe harness since macOS Chrome clamps --window-size) and a finish
review; QA screenshots and interview logs are gitignored as scratch,
not shipped in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
This commit is contained in:
Barely Removable 2026-08-29 21:00:45 -07:00
parent 9a277b9031
commit 4b19cbdfb2
25 changed files with 3002 additions and 2343 deletions

268
.impeccable/design.json Normal file
View file

@ -0,0 +1,268 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-29T00:00:00Z",
"title": "Design System: Media Centre",
"extensions": {
"colorMeta": {
"tt-black": {
"role": "neutral",
"displayName": "Pure Black",
"canonical": "#000000",
"purpose": "The ground, everywhere and unconditionally. There is no second surface colour, because there are no surfaces.",
"tonalRamp": ["#000000", "#0d0d0d", "#1a1a1a", "#262626", "#333333", "#404040", "#4d4d4d", "#595959"]
},
"tt-white": {
"role": "neutral",
"displayName": "Transmission White",
"canonical": "#ffffff",
"purpose": "Body text, row titles, table row headers. The default reading colour.",
"tonalRamp": ["#262626", "#404040", "#595959", "#737373", "#8c8c8c", "#a6a6a6", "#d9d9d9", "#ffffff"]
},
"tt-yellow": {
"role": "primary",
"displayName": "Service Yellow",
"canonical": "#ffff00",
"purpose": "The service speaking in its own voice: mastheads, page numbers, section-band titles, focus reversal, and the warn tier on any threshold-coloured value.",
"tonalRamp": ["#333300", "#4d4d00", "#666600", "#999900", "#cccc00", "#ffff00", "#ffff66", "#ffffcc"]
},
"tt-cyan": {
"role": "primary",
"displayName": "Transmission Cyan",
"canonical": "#00ffff",
"purpose": "Live values and now-showing: the header clock, active figures, links, the currently-playing title. Cyan means this number is moving.",
"tonalRamp": ["#003333", "#004d4d", "#006666", "#009999", "#00cccc", "#00ffff", "#66ffff", "#ccffff"]
},
"tt-green": {
"role": "secondary",
"displayName": "Signal Green",
"canonical": "#00ff00",
"purpose": "Running, healthy, available. ON AIR rows, usage under 75%, healthy progress bars, the confirmation toast.",
"tonalRamp": ["#003300", "#004d00", "#006600", "#009900", "#00cc00", "#00ff00", "#66ff66", "#ccffcc"]
},
"tt-red": {
"role": "secondary",
"displayName": "Off-Air Red",
"canonical": "#ff0000",
"purpose": "Off air, failed, danger. Degraded-service copy, OFF AIR rows, usage at or above 90%, disk temperature at or above 55C, the armed state of a destructive button.",
"tonalRamp": ["#330000", "#4d0000", "#660000", "#990000", "#cc0000", "#ff0000", "#ff6666", "#ffcccc"]
},
"tt-magenta": {
"role": "secondary",
"displayName": "Request Magenta",
"canonical": "#ff00ff",
"purpose": "Waiting on a human. Ombi's pending count and requested events, and nothing else. Its rarity is the point.",
"tonalRamp": ["#330033", "#4d004d", "#660066", "#990099", "#cc00cc", "#ff00ff", "#ff66ff", "#ffccff"]
},
"tt-blue": {
"role": "tertiary",
"displayName": "Field Blue",
"canonical": "#0000ff",
"purpose": "The only colour used as a background rather than as ink. Draws the full-bleed section band and the table-row hover. It never sets text.",
"tonalRamp": ["#000033", "#00004d", "#000066", "#000099", "#0000cc", "#0000ff", "#6666ff", "#ccccff"]
},
"ink-dim": {
"role": "neutral",
"displayName": "Dim Ink",
"canonical": "color-mix(in srgb, #ffffff 58%, #000000)",
"purpose": "The only dimmed value permitted to carry text; at 58% it clears 4.5:1 on black. Secondary row copy, labels, timestamps, the flat trend arrow.",
"tonalRamp": ["#1f1f1f", "#333333", "#4d4d4d", "#666666", "#808080", "#949494", "#b3b3b3", "#d9d9d9"]
},
"ink-dimmer": {
"role": "neutral",
"displayName": "Dither Grey",
"canonical": "color-mix(in srgb, #ffffff 30%, #000000)",
"purpose": "Graphics only. Bar troughs, the mosaic separator, the empty-artwork field, the disabled-button fill, scrollbar thumbs. At 30% it fails text contrast outright.",
"tonalRamp": ["#0d0d0d", "#1a1a1a", "#262626", "#333333", "#404040", "#4d4d4d", "#737373", "#a6a6a6"]
}
},
"typographyMeta": {
"double-height": {
"displayName": "Double Height",
"purpose": "The only size other than body text. Page mastheads, the now-showing title, and the big figures where the number is the content. Set in DotGothic16."
},
"double-height-mobile": {
"displayName": "Double Height (phone)",
"purpose": "Below 760px the double-height row steps to 1.375rem, because a true 30px row eats a phone viewport."
},
"body": {
"displayName": "Body",
"purpose": "Everything else, at one size and one weight, with tabular figures so columns line up on the character grid."
},
"label": {
"displayName": "Label",
"purpose": "Row names, figure captions, buttons, fastext keys, table headers. Letterspacing and case do the work a smaller size would do elsewhere."
},
"field-title": {
"displayName": "Field Title",
"purpose": "The widest tracking in the system (0.22em), reserved for the section colour band and the header row's service name."
},
"input": {
"displayName": "Input",
"purpose": "The one hardcoded pixel size in the codebase. 16px keeps iOS Safari from zooming the page on focus, which would break the character grid mid-interaction."
}
},
"shadows": [],
"shadowPhilosophy": "There are no shadows. The codebase contains zero box-shadow declarations and zero border-radius declarations; the grep returning nothing is the audit test for the whole system. Depth is emissive, not cast: full-saturation colour on pure black reads as lit, a region comes forward by reversing (coloured ground, black text) rather than by lifting, and the single text-shadow in the system is a phosphor bloom at 55% of the element's own colour, spent only on mastheads and the now-showing title.",
"motion": [
{
"name": "tt-transmit",
"value": "tt-in 0.18s steps(2, end) both",
"purpose": "Page transmission. Sections fade in in sequence on load, in stepped rather than smooth opacity, because a teletext page did not appear, it was painted in a few rows at a time."
},
{
"name": "tt-transmit-stagger",
"value": "0.05s per section, capped at 0.35s from the eighth child on",
"purpose": "The delay ramp across .tt-page children that produces the row-by-row paint."
},
{
"name": "toast-slide",
"value": "opacity 0.15s, transform 0.15s",
"purpose": "The only other authored motion: the bottom confirmation strip slides up 100% and fades in."
},
{
"name": "reduced-motion",
"value": "@media (prefers-reduced-motion: reduce) { .tt-page > * { animation: none } * { transition-duration: 0.01ms !important } }",
"purpose": "Both moves are removed outright under reduced motion; content is visible by default, so nothing is lost."
}
],
"breakpoints": [
{ "name": "page", "value": "92ch", "purpose": "The measure. A teletext page was 40 columns; a screen this wide gets double that." },
{ "name": "md", "value": "760px", "purpose": "Double-height steps to 1.375rem, fastext goes to two columns, the bar ruler narrows from 20 cells to 14, the gutter goes to 1rem." },
{ "name": "sm", "value": "560px", "purpose": "A list row wraps its description to a full-width third line; the header clock takes its own line; search stacks." }
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "A reversed row of cells. The entire button vocabulary: no fill variants, no radius, no border.",
"html": "<button class=\"ds-btn\">Search</button>",
"css": ".ds-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 1.5ch; background: var(--voice, #ffff00); color: var(--tt-black, #000); border: none; border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; } .ds-btn:hover { background: var(--live, #00ffff); } .ds-btn:focus-visible { outline: none; background: var(--live, #00ffff); color: var(--tt-black, #000); } .ds-btn:active { background: var(--ok, #00ff00); } .ds-btn:disabled { background: var(--ink-dimmer, #4d4d4d); color: var(--ink-dim, #949494); cursor: default; }"
},
{
"name": "Armed Button (two-tap confirm)",
"kind": "button",
"refersTo": "button-primary-armed",
"description": "A destructive action arms on first press and states its own stage rather than opening a dialog. Red armed, green when done.",
"html": "<button class=\"ds-btn-armed\">CONFIRM?</button> <button class=\"ds-btn-armed ds-done\" disabled>ADDED</button>",
"css": ".ds-btn-armed { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 1.5ch; background: var(--alert, #ff0000); color: var(--tt-white, #fff); border: none; border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; } .ds-btn-armed:hover { background: var(--live, #00ffff); color: var(--tt-black, #000); } .ds-btn-armed:focus-visible { outline: none; background: var(--voice, #ffff00); color: var(--tt-black, #000); } .ds-btn-armed.ds-done { background: transparent; color: var(--ok, #00ff00); cursor: default; }"
},
{
"name": "Quiet Button",
"kind": "button",
"refersTo": "button-quiet",
"description": "The secondary action. Transparent until touched, then it reverses like everything else.",
"html": "<button class=\"ds-btn-quiet\">Save Sonarr</button>",
"css": ".ds-btn-quiet { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 1ch; background: transparent; color: var(--live, #00ffff); border: none; border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; } .ds-btn-quiet:hover { background: var(--live, #00ffff); color: var(--tt-black, #000); } .ds-btn-quiet:focus-visible { outline: none; background: var(--voice, #ffff00); color: var(--tt-black, #000); }"
},
{
"name": "Text Field",
"kind": "input",
"refersTo": "input-field",
"description": "No border, no box. The field is marked by an underscore run pinned to its bottom edge, which turns yellow on focus. 16px type stops iOS zooming on focus.",
"html": "<label class=\"ds-label\" for=\"ds-q\">Search the library</label><input class=\"ds-input\" id=\"ds-q\" type=\"search\" placeholder=\"TITLE\">",
"css": ".ds-label { display: block; color: var(--ink-dim, #949494); font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.16em; text-transform: uppercase; } .ds-input { display: block; width: 100%; min-height: 44px; padding: 0 1ch; background-color: var(--tt-black, #000); color: var(--voice, #ffff00); border: none; border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: 16px; outline: none; background-image: repeating-linear-gradient(90deg, var(--ink-dimmer, #4d4d4d) 0 0.6em, transparent 0.6em 0.9em); background-size: 100% 2px; background-repeat: no-repeat; background-position: 0 100%; caret-color: var(--voice, #ffff00); } .ds-input::placeholder { color: var(--ink-dim, #949494); } .ds-input:focus, .ds-input:focus-visible { background-image: repeating-linear-gradient(90deg, var(--voice, #ffff00) 0 0.6em, transparent 0.6em 0.9em); background-size: 100% 2px; background-repeat: no-repeat; background-position: 0 100%; }"
},
{
"name": "Colour Field Band",
"kind": "card",
"refersTo": "field-band",
"description": "A section header as a full-bleed band of colour with the title reversed out of it. This replaces every card header and every 1px border. Goes yellow when something wants attention, red when a region is down.",
"html": "<div class=\"ds-field\"><span>Now Showing</span><span class=\"ds-field-meta\">2 active</span></div><div class=\"ds-field ds-warn\"><span>Requests</span><span class=\"ds-field-meta\">3 of 41</span></div><div class=\"ds-field ds-crit\"><span>Array</span><span class=\"ds-field-meta\">unreachable</span></div>",
"css": ".ds-field { display: flex; align-items: baseline; gap: 1ch; margin: 0 0 0.5rem; padding: 0 1ch; min-height: 2.175rem; background: var(--field, #0000ff); color: var(--voice, #ffff00); border-radius: 0; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; letter-spacing: 0.22em; text-transform: uppercase; } .ds-field-meta { margin-left: auto; color: var(--tt-white, #fff); letter-spacing: 0.06em; } .ds-field.ds-warn { background: var(--voice, #ffff00); color: var(--tt-black, #000); } .ds-field.ds-warn .ds-field-meta { color: var(--tt-black, #000); } .ds-field.ds-crit { background: var(--alert, #ff0000); color: var(--tt-white, #fff); }"
},
{
"name": "Index Row",
"kind": "nav",
"refersTo": "index-row",
"description": "The signature row: page number, name, description, live value in its status colour. On P100 these are the navigation and the status page at once. The whole row reverses on hover and focus.",
"html": "<a class=\"ds-row\" href=\"#\"><span class=\"ds-row-num\">105</span><span class=\"ds-row-name\">System</span><span class=\"ds-row-body\"><span class=\"ds-row-sub\">Array, disks and indexers</span></span><span class=\"ds-row-val ds-ok\">62% FULL</span></a>",
"css": ".ds-row { display: flex; align-items: center; gap: 1.5ch; min-height: 44px; padding: 0 1ch; background: transparent; color: var(--tt-white, #fff); border-radius: 0; text-decoration: none; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; font-variant-numeric: tabular-nums; } .ds-row-num { flex: 0 0 auto; color: var(--voice, #ffff00); letter-spacing: 0.08em; } .ds-row-name { flex: 0 0 auto; min-width: 11ch; letter-spacing: 0.16em; text-transform: uppercase; } .ds-row-body { flex: 1; min-width: 0; } .ds-row-sub { color: var(--ink-dim, #949494); } .ds-row-val { flex: 0 0 auto; margin-left: auto; color: var(--live, #00ffff); white-space: nowrap; } .ds-row-val.ds-ok { color: var(--ok, #00ff00); } .ds-row-val.ds-alert { color: var(--alert, #ff0000); } .ds-row-val.ds-pending { color: var(--pending, #ff00ff); } .ds-row:hover, .ds-row:focus-visible { outline: none; background: var(--voice, #ffff00); color: var(--tt-black, #000); } .ds-row:hover .ds-row-num, .ds-row:hover .ds-row-val, .ds-row:hover .ds-row-sub, .ds-row:focus-visible .ds-row-num, .ds-row:focus-visible .ds-row-val, .ds-row:focus-visible .ds-row-sub { color: var(--tt-black, #000); }"
},
{
"name": "The One Ruler (bar)",
"kind": "custom",
"refersTo": "index-row",
"description": "Every bar, meter and percentage in the service is this, at one shared width, filled in whole mosaic cells. 60% on the array and 60% on an indexer are the same width and compare directly.",
"html": "<span class=\"ds-bar ds-bar-ok\" role=\"img\" aria-label=\"62 percent\"><i style=\"width:60%\"></i></span> <span class=\"ds-bar ds-bar-alert\" role=\"img\" aria-label=\"95 percent\"><i style=\"width:95%\"></i></span>",
"css": ".ds-bar { display: inline-block; width: min(calc(var(--bar-cells, 20) * var(--bar-cell-w, 0.62em)), 100%); height: 0.8em; flex: 0 0 auto; vertical-align: -0.05em; border-radius: 0; font-size: var(--fs, 0.9375rem); background-image: repeating-linear-gradient(90deg, var(--ink-dimmer, #4d4d4d) 0 calc(var(--bar-cell-w, 0.62em) - 2px), transparent calc(var(--bar-cell-w, 0.62em) - 2px) var(--bar-cell-w, 0.62em)); } .ds-bar > i { display: block; height: 100%; background-image: repeating-linear-gradient(90deg, currentColor 0 calc(var(--bar-cell-w, 0.62em) - 2px), transparent calc(var(--bar-cell-w, 0.62em) - 2px) var(--bar-cell-w, 0.62em)); } .ds-bar-ok { color: var(--ok, #00ff00); } .ds-bar-warn { color: var(--voice, #ffff00); } .ds-bar-alert { color: var(--alert, #ff0000); }"
},
{
"name": "Fastext Keys",
"kind": "nav",
"refersTo": "index-row",
"description": "The four coloured keys along the bottom of every page. Real navigation, not decoration: click one, or press its initial (r/g/y/c). Deliberately not fixed to the viewport, because it is the last row of the page.",
"html": "<nav class=\"ds-fastext\" aria-label=\"Fastext\"><a class=\"ds-k-red\" href=\"#\"><span>100 Index</span></a><a class=\"ds-k-green\" href=\"#\"><span>102 Library</span></a><a class=\"ds-k-yellow\" href=\"#\"><span>103 Discover</span></a><a class=\"ds-k-cyan\" href=\"#\"><span>105 System</span></a></nav>",
"css": ".ds-fastext { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1ch; font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; } .ds-fastext a { display: flex; align-items: center; gap: 1ch; min-width: 0; min-height: 44px; padding: 0 1ch; border-radius: 0; text-decoration: none; letter-spacing: 0.16em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ds-fastext a > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--tt-white, #fff); } .ds-fastext a::before { content: \"\"; flex: 0 0 auto; width: 1.2ch; height: 1em; background: currentColor; } .ds-k-red { color: var(--alert, #ff0000); } .ds-k-green { color: var(--ok, #00ff00); } .ds-k-yellow { color: var(--voice, #ffff00); } .ds-k-cyan { color: var(--live, #00ffff); } .ds-fastext a:hover, .ds-fastext a:focus-visible { outline: none; background: currentColor; } .ds-fastext a:hover > span, .ds-fastext a:focus-visible > span { color: var(--tt-black, #000); }"
},
{
"name": "Poster Tile",
"kind": "card",
"refersTo": "index-row",
"description": "Artwork is the only thing in this world not made of characters, so it sits as a flat rectangle on the grid with its label below. No frame, no rounding, no shadow. A missing poster is a dithered field, never a placeholder illustration.",
"html": "<div class=\"ds-tiles\"><div class=\"ds-tile\"><span class=\"ds-tile-art ds-tile-empty\" aria-hidden=\"true\"></span><span class=\"ds-tile-t\">The Thing</span><span class=\"ds-tile-s\"><span class=\"ds-tile-badge\">92%</span> 1982</span><span class=\"ds-tile-p\"><button class=\"ds-tile-btn\">+ Add</button></span></div></div>",
"css": ".ds-tiles { display: grid; gap: 1.359rem 2ch; grid-template-columns: repeat(auto-fill, minmax(min(12.5ch, 100%), 1fr)); font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; } .ds-tile { display: flex; flex-direction: column; min-width: 0; } .ds-tile-art { display: block; width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 0; background: var(--ink-dimmer, #4d4d4d); } .ds-tile-empty { background-image: repeating-linear-gradient(45deg, var(--ink-dimmer, #4d4d4d) 0 1px, var(--tt-black, #000) 1px 4px); } .ds-tile-t { margin-top: 0.4em; color: var(--tt-white, #fff); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ds-tile-s { color: var(--ink-dim, #949494); } .ds-tile-badge { color: var(--voice, #ffff00); } .ds-tile-p { margin-top: 0.35em; } .ds-tile-btn { display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 2.175rem; padding: 0 1.5ch; background: var(--voice, #ffff00); color: var(--tt-black, #000); border: none; border-radius: 0; font: inherit; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; } .ds-tile-btn:hover { background: var(--live, #00ffff); } .ds-tile-btn:focus-visible { outline: none; background: var(--live, #00ffff); }"
},
{
"name": "Degraded Service",
"kind": "custom",
"refersTo": "field-band-crit",
"description": "Teletext's failure was a page that did not arrive, said in red where the content should have been. No panel, no left-border accent, no icon. The raw exception hides behind a character-drawn disclosure marker.",
"html": "<div class=\"ds-err\" role=\"alert\"><div class=\"ds-err-cause\">Tautulli did not answer. Check that the service is running.</div><details><summary>Technical detail</summary><pre>ConnectionRefusedError: [Errno 61] Connection refused</pre></details></div>",
"css": ".ds-err { padding: 0 1ch; color: var(--alert, #ff0000); font-family: var(--font, 'Chivo Mono', ui-monospace, Menlo, monospace); font-size: var(--fs, 0.9375rem); line-height: 1.45; } .ds-err details { margin-top: 0.3em; } .ds-err summary { display: flex; align-items: center; min-height: 2.175rem; color: var(--ink-dim, #949494); cursor: pointer; list-style: none; letter-spacing: 0.16em; text-transform: uppercase; } .ds-err summary::-webkit-details-marker { display: none; } .ds-err summary::before { content: \"\\25B8\\00a0\"; } .ds-err details[open] summary::before { content: \"\\25BE\\00a0\"; } .ds-err summary:hover { color: var(--tt-white, #fff); } .ds-err summary:focus-visible { outline: none; background: var(--voice, #ffff00); color: var(--tt-black, #000); } .ds-err pre { margin: 0.3em 0 0; padding: 0.5em 1ch; background: var(--ink-dimmer, #4d4d4d); color: var(--tt-white, #fff); font: inherit; white-space: pre-wrap; word-break: break-word; }"
}
],
"narrative": {
"northStar": "Page 100",
"overview": "Media Centre is a broadcast information service, not a dashboard. It refuses the bordered-card grid that every self-hosted *arr dashboard ships — including this one, until this redesign. The world is teletext: Ceefax and the American cable guide channel, fused. Eight colours at full saturation on pure black, one blocky monospace at exactly two heights, a character-cell grid, and grouping carried entirely by colour fields and row weight. The visitor types or clicks a page number, reads the machine's state off colour alone, and leaves knowing whether anything needs them.\n\nThe density is high and unapologetic. A teletext page was 40 columns; this one gets 92 (--page-cols: 92ch) because the screen allows it, but the character grid is the substrate everywhere — padding is measured in ch, bars are measured in cells, and nothing floats free of the row rhythm (--row, one character row). There is no chrome. The header is a row of the page, not furniture bolted above it, so it scrolls away with everything else. The fastext colour keys sit at the bottom of the page where they always sat, not fixed to the viewport.\n\nThe confirmed anti-reference is the world this replaced and every dashboard that shares its reflexes: the rounded, bordered, drop-shadowed card grid with a neutral dark-grey palette and a soft accent. Nothing here is enclosed. There is not one border-radius and not one box-shadow in the codebase — the single text-shadow is a phosphor bloom, and it is spent deliberately. Emphasis is bought with colour and with doubled height, never with a weight ramp, never with a third type size, never with a container.",
"keyCharacteristics": [
"Eight locked colours at full saturation on pure black; a ninth tone is a dither, never a new colour",
"Zero enclosure: no border, no box, no radius, no shadow, anywhere",
"Exactly two type sizes (15px and 30px), one weight, two blocky monospace faces",
"Every bar in the service shares one 20-cell ruler, so any two percentages compare by eye",
"Grouping by full-bleed colour band, indentation and row weight",
"The page number is the navigation: type three digits anywhere and the service jumps",
"Pages differ in stock — a page holding critical state is set heavier than a quiet one"
],
"rules": [
{ "name": "The Locked Eight Rule", "body": "Eight colours exist. Adding a ninth hue, or desaturating one of the eight to make it sit more politely, is a regression rather than a variation. If a new state needs a colour, it takes one of the eight and inherits that colour's meaning.", "section": "colors" },
{ "name": "The Dither Rule", "body": "A mid-tone is an ordered dither of two of the eight at cell scale — a checkerboard or a repeating gradient stepped at --dither-size: 2px or at --bar-cell-w — exactly how a 16-colour screen faked a gradient. It is never a ninth colour smuggled into the palette. The two derived greys are the sanctioned exception, and they derive from white by color-mix, not by hand.", "section": "colors" },
{ "name": "The One Meaning Rule", "body": "Each colour means one thing across all nine pages. Cyan is never decorative; magenta never appears outside a pending request; red never marks emphasis, only failure. A reader who learns the palette on P100 can read P105 without looking at a label.", "section": "colors" },
{ "name": "The Text Contrast Rule", "body": "--ink-dim is the floor for anything a person reads. --ink-dimmer draws graphics and never sets type.", "section": "colors" },
{ "name": "The Two Heights Rule", "body": "Emphasis comes from colour and from doubling the height. It never comes from a weight ramp and never from a third size. Chivo Mono 600 is loaded but unused; reaching for it is the first sign the hierarchy is being solved the wrong way.", "section": "typography" },
{ "name": "The Bloom Rule", "body": "Phosphor bloom (text-shadow: 0 0 0.4em currentColor@55%) is opt-in via .bloom and is spent only where the world is speaking loudly — page mastheads and the now-showing title. Bloom under a dense table of figures costs more legibility than it buys character, so the double-height figures on System and Library carry none.", "section": "typography" },
{ "name": "The Uppercase-Is-A-Role Rule", "body": "Uppercase plus tracking marks a label, a key or a band title. Content — a show name, a username, a raw error — is never uppercased for effect.", "section": "typography" },
{ "name": "The One Ruler Rule", "body": "Every bar, meter and percentage in the service shares one scale, --bar-cells, and is drawn by the bar() macro and nowhere else. 60% on the array and 60% on an indexer are the same physical width and compare directly. Restyling a bar per context is precisely what the ruler exists to prevent.", "section": "layout" },
{ "name": "The Page Number Is The Navigation Rule", "body": "Every page owns a three-digit number (100 Index, 101 Watch, 102 Library, 103 Discover, 104 Requests, 105 System, 106 Activity, 900 Settings, 000 Sign In). That number is the header's first element, the row label on the index, the browser title prefix, and what a visitor types to jump. There is no separate nav bar, and adding one would be a regression.", "section": "layout" },
{ "name": "The Index Is The Status Page Rule", "body": "On P100 every destination row carries its own live figure in its own status colour, so choosing where to go and reading how things are is one act. A navigation row without a reading is an incomplete row.", "section": "layout" },
{ "name": "The No Enclosure Rule", "body": "Nothing is ever surrounded by a border or a box. Grouping is a coloured field row, indentation and weight. This is the exact reflex the previous design was built on and the main thing this world refuses — an added card, panel, outline, divider rule or hairline is a regression, not a refinement.", "section": "elevation" },
{ "name": "The Reversed Cell Rule", "body": "Focus and hover are a reversed cell, the way a teletext menu row selected itself: outline: none, background set to the state colour, text to black. A focus ring is an enclosure and is therefore banned; the reversal is what replaces it, and it must be present on every interactive element so keyboard focus stays visible.", "section": "elevation" },
{ "name": "The Two Sanctioned Lines Rule", "body": "Exactly two horizontal lines exist, and both are characters in the grid rather than borders on a box: the mosaic separator (.tt-sep, a repeating block-graphic run in dither grey) and the underscore run beneath an input, which is how a teletext form showed a field and which turns yellow on focus. Neither may be repurposed as a divider between arbitrary regions.", "section": "elevation" }
],
"dos": [
"Do take every colour from the locked eight (#000000 #ff0000 #00ff00 #ffff00 #0000ff #ff00ff #00ffff #ffffff) at full saturation, and keep each one's single meaning: yellow speaks, cyan is live, green is healthy, red is off air, magenta wants a human, blue is the only background.",
"Do group with a full-bleed colour band, indentation and row weight. A .tt-field opens a region; the next one closes it.",
"Do draw every bar, meter and percentage through the bar() macro so the whole service shares one ruler.",
"Do set a page carrying critical state heavier than a quiet one — .tt-field.warn when something wants attention, .tt-field.crit when a region is down. Uniform texture across the pages is a failure.",
"Do reverse the cell for hover and focus (state colour ground, black text), and make sure every interactive element has it.",
"Do make a tone that isn't one of the eight by dithering at cell scale, using --ink-dim for anything readable and --ink-dimmer for graphics only.",
"Do give a new page a three-digit number, register it in nav_items, and put that number in the header, the browser title and its row on P100.",
"Do keep spacing on the grid: 1ch horizontal, multiples of --row vertical, 44px minimum on anything tappable.",
"Do keep inputs at the 16px literal, so iOS does not zoom the page on focus.",
"Do draw a trend arrow only where the backend genuinely holds a comparison. Today that is one place — SABnzbd's current speed against its stored 7-day average — because no history exists for disk usage or temperature. An invented trend is worse than no trend; extend the rule by adding history, not by adding arrows."
],
"donts": [
"Don't introduce a ninth colour, a tint, or a desaturated version of one of the eight.",
"Don't enclose anything. No card, no panel, no border, no border-radius, no outline ring, no hairline divider, no box-shadow. The audit test is a grep: border-radius and box-shadow must return zero hits across static/ and templates/.",
"Don't add a third type size or a second font weight. If something needs emphasis, colour it or double its height.",
"Don't put bloom on body copy, on tables of figures, or on anything that is not a masthead or the now-showing title.",
"Don't restyle a bar for one context, or give a card its own scale — that breaks the one ruler and with it every cross-page comparison.",
"Don't set text in --ink-dimmer; it fails contrast on black and has had to be corrected once already.",
"Don't fix the fastext strip, the header or anything else to the viewport. Everything scrolls, because everything is a row of the page.",
"Don't solve overflow with an inner scroll area, a truncated list, or content hidden behind a toggle. Give the content the page's full measure, or lay it out in more columns. The one sanctioned scroller is .tt-table-scroll, and only to keep a wide table from scrolling the whole page sideways.",
"Don't reach for a glyph icon set. Trend and disclosure marks are characters (▲ ▼ ▬ ▸ ▾); artwork is the only non-character content in the world.",
"Don't animate anything beyond the page-transmission stagger and the toast. A teletext page painted in a few rows at a time and then held still."
]
}
}