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:
parent
9a277b9031
commit
4b19cbdfb2
25 changed files with 3002 additions and 2343 deletions
|
|
@ -1,75 +1,151 @@
|
|||
/* ═══════════════════════════════════════════════════════
|
||||
Media Centre — shared design tokens + base reset.
|
||||
MEDIA CENTRE — "Page 100"
|
||||
|
||||
Single source of truth for the palette. Loaded by
|
||||
dashboard.html, login.html and settings.html; page-specific
|
||||
rules live in dashboard.css / login.css / settings.css.
|
||||
A broadcast information service, not a dashboard. The world
|
||||
is teletext (Ceefax, and the cable guide channel that did the
|
||||
same job in America): a locked eight-colour palette on pure
|
||||
black, one blocky monospace at exactly two heights, a strict
|
||||
character-cell grid, and grouping carried by colour fields and
|
||||
row weight — never by a border, never by a box.
|
||||
|
||||
To reshade the whole app, change the four hues below —
|
||||
every dim/border variant is derived from them via color-mix.
|
||||
Five disciplines this world is held to. Breaking one is a
|
||||
regression, not a variation:
|
||||
|
||||
1. NO IN-BETWEEN TONE. There are eight colours. A mid-grey is
|
||||
an ordered dither of two of them (--dither-*), never a
|
||||
ninth colour smuggled into the palette.
|
||||
2. NO ENCLOSURE. 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 old design was built
|
||||
on and the main thing this world refuses.
|
||||
3. ONE RULER. Every bar, meter and percentage on every page
|
||||
shares one scale (--bar-cells), so 60% on the array and
|
||||
60% on an indexer are the same width and compare directly.
|
||||
4. EVERY READING CARRIES ITS DIRECTION. A value shows where
|
||||
it is heading, so a disk becoming a problem is visible
|
||||
before it is one.
|
||||
5. PAGES DIFFER IN STOCK. A page holding critical state is
|
||||
set heavier than a quiet one. Uniform texture across seven
|
||||
pages is a failure.
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* ── Surfaces ── */
|
||||
--bg: #141416;
|
||||
--surface: #1a1a1d;
|
||||
--surface-2: #202024;
|
||||
--border: #2c2c31;
|
||||
--border-lt: #38383f;
|
||||
/* ── The locked eight ──────────────────────────────────
|
||||
Teletext had exactly these, at full saturation, and the
|
||||
product's semantics were already the same semantics:
|
||||
green runs, yellow works, red is off air. Do not soften
|
||||
them; the emissive-on-black scene is what they are for. */
|
||||
--tt-black: #000000;
|
||||
--tt-red: #ff0000;
|
||||
--tt-green: #00ff00;
|
||||
--tt-yellow: #ffff00;
|
||||
--tt-blue: #0000ff;
|
||||
--tt-magenta: #ff00ff;
|
||||
--tt-cyan: #00ffff;
|
||||
--tt-white: #ffffff;
|
||||
|
||||
/* ── Text (all ≥ 4.5:1 on every surface above) ── */
|
||||
--text: #edecf1;
|
||||
--text-2: #a8a7b0;
|
||||
--text-3: #7c7b84;
|
||||
/* ── Roles ─────────────────────────────────────────────
|
||||
Each colour means one thing across all nine pages. */
|
||||
--ink: var(--tt-white); /* body text */
|
||||
--voice: var(--tt-yellow); /* the service speaking: mastheads, page numbers */
|
||||
--live: var(--tt-cyan); /* live values, now showing */
|
||||
--ok: var(--tt-green); /* running, healthy, available */
|
||||
--alert: var(--tt-red); /* off air, failed, danger */
|
||||
--pending: var(--tt-magenta); /* requested, waiting on a human */
|
||||
--field: var(--tt-blue); /* the only "background" colour — header fields */
|
||||
|
||||
/* ── Accent + semantics (the four source hues) ── */
|
||||
--accent: #7c86ff;
|
||||
--ok: #68b98a;
|
||||
--warn: #d0a75a;
|
||||
--danger: #dc5b52;
|
||||
/* ── Dither ────────────────────────────────────────────
|
||||
The only legal way to make a tone that isn't one of the
|
||||
eight. A checkerboard of the colour against black at cell
|
||||
scale, exactly how a 16-colour screen faked a gradient.
|
||||
Used for inactive bar cells and secondary text grounds. */
|
||||
--dither-size: 2px;
|
||||
/* --ink-dim is the ONLY dimmed value allowed to carry text: at 58% it
|
||||
clears 4.5:1 on black. --ink-dimmer is for graphics only — bar
|
||||
troughs, the dither field, the separator — because at 30% it does
|
||||
not, and secondary text set in it was failing contrast outright. */
|
||||
--ink-dim: color-mix(in srgb, var(--tt-white) 58%, var(--tt-black));
|
||||
--ink-dimmer: color-mix(in srgb, var(--tt-white) 30%, var(--tt-black));
|
||||
|
||||
/* ── Derived tints — never hardcode these as literals ── */
|
||||
--accent-dim: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
--ok-dim: color-mix(in srgb, var(--ok) 14%, transparent);
|
||||
--warn-dim: color-mix(in srgb, var(--warn) 14%, transparent);
|
||||
--danger-dim: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
--danger-border: color-mix(in srgb, var(--danger) 35%, transparent);
|
||||
/* ── Type: one face, exactly two heights ───────────────
|
||||
Teletext's entire scale. Emphasis comes from colour and
|
||||
from doubling the height, never from a weight ramp and
|
||||
never from a third size. */
|
||||
--fs: 0.9375rem; /* 15px — the single body size */
|
||||
--fs2: 1.875rem; /* 30px — double height, the only other size */
|
||||
--lh: 1.45;
|
||||
--row: calc(var(--fs) * var(--lh)); /* one character row */
|
||||
--cell: 1ch;
|
||||
|
||||
/* ── Type scale (6 steps, no one-offs) ── */
|
||||
--fs-xs: 0.75rem; /* 12px — smallest permitted */
|
||||
--fs-sm: 0.8125rem; /* 13px */
|
||||
--fs-base: 0.9375rem; /* 15px */
|
||||
--fs-lg: 1.125rem; /* 18px */
|
||||
--fs-xl: 1.5rem; /* 24px */
|
||||
--fs-2xl: 2rem; /* 32px */
|
||||
--font: 'Chivo Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--font-dh: 'DotGothic16', 'Chivo Mono', ui-monospace, Menlo, monospace;
|
||||
|
||||
/* ── Spacing / shape ── */
|
||||
--gap: 1rem;
|
||||
--gap-lg: 1.5rem;
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--tap: 44px; /* minimum touch target */
|
||||
--row-h: 4.25rem; /* paired Requests / Indexers row height */
|
||||
/* ── Measure ───────────────────────────────────────────
|
||||
A teletext page was 40 columns. A screen this wide gets
|
||||
double that, so density survives without the grid dying. */
|
||||
--page-cols: 92ch;
|
||||
--gutter: 1.5rem;
|
||||
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
/* ── The one ruler (discipline 3) ──────────────────────
|
||||
Every bar on every page is this many cells wide, so any
|
||||
two percentages anywhere in the service are comparable. */
|
||||
--bar-cells: 20;
|
||||
--bar-cell-w: 0.62em;
|
||||
|
||||
--tap: 44px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--tt-black); }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
background: var(--tt-black);
|
||||
color: var(--ink);
|
||||
font-family: var(--font);
|
||||
font-size: var(--fs-base);
|
||||
line-height: 1.5;
|
||||
font-size: var(--fs);
|
||||
line-height: var(--lh);
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Teletext never anti-aliased into a soft grey edge. Keep the
|
||||
letterforms crisp rather than letting the browser blend them
|
||||
toward tones the palette does not contain. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
a { color: var(--accent); }
|
||||
/* Phosphor bloom. Applied only where the world is speaking loudly —
|
||||
double-height rows and status glyphs — because bloom under a dense
|
||||
table of figures costs more legibility than it buys character. */
|
||||
.bloom {
|
||||
text-shadow: 0 0 0.4em color-mix(in srgb, currentColor 55%, transparent);
|
||||
}
|
||||
|
||||
a { color: var(--live); text-decoration: none; }
|
||||
a:hover { color: var(--tt-black); background: var(--live); }
|
||||
|
||||
/* Focus is a reversed cell, the way a teletext menu row selected
|
||||
itself. No outline ring — a ring is an enclosure. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
background: var(--voice);
|
||||
color: var(--tt-black);
|
||||
}
|
||||
|
||||
::selection { background: var(--live); color: var(--tt-black); }
|
||||
|
||||
/* Browser furniture, themed to the palette rather than left at the
|
||||
defaults that belong to no design system. */
|
||||
* { scrollbar-color: var(--ink-dimmer) var(--tt-black); scrollbar-width: thin; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--tt-black); }
|
||||
::-webkit-scrollbar-thumb { background: var(--ink-dimmer); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ink-dim); }
|
||||
::-webkit-scrollbar-corner { background: var(--tt-black); }
|
||||
input { caret-color: var(--voice); }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,775 +0,0 @@
|
|||
|
||||
/* ── Header ─────────────────────────────────────────── */
|
||||
/* Identity on the left, clock + settings on the right, one row at every
|
||||
width. The service pills used to occupy a second full-width row here;
|
||||
they now live in the Status section at the top of <main>, which is
|
||||
where someone looks for them. Scrolls away with the page — a status
|
||||
bar isn't worth permanent screen real estate. */
|
||||
header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem var(--gap);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand svg { flex-shrink: 0; display: block; }
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Service status pills — the Status section's only content, left-aligned
|
||||
with every other section's content. All six share the row equally and
|
||||
shrink together, so the strip is always exactly one row: never two,
|
||||
never a scroller. Every service stays visible at every width; what
|
||||
gives way as space tightens is detail *within* a pill, in this order:
|
||||
the stat drops below 1024px, then the name shortens below 560px. The
|
||||
dot and the pill itself never go, so no service is ever hidden. */
|
||||
.svc-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Full --tap height now they're content rather than header furniture. */
|
||||
.svc-pill {
|
||||
flex: 1 1 0; /* equal share of the row, all shrink together */
|
||||
min-width: 0; /* ...which flex items won't do without this */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.9rem;
|
||||
min-height: var(--tap);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 100px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.svc-pill:hover { border-color: var(--border-lt); color: var(--text); }
|
||||
.svc-pill.down { color: var(--danger); border-color: var(--danger-border); }
|
||||
.svc-name {
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.svc-stat {
|
||||
color: var(--text-3);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.svc-pill.down .svc-stat { color: var(--danger); }
|
||||
@media (max-width: 1024px) {
|
||||
/* Six stats stop fitting here. The dot already carries up/down, so the
|
||||
stat is the first thing that can go without losing any status. */
|
||||
.svc-stat { display: none; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
/* Tighten the furniture so the names keep their characters longer. */
|
||||
.svc-strip { gap: 0.3rem; }
|
||||
.svc-pill { padding: 0.35rem 0.5rem; gap: 0.3rem; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
/* Six full names in one row leaves ~23px each — two characters and an
|
||||
ellipsis, which identifies nothing. Swap in the short label instead.
|
||||
The full name stays in the DOM (font-size: 0 — invisible but still
|
||||
announced) and on the pill's title attribute. */
|
||||
.svc-strip { gap: 0.25rem; }
|
||||
.svc-pill { padding: 0.35rem 0.35rem; gap: 0.25rem; }
|
||||
.svc-name { font-size: 0; }
|
||||
.svc-name::after {
|
||||
content: attr(data-short);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.status-light {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
flex-shrink: 0; background: var(--text-3);
|
||||
}
|
||||
.status-light.up { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-dim); }
|
||||
.status-light.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-dim); }
|
||||
.status-light.warn { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-dim); }
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.header-clock {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-width: var(--tap);
|
||||
min-height: var(--tap);
|
||||
padding: 0 0.85rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-2);
|
||||
/* The button was always a full --tap target, but the cog glyph inside it
|
||||
was set at --fs-sm (13px) and read as a speck in a 44px box. The glyph
|
||||
is the affordance, so size it to the button. */
|
||||
font-size: var(--fs-xl);
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.icon-btn:hover { color: var(--text); border-color: var(--border-lt); background: var(--surface); }
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────── */
|
||||
main {
|
||||
padding: var(--gap-lg);
|
||||
max-width: 1360px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.section > h2 {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
gap: var(--gap);
|
||||
}
|
||||
.row.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.row.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
/* A section with two stacked rows (Watch insights) needs the same gap
|
||||
between them as the grid uses between columns. */
|
||||
.row + .row { margin-top: var(--gap); }
|
||||
|
||||
/* ── Card ───────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.card-header h3 a { color: inherit; text-decoration: none; }
|
||||
.card-header h3 a:hover { color: var(--accent); }
|
||||
.card-meta {
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-body { padding: 1.1rem; flex: 1; }
|
||||
.card-body > :first-child { margin-top: 0; }
|
||||
/* Requests can grow tall when expanded; the Indexers card beside it should
|
||||
keep its natural height and sit at the top rather than stretching into a
|
||||
mostly-empty box with its content floating in the middle. */
|
||||
.row.align-start { align-items: start; }
|
||||
|
||||
/* ── "Right now" hero strip ─────────────────────────── */
|
||||
.hero .card { border-color: var(--border-lt); }
|
||||
.hero-figure {
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-3);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* Now-playing list */
|
||||
.stream {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.stream:first-child { border-top: none; padding-top: 0; }
|
||||
.stream-icon { font-size: var(--fs-lg); flex-shrink: 0; }
|
||||
.stream-info { min-width: 0; flex: 1; }
|
||||
.stream-title {
|
||||
font-size: var(--fs-base); font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.stream-user { font-size: var(--fs-sm); color: var(--text-3); }
|
||||
.live-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--ok); flex-shrink: 0;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-dot { animation: none; }
|
||||
* { transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ── Meter (storage / progress) ─────────────────────── */
|
||||
.meter {
|
||||
height: 7px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
.meter-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--ok);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.meter-fill.warn { background: var(--warn); }
|
||||
.meter-fill.danger { background: var(--danger); }
|
||||
.meter-legend {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
/* ── Stats (semantic definition list) ───────────────── */
|
||||
dl.stats {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
}
|
||||
/* Only the Downloads card stacks stats below the hero figure/sub block —
|
||||
give that pairing breathing room without affecting stats blocks that
|
||||
open a card (Sonarr/Radarr put dl.stats right after the search bar). */
|
||||
.card-body > [aria-live="polite"] + dl.stats { margin-top: 0.9rem; }
|
||||
dl.stats > div {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.65rem 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
dl.stats dt {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
dl.stats dd {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
dd.is-warn { color: var(--warn); }
|
||||
dd.is-danger { color: var(--danger); }
|
||||
dd.is-ok { color: var(--ok); }
|
||||
dd.is-accent { color: var(--accent); }
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--tap);
|
||||
padding: 0 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { opacity: 0.88; }
|
||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-quiet {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn-quiet:hover:not(:disabled) { color: var(--text); border-color: var(--border-lt); }
|
||||
|
||||
/* Compact add button — still meets 44px on touch (see media query) */
|
||||
.btn-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--border-lt);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||
/* Armed = awaiting the confirming second tap */
|
||||
.btn-add.armed {
|
||||
background: var(--warn);
|
||||
border-color: var(--warn);
|
||||
color: var(--bg);
|
||||
}
|
||||
.btn-add.done {
|
||||
background: var(--ok-dim);
|
||||
border-color: transparent;
|
||||
color: var(--ok);
|
||||
cursor: default;
|
||||
}
|
||||
.btn-add:disabled { cursor: default; }
|
||||
|
||||
/* ── Search ─────────────────────────────────────────── */
|
||||
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 0.85rem; }
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: var(--tap);
|
||||
padding: 0 0.85rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.search-bar input::placeholder { color: var(--text-3); }
|
||||
.search-bar input:focus { border-color: var(--accent); outline: none; }
|
||||
|
||||
.search-result {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
margin-bottom: 0.85rem;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid rgba(122,162,247,0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.search-result.visible { display: flex; }
|
||||
.search-result-info { min-width: 0; flex: 1; }
|
||||
.search-result-title {
|
||||
font-size: var(--fs-sm); font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.search-result-meta { font-size: var(--fs-xs); color: var(--text-3); }
|
||||
.search-msg {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-2);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.search-msg.error { color: var(--danger); }
|
||||
|
||||
/* ── Discover (RT) list ─────────────────────────────── */
|
||||
.rt-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.rt-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.2s, opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
.rt-item:hover { border-color: var(--border-lt); }
|
||||
.rt-item.removing { opacity: 0; transform: scale(0.97); pointer-events: none; }
|
||||
.rt-item.is-hidden { display: none; }
|
||||
.rt-rank {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
width: 1.2rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rt-poster {
|
||||
width: 38px; height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rt-info { min-width: 0; flex: 1; }
|
||||
.rt-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.rt-year { color: var(--text-3); font-weight: 400; }
|
||||
.rt-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: var(--fs-xs);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.score.certified { color: var(--ok); }
|
||||
.score.critics { color: var(--warn); }
|
||||
.score.audience { color: var(--text-3); }
|
||||
/* Description is inline + clamped — readable on touch, no hover required */
|
||||
.rt-desc {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
margin-top: 0.25rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rt-desc:empty { display: none; }
|
||||
.show-more {
|
||||
margin-top: 0.6rem;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
}
|
||||
/* Sits below the request pills, which use --row-h — match it so the
|
||||
button reads as one more row rather than a shorter tacked-on control. */
|
||||
.show-more.show-more-row { min-height: var(--row-h); }
|
||||
|
||||
/* ── Indexers ───────────────────────────────────────── */
|
||||
/* One indexer per line, built to the same rhythm as the Ombi request rows
|
||||
beside it — same padding, gap, radius and two-line body, so the two cards
|
||||
read as one pair rather than two unrelated layouts. */
|
||||
.indexer-grid { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.indexer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-height: var(--row-h);
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.indexer-item.down { border-color: var(--danger-border); }
|
||||
.indexer-item .status-light { width: 9px; height: 9px; flex-shrink: 0; }
|
||||
.indexer-info { min-width: 0; flex: 1; }
|
||||
.indexer-name {
|
||||
font-size: var(--fs-sm); font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.indexer-sub { font-size: var(--fs-xs); color: var(--text-3); }
|
||||
.indexer-sub .proto { text-transform: capitalize; }
|
||||
.indexer-sub:empty::before { content: "Indexer"; }
|
||||
/* Share of grabs, scaled to the busiest indexer (see dashboard.html).
|
||||
The margin is trimmed so a bar-carrying row still fits inside --row-h
|
||||
and stays flush with the request rows beside it. */
|
||||
.share-bar {
|
||||
margin-top: 0.3rem;
|
||||
height: 4px;
|
||||
border-radius: 100px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.share-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 100px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.indexer-item.down .share-bar span { background: var(--text-3); }
|
||||
.share-pct {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Requests (Ombi) ────────────────────────────────── */
|
||||
/* Request rows are the same object as an indexer row — same geometry and
|
||||
the same dot/title/sub/badge anatomy — so the two cards read across as
|
||||
one pair. --row-h keeps them aligned line-for-line; the indexer rows
|
||||
reach it naturally once their share bar is in. */
|
||||
.req-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.req-item.is-hidden { display: none; }
|
||||
.req-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-height: var(--row-h);
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.req-item .status-light { width: 9px; height: 9px; flex-shrink: 0; }
|
||||
.req-type { text-transform: capitalize; }
|
||||
.badge {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 100px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge.movie { background: var(--accent-dim); color: var(--accent); }
|
||||
.badge.tv { background: var(--ok-dim); color: var(--ok); }
|
||||
.badge.pending { background: var(--warn-dim); color: var(--warn); }
|
||||
.badge.available, .badge.approved { background: var(--ok-dim); color: var(--ok); }
|
||||
.badge.denied { background: var(--danger-dim); color: var(--danger); }
|
||||
.badge.idle { background: var(--surface); color: var(--text-3); }
|
||||
.badge.downloading { background: var(--accent-dim); color: var(--accent); }
|
||||
.badge.online { background: var(--ok-dim); color: var(--ok); }
|
||||
.badge.offline { background: var(--danger-dim); color: var(--danger); }
|
||||
.badge.paused { background: var(--warn-dim); color: var(--warn); }
|
||||
.req-info { min-width: 0; flex: 1; }
|
||||
.req-title {
|
||||
font-size: var(--fs-sm); font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.req-sub { font-size: var(--fs-xs); color: var(--text-3); }
|
||||
|
||||
/* ── Storage — per-disk table ──────────────────────────── */
|
||||
/* Storage owns a full-width row of its own, so the per-disk breakdown is a
|
||||
real table rather than a stack of cards: columns line up, digits are
|
||||
tabular, and a new metric is a new column instead of a redesign. Stacking
|
||||
these as rows in a third-of-a-page column is what hollowed out the two
|
||||
cards beside it — see the dead-space rule in CLAUDE.md. */
|
||||
/* Storage's header meta is a sentence, not a badge. .card-meta is nowrap so
|
||||
short status words never break; this one has to wrap or it pushes the whole
|
||||
page sideways on a phone. */
|
||||
.card-meta.card-meta-wrap { white-space: normal; text-align: right; }
|
||||
.array-meter.meter { margin-top: 0; margin-bottom: 1.1rem; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.disk-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.disk-table th, .disk-table td { padding: 0.45rem 0.6rem; text-align: left; }
|
||||
.disk-table th:first-child, .disk-table td:first-child { padding-left: 0; }
|
||||
.disk-table th:last-child, .disk-table td:last-child { padding-right: 0; }
|
||||
.disk-table thead th {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.disk-table tbody th {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.disk-table tbody td {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.disk-table tbody th { border-bottom: 1px solid var(--border); }
|
||||
.disk-table tbody tr:last-child th,
|
||||
.disk-table tbody tr:last-child td { border-bottom: none; }
|
||||
.disk-table .num { text-align: right; white-space: nowrap; }
|
||||
/* The bar and its percentage share one cell, so the bar flexes with the
|
||||
table while the number stays a fixed, right-aligned column of digits. */
|
||||
.disk-table .usage { width: 32%; min-width: 140px; }
|
||||
.usage-cell { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.usage-cell .meter { margin-top: 0; flex: 1; height: 5px; }
|
||||
.usage-pct {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
width: 2.6em;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Still used by the disk_state() macro when a disk reports no temperature. */
|
||||
.disk-null { color: var(--text-3); }
|
||||
/* Parity temp in the Storage header — keep the label and its badge together
|
||||
so they never wrap apart from each other. */
|
||||
.meta-parity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
/* Below this the six columns stop fitting; the table scrolls in its own
|
||||
container rather than squeezing the sizes into unreadable wraps. */
|
||||
.table-scroll .disk-table { min-width: 540px; }
|
||||
}
|
||||
.badge.temp-ok { background: var(--ok-dim); color: var(--ok); }
|
||||
.badge.temp-warn { background: var(--warn-dim); color: var(--warn); }
|
||||
.badge.temp-danger { background: var(--danger-dim); color: var(--danger); }
|
||||
|
||||
/* ── Insights top lists ─────────────────────────────── */
|
||||
.top-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.top-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.top-rank {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
width: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.top-info { min-width: 0; flex: 1; }
|
||||
.top-name {
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.top-sub { font-size: var(--fs-xs); color: var(--text-3); }
|
||||
.top-plays {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty { color: var(--text-3); font-size: var(--fs-sm); }
|
||||
/* Storage card's usage note trails the meter with no room to spare below it. */
|
||||
.meter-legend + .empty { margin-bottom: 0; }
|
||||
|
||||
/* ── Errors ─────────────────────────────────────────── */
|
||||
.error {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.8rem 0.9rem;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.error-detail { margin-top: 0.5rem; }
|
||||
.error-detail summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.error-detail summary::-webkit-details-marker { display: none; }
|
||||
.error-detail summary::before { content: "▸"; }
|
||||
.error-detail[open] summary::before { content: "▾"; }
|
||||
.error-detail summary:hover { color: var(--text-2); }
|
||||
.error-detail pre {
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
color: var(--text-3);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
/* Build identity — dim, so it reads as metadata rather than content. */
|
||||
footer .version {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────── */
|
||||
@media (max-width: 1100px) {
|
||||
.row.cols-3 { grid-template-columns: 1fr 1fr; }
|
||||
.row.cols-3 > :first-child { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.row.cols-2, .row.cols-3 { grid-template-columns: 1fr; }
|
||||
.row.cols-3 > :first-child { grid-column: auto; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
header { padding: 0.7rem 1rem; gap: 0.6rem; }
|
||||
.brand { flex: 1 1 auto; }
|
||||
.brand h1 { font-size: var(--fs-base); }
|
||||
.header-clock { display: none; }
|
||||
main { padding: 1rem; }
|
||||
.card-body { padding: 0.9rem; }
|
||||
.card-header { padding: 0.75rem 0.9rem; }
|
||||
.search-bar { flex-direction: column; }
|
||||
.search-bar .btn { width: 100%; }
|
||||
/* Full touch targets once there's no mouse */
|
||||
.btn-add { min-height: var(--tap); padding: 0 0.9rem; }
|
||||
.search-bar input { font-size: 16px; } /* prevent iOS zoom on focus */
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/* Login page — tokens and base reset come from base.css */
|
||||
|
||||
/* Centre the card in the viewport (overrides base.css body). */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.wrap { width: 100%; max-width: 360px; }
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: var(--tap);
|
||||
padding: 0 0.85rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 16px; /* prevents iOS zoom on focus */
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus { border-color: var(--accent); }
|
||||
input::placeholder { color: var(--text-3); }
|
||||
/* Second field in a stacked pair (username above password) needs the gap
|
||||
the single-field form never had. */
|
||||
label.stacked { margin-top: 1rem; }
|
||||
|
||||
.error-msg {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.7rem 0.85rem;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-sm);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: var(--tap);
|
||||
margin-top: 1rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
button:hover { opacity: 0.88; }
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
/* Settings page — tokens and base reset come from base.css */
|
||||
|
||||
header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.back-btn {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap);
|
||||
padding: 0 0.9rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-sm);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.back-btn:hover { color: var(--text); border-color: var(--border-lt); }
|
||||
|
||||
main { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: var(--gap);
|
||||
}
|
||||
.full { grid-column: 1 / -1; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.card-header {
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.card-body { padding: 1.1rem; }
|
||||
|
||||
.field { margin-bottom: 0.9rem; }
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
min-height: var(--tap);
|
||||
padding: 0 0.8rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 16px; /* prevents iOS zoom on focus */
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--accent); }
|
||||
input::placeholder { color: var(--text-3); }
|
||||
|
||||
.hint {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-3);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
button.save {
|
||||
width: 100%;
|
||||
min-height: var(--tap);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
button.save:hover:not(:disabled) { opacity: 0.88; }
|
||||
button.save:disabled { background: var(--border); color: var(--text-3); cursor: default; }
|
||||
|
||||
.error-inline {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--danger);
|
||||
display: none;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--ok);
|
||||
color: var(--ok);
|
||||
padding: 0.7rem 1.1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
max-width: calc(100vw - 3rem);
|
||||
}
|
||||
.toast.error { border-color: var(--danger); color: var(--danger); }
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
header { padding: 0.7rem 1rem; }
|
||||
main { padding: 1rem; }
|
||||
.toast { left: 1rem; right: 1rem; bottom: 1rem; }
|
||||
}
|
||||
625
static/css/teletext.css
Normal file
625
static/css/teletext.css
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
/* ═══════════════════════════════════════════════════════
|
||||
Page 100 — the service.
|
||||
|
||||
Every rule here answers to one of the five disciplines in
|
||||
base.css. The two that get broken by reflex, and so get
|
||||
guarded hardest: NO ENCLOSURE (there is not a single
|
||||
border-radius or box-drawing border in this file) and ONE
|
||||
RULER (every bar is --bar-cells wide, no exceptions).
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Header row ─────────────────────────────────────────
|
||||
Teletext's top line, unchanged for forty years: the page
|
||||
number you are on, who is transmitting, and the clock. It
|
||||
is a row of the page, not furniture bolted above it, so it
|
||||
scrolls away like everything else. */
|
||||
.tt-head {
|
||||
max-width: min(var(--page-cols), 100%);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 1ch;
|
||||
padding: var(--row) var(--gutter) 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.tt-head-num {
|
||||
font-family: var(--font-dh);
|
||||
color: var(--voice);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.tt-head-name {
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tt-head-clock {
|
||||
margin-left: auto;
|
||||
color: var(--live);
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The page-number entry indicator. Hidden until the visitor starts
|
||||
typing digits, then fills in place — the signature interaction. */
|
||||
.tt-entry {
|
||||
color: var(--voice);
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
.tt-entry[hidden] { display: none; }
|
||||
|
||||
/* ── Page ───────────────────────────────────────────────
|
||||
One measure for every page in the service. Nothing is ever
|
||||
centred except the page itself. */
|
||||
.tt-page {
|
||||
max-width: min(var(--page-cols), 100%);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--gutter) calc(var(--row) * 2);
|
||||
}
|
||||
|
||||
/* The page masthead: double height in the service's own colour. */
|
||||
.tt-masthead { color: var(--voice); margin-bottom: calc(var(--row) * 0.75); }
|
||||
|
||||
/* A rows block that follows another block rather than opening one. */
|
||||
.tt-rows-after { margin-top: var(--row); }
|
||||
|
||||
/* Double-height. The only size other than body text, and the
|
||||
only place bloom is allowed. */
|
||||
.tt-dh {
|
||||
font-family: var(--font-dh);
|
||||
font-size: var(--fs2);
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Field row (discipline 2: grouping without enclosure) ──
|
||||
A section is announced by a full-bleed band of colour with
|
||||
the title reversed out of it. This is what replaces every
|
||||
card header and every 1px border in the old design. */
|
||||
.tt-field {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1ch;
|
||||
margin: calc(var(--row) * 1.5) 0 var(--row);
|
||||
padding: 0 1ch;
|
||||
background: var(--field);
|
||||
color: var(--voice);
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
min-height: calc(var(--row) * 1.6);
|
||||
}
|
||||
.tt-field-meta {
|
||||
margin-left: auto;
|
||||
color: var(--tt-white);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* A page carrying critical state gets a heavier field than a quiet
|
||||
one (discipline 5) — the band goes red and doubles in height. */
|
||||
.tt-field.crit { background: var(--alert); color: var(--tt-white); }
|
||||
.tt-field.warn { background: var(--voice); color: var(--tt-black); }
|
||||
.tt-field.warn .tt-field-meta { color: var(--tt-black); }
|
||||
|
||||
/* ── Mosaic separator ───────────────────────────────────
|
||||
Teletext's block graphics doing the job a rule would do in a
|
||||
world that allowed rules. It is content in the grid, not a
|
||||
border on a box. */
|
||||
.tt-sep {
|
||||
height: 0.4em;
|
||||
margin: var(--row) 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--ink-dimmer) 0 0.6em,
|
||||
transparent 0.6em 0.9em
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Index / list rows ──────────────────────────────────
|
||||
The index page IS the status page: every destination row
|
||||
carries its own live figure in its own colour, so reading the
|
||||
menu and reading the state are the same act. */
|
||||
.tt-rows { display: flex; flex-direction: column; }
|
||||
.tt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5ch;
|
||||
min-height: var(--tap);
|
||||
padding: 0 1ch;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
a.tt-row:hover, a.tt-row:focus-visible {
|
||||
background: var(--voice);
|
||||
color: var(--tt-black);
|
||||
outline: none;
|
||||
}
|
||||
a.tt-row:hover .tt-row-num,
|
||||
a.tt-row:hover .tt-row-val,
|
||||
a.tt-row:hover .tt-row-sub,
|
||||
a.tt-row:focus-visible .tt-row-num,
|
||||
a.tt-row:focus-visible .tt-row-val,
|
||||
a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
|
||||
|
||||
.tt-row-num {
|
||||
color: var(--voice);
|
||||
flex: 0 0 auto;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.tt-row-name {
|
||||
flex: 0 0 auto;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
min-width: 11ch;
|
||||
}
|
||||
.tt-row-body { flex: 1; min-width: 0; }
|
||||
.tt-row-title {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tt-row-sub { color: var(--ink-dim); }
|
||||
.tt-row-val {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--live);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tt-row-val.ok { color: var(--ok); }
|
||||
.tt-row-val.alert { color: var(--alert); }
|
||||
.tt-row-val.warn { color: var(--voice); }
|
||||
.tt-row-val.pending { color: var(--pending); }
|
||||
.tt-row-val.off { color: var(--ink-dim); }
|
||||
|
||||
/* ── The one ruler (discipline 3) ───────────────────────
|
||||
Every bar in the service is exactly --bar-cells cells wide and
|
||||
fills in whole cells. Two percentages anywhere in the app are
|
||||
therefore directly comparable by eye. Never restyle this per
|
||||
card; that is precisely what it exists to prevent. */
|
||||
.tt-bar {
|
||||
display: inline-block;
|
||||
width: min(calc(var(--bar-cells) * var(--bar-cell-w)), 100%);
|
||||
height: 0.8em;
|
||||
flex: 0 0 auto;
|
||||
vertical-align: -0.05em;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--ink-dimmer) 0 calc(var(--bar-cell-w) - 2px),
|
||||
transparent calc(var(--bar-cell-w) - 2px) var(--bar-cell-w)
|
||||
);
|
||||
}
|
||||
.tt-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
currentColor 0 calc(var(--bar-cell-w) - 2px),
|
||||
transparent calc(var(--bar-cell-w) - 2px) var(--bar-cell-w)
|
||||
);
|
||||
}
|
||||
.tt-bar.ok { color: var(--ok); }
|
||||
.tt-bar.warn { color: var(--voice); }
|
||||
.tt-bar.alert { color: var(--alert); }
|
||||
|
||||
/* ── Trend (discipline 4) ───────────────────────────────
|
||||
Only rendered where the backend actually carries a comparison
|
||||
(current speed against the 7-day average, a temperature
|
||||
against its threshold). Where there is no history there is no
|
||||
arrow — an invented trend is worse than no trend. */
|
||||
.tt-trend { flex: 0 0 auto; letter-spacing: 0.05em; }
|
||||
.tt-trend.up { color: var(--ok); }
|
||||
.tt-trend.down { color: var(--voice); }
|
||||
.tt-trend.flat { color: var(--ink-dim); }
|
||||
|
||||
/* ── Figures ────────────────────────────────────────────
|
||||
A labelled number, set at double height because on this page
|
||||
the number is the content. */
|
||||
.tt-figs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 4ch;
|
||||
margin: var(--row) 0;
|
||||
padding: 0 1ch;
|
||||
}
|
||||
.tt-fig { min-width: 12ch; }
|
||||
.tt-fig-v { color: var(--live); }
|
||||
.tt-fig-v.ok { color: var(--ok); }
|
||||
.tt-fig-v.alert { color: var(--alert); }
|
||||
.tt-fig-v.warn { color: var(--voice); }
|
||||
.tt-fig-v.pending { color: var(--pending); }
|
||||
.tt-fig-l {
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Now showing ────────────────────────────────────────
|
||||
The lower two-thirds of the index. Double height, cyan, with
|
||||
the progress as a run of mosaic cells. */
|
||||
.tt-now { margin: var(--row) 0 0; padding: 0 1ch; }
|
||||
.tt-now-title { color: var(--live); margin-bottom: calc(var(--row) * 0.4); }
|
||||
.tt-now-meta { color: var(--ink); }
|
||||
.tt-now-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5ch;
|
||||
margin-top: calc(var(--row) * 0.5);
|
||||
color: var(--live);
|
||||
}
|
||||
.tt-now-pct { color: var(--live); flex: 0 0 auto; }
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────
|
||||
No rules, no zebra, no borders. Columns are held by alignment
|
||||
and the monospace grid; the head row is a colour field. */
|
||||
.tt-table-scroll { overflow-x: auto; }
|
||||
.tt-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tt-table th, .tt-table td {
|
||||
padding: 0.35em 1ch 0.35em 0;
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tt-table thead th {
|
||||
color: var(--voice);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tt-table tbody th { color: var(--ink); }
|
||||
.tt-table td { color: var(--ink-dim); }
|
||||
.tt-table .num { text-align: right; padding-right: 2ch; }
|
||||
.tt-table .val { color: var(--live); }
|
||||
.tt-table tbody tr:hover th,
|
||||
.tt-table tbody tr:hover td { background: var(--field); color: var(--tt-white); }
|
||||
|
||||
/* ── Tiles (library / discover) ─────────────────────────
|
||||
Artwork is the one thing in this world that is not made of
|
||||
characters, so it is treated as an inserted transmission: a
|
||||
flat rectangle on the grid with its label in a colour field
|
||||
beneath. No rounding, no shadow, no border. */
|
||||
.tt-tiles {
|
||||
display: grid;
|
||||
gap: var(--row) 2ch;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(12.5ch, 100%), 1fr));
|
||||
padding: 0 1ch;
|
||||
}
|
||||
.tt-tile { display: flex; flex-direction: column; min-width: 0; }
|
||||
.tt-tile-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
background: var(--ink-dimmer);
|
||||
}
|
||||
/* No artwork came back. A dithered field, not a fake poster. */
|
||||
.tt-tile-art-empty {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--ink-dimmer) 0 1px,
|
||||
var(--tt-black) 1px calc(var(--dither-size) * 2)
|
||||
);
|
||||
}
|
||||
.tt-tile-t {
|
||||
margin-top: 0.4em;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tt-tile-s { color: var(--ink-dim); }
|
||||
.tt-tile-badge { color: var(--voice); }
|
||||
.tt-tile-p { margin-top: 0.35em; }
|
||||
|
||||
/* ── Controls ───────────────────────────────────────────
|
||||
A teletext control is a reversed row of cells. That is the
|
||||
entire button vocabulary: no fill, no radius, no border. */
|
||||
.tt-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--tap);
|
||||
padding: 0 1.5ch;
|
||||
background: var(--voice);
|
||||
color: var(--tt-black);
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tt-btn:hover:not(:disabled) { background: var(--live); }
|
||||
.tt-btn:disabled { background: var(--ink-dimmer); color: var(--ink-dim); cursor: default; }
|
||||
.tt-btn.armed { background: var(--alert); color: var(--tt-white); }
|
||||
.tt-btn.done { background: transparent; color: var(--ok); cursor: default; }
|
||||
.tt-btn-quiet {
|
||||
background: transparent;
|
||||
color: var(--live);
|
||||
padding: 0 1ch;
|
||||
}
|
||||
.tt-btn-quiet:hover:not(:disabled) { background: var(--live); color: var(--tt-black); }
|
||||
.tt-btn-sm { min-height: calc(var(--row) * 1.6); width: 100%; }
|
||||
|
||||
.tt-input {
|
||||
min-height: var(--tap);
|
||||
padding: 0 1ch;
|
||||
background: var(--tt-black);
|
||||
color: var(--voice);
|
||||
border: none;
|
||||
/* The one place a line is allowed: an underscore run, which is
|
||||
how a teletext form showed a field. It is a character row, not
|
||||
an enclosure. */
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--ink-dimmer) 0 0.6em,
|
||||
transparent 0.6em 0.9em
|
||||
);
|
||||
background-size: 100% 2px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 100%;
|
||||
font-family: inherit;
|
||||
font-size: 16px; /* stops iOS zooming the page on focus */
|
||||
outline: none;
|
||||
}
|
||||
.tt-input::placeholder { color: var(--ink-dim); }
|
||||
.tt-input:focus {
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--voice) 0 0.6em,
|
||||
transparent 0.6em 0.9em
|
||||
);
|
||||
background-size: 100% 2px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 100%;
|
||||
}
|
||||
|
||||
/* ── Degraded service ───────────────────────────────────
|
||||
Teletext's own failure state was a page that simply did not
|
||||
arrive, announced in red on the row where it should have been.
|
||||
No panel, no left-border accent, no icon. */
|
||||
.tt-err { padding: 0 1ch; color: var(--alert); }
|
||||
.tt-err-cause { color: var(--alert); }
|
||||
.tt-err details { margin-top: 0.3em; }
|
||||
.tt-err summary {
|
||||
color: var(--ink-dim);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
min-height: calc(var(--row) * 1.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.tt-err summary::-webkit-details-marker { display: none; }
|
||||
.tt-err summary::before { content: "\25B8\00a0"; }
|
||||
.tt-err details[open] summary::before { content: "\25BE\00a0"; }
|
||||
.tt-err summary:hover { color: var(--ink); }
|
||||
.tt-err pre {
|
||||
margin: 0.3em 0 0;
|
||||
padding: 0.5em 1ch;
|
||||
background: var(--ink-dimmer);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tt-empty { padding: 0 1ch; color: var(--ink-dim); }
|
||||
|
||||
/* ── Fastext ────────────────────────────────────────────
|
||||
The four coloured keys along the bottom of every teletext
|
||||
page. Real navigation, not decoration: click one, or press
|
||||
its initial. Deliberately NOT fixed to the viewport — it is
|
||||
the last row of the page, where it always was. */
|
||||
.fastext {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
max-width: min(var(--page-cols), 100%);
|
||||
margin: calc(var(--row) * 2) auto 0;
|
||||
padding: 0 var(--gutter);
|
||||
gap: 1ch;
|
||||
}
|
||||
.fastext a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 1ch;
|
||||
min-height: var(--tap);
|
||||
padding: 0 1ch;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* The key's colour is carried by a solid cell at its head — the
|
||||
physical coloured button on the remote. */
|
||||
.fastext a > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fastext a::before {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 1.2ch;
|
||||
height: 1em;
|
||||
background: currentColor;
|
||||
}
|
||||
.fastext .k-red { color: var(--alert); }
|
||||
.fastext .k-green { color: var(--ok); }
|
||||
.fastext .k-yellow { color: var(--voice); }
|
||||
.fastext .k-cyan { color: var(--live); }
|
||||
.fastext a:hover, .fastext a:focus-visible {
|
||||
background: currentColor;
|
||||
outline: none;
|
||||
}
|
||||
.fastext a:hover span, .fastext a:focus-visible span { color: var(--tt-black); }
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────── */
|
||||
.tt-foot {
|
||||
max-width: min(var(--page-cols), 100%);
|
||||
margin: var(--row) auto 0;
|
||||
padding: 0 var(--gutter) var(--row);
|
||||
display: flex;
|
||||
gap: 2ch;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.tt-foot .v { margin-left: auto; }
|
||||
|
||||
/* ── Motion ─────────────────────────────────────────────
|
||||
The one authored moment: a teletext page did not appear, it
|
||||
was transmitted, painting in a few rows at a time. Sections
|
||||
arrive in sequence on load. Nothing else in this world moves.
|
||||
Content is visible by default; the animation only delays an
|
||||
already-rendered row's opacity. */
|
||||
@keyframes tt-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.tt-page > * {
|
||||
animation: tt-in 0.18s steps(2, end) both;
|
||||
}
|
||||
.tt-page > :nth-child(1) { animation-delay: 0.00s; }
|
||||
.tt-page > :nth-child(2) { animation-delay: 0.05s; }
|
||||
.tt-page > :nth-child(3) { animation-delay: 0.10s; }
|
||||
.tt-page > :nth-child(4) { animation-delay: 0.15s; }
|
||||
.tt-page > :nth-child(5) { animation-delay: 0.20s; }
|
||||
.tt-page > :nth-child(6) { animation-delay: 0.25s; }
|
||||
.tt-page > :nth-child(7) { animation-delay: 0.30s; }
|
||||
.tt-page > :nth-child(n+8) { animation-delay: 0.35s; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tt-page > * { animation: none; }
|
||||
* { transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────
|
||||
The grid is character-based, so it survives narrowing on its
|
||||
own. Two real moves: the double-height row stops being double
|
||||
on a phone (it would eat the viewport), and fastext stacks to
|
||||
two columns rather than shrinking below a tap target. */
|
||||
@media (max-width: 760px) {
|
||||
/* --bar-cells stays 20 at every width, on purpose: the bar() macro in
|
||||
_macros.html hardcodes the same 20 so it can quantise a percentage to
|
||||
whole cells server-side. Two constants that have to agree is exactly
|
||||
the kind of drift discipline 3 (One Ruler) exists to prevent — an
|
||||
earlier version of this file set 14 here, which desynced the CSS
|
||||
width from the markup's own cell math and could land a fill mid-cell
|
||||
on a phone. --bar-cell-w is in ch, so the ruler still shrinks with
|
||||
the font; it just does it as 20 smaller cells, never a different
|
||||
cell count. */
|
||||
:root {
|
||||
--gutter: 1rem;
|
||||
}
|
||||
.tt-dh { font-size: 1.375rem; }
|
||||
.fastext { grid-template-columns: repeat(2, 1fr); gap: 0; }
|
||||
.tt-row-name { min-width: 9ch; }
|
||||
.tt-figs { gap: 0 2.5ch; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
/* Below this a row cannot hold number, name, description and value on
|
||||
one line. The description drops to its own line under them rather
|
||||
than the row growing wider than the screen. */
|
||||
.tt-row {
|
||||
gap: 0 1ch;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.35em;
|
||||
padding-bottom: 0.35em;
|
||||
}
|
||||
.tt-row-name { min-width: 0; }
|
||||
.tt-row-body {
|
||||
flex: 1 0 100%;
|
||||
order: 3;
|
||||
}
|
||||
.tt-row-title { white-space: normal; }
|
||||
.tt-row-val { margin-left: auto; white-space: normal; }
|
||||
.tt-head-name { letter-spacing: 0.12em; }
|
||||
.tt-head-clock { margin-left: 0; flex-basis: 100%; }
|
||||
}
|
||||
|
||||
/* ── Search ─────────────────────────────────────────────
|
||||
A field and a reversed key. The underscore run under the
|
||||
input is the only line in this world, and it is a character
|
||||
row rather than a box around anything. */
|
||||
.tt-search {
|
||||
display: flex;
|
||||
gap: 1ch;
|
||||
align-items: stretch;
|
||||
margin: var(--row) 0;
|
||||
padding: 0 1ch;
|
||||
}
|
||||
.tt-search .tt-input { flex: 1; min-width: 0; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.tt-search { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Forms ──────────────────────────────────────────────
|
||||
A label above its field, the field marked by an underscore
|
||||
run. No box, no group border — the colour field header above
|
||||
each block is what says where one section ends. */
|
||||
.tt-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--row);
|
||||
align-items: flex-start;
|
||||
margin: var(--row) 0 calc(var(--row) * 1.5);
|
||||
padding: 0 1ch;
|
||||
max-width: 52ch;
|
||||
}
|
||||
.tt-field-row { width: 100%; }
|
||||
.tt-label {
|
||||
display: block;
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tt-form .tt-input { width: 100%; }
|
||||
.tt-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
color: var(--voice);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tt-select option { background: var(--tt-black); color: var(--voice); }
|
||||
.tt-hint { margin: 0.2em 0 0; color: var(--ink-dim); }
|
||||
.tt-inline-err { color: var(--alert); min-height: 1em; }
|
||||
|
||||
.tt-keys {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(30ch, 100%), 1fr));
|
||||
gap: 0 3ch;
|
||||
}
|
||||
.tt-keys .tt-form { max-width: none; }
|
||||
|
||||
/* ── Toast ──────────────────────────────────────────────
|
||||
Teletext announced itself in the header row, so a confirmation
|
||||
lands as a reversed strip rather than a floating card. */
|
||||
.tt-toast {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0.6em var(--gutter);
|
||||
background: var(--ok);
|
||||
color: var(--tt-black);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tt-toast.show { opacity: 1; transform: translateY(0); }
|
||||
.tt-toast.error { background: var(--alert); color: var(--tt-white); }
|
||||
|
||||
/* ── Sign-in ────────────────────────────────────────────
|
||||
The one page with no navigation, so it centres its single
|
||||
block rather than opening the full page measure. */
|
||||
.tt-login .tt-page { max-width: 52ch; margin: 0 auto; }
|
||||
.tt-login .tt-head { max-width: 52ch; }
|
||||
.tt-login .tt-foot { max-width: 52ch; }
|
||||
Loading…
Add table
Add a link
Reference in a new issue