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

View file

@ -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;
}

View file

@ -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 */
}

View file

@ -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; }

View file

@ -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
View 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; }

View file

@ -1,406 +0,0 @@
/* Server-rendered config, read off <body data-*> (see dashboard.html).
URL_BASE carries the reverse-proxy prefix so /api/* fetches still
resolve when the app is mounted on a subpath via SCRIPT_NAME. */
const URL_BASE = document.body.dataset.urlBase || "";
const TIMEZONE = document.body.dataset.timezone || "UTC";
let sonarrSearchResult = null;
let radarrSearchResult = null;
/* ── Clock ── */
function updateClock() {
const el = document.getElementById('header-clock');
if (!el) return;
try {
el.textContent = new Date().toLocaleTimeString('en-US', {
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit'
});
} catch (e) {
el.textContent = new Date().toLocaleTimeString();
}
}
updateClock();
setInterval(updateClock, 1000);
/* ── Discover: show more / show less ── */
function toggleMore(btn, listId) {
const list = document.getElementById(listId);
if (!list) return;
const items = Array.from(list.children);
const hidden = items.filter(el => el.classList.contains('is-hidden'));
if (hidden.length) {
hidden.forEach(el => el.classList.remove('is-hidden'));
btn.textContent = 'Show less';
btn.setAttribute('aria-expanded', 'true');
} else {
items.forEach((el, i) => {
if (i >= 5) el.classList.add('is-hidden');
});
const n = items.length - 5;
btn.textContent = 'Show ' + n + ' more';
btn.setAttribute('aria-expanded', 'false');
}
}
/* ── Add flow: first tap arms, second tap commits ──
Adding queues a real download, so a single stray tap shouldn't do it. */
let armedBtn = null;
let armedTimer = null;
function disarm() {
if (armedBtn) {
armedBtn.classList.remove('armed');
armedBtn.textContent = '+ Add';
armedBtn = null;
}
clearTimeout(armedTimer);
}
function armAdd(btn) {
if (armedBtn === btn) { disarm(); return doAdd(btn); }
disarm();
armedBtn = btn;
btn.classList.add('armed');
btn.textContent = 'Confirm?';
armedTimer = setTimeout(disarm, 4000);
}
function removeRtItem(btn) {
const item = btn.closest('.rt-item');
if (!item) return;
item.classList.add('removing');
setTimeout(() => {
const list = item.parentElement;
item.remove();
if (list) {
let n = 0;
list.querySelectorAll('.rt-item').forEach(el => {
el.querySelector('.rt-rank').textContent = ++n;
});
}
}, 320);
}
async function doAdd(btn) {
const title = btn.dataset.title;
const target = btn.dataset.target; // 'sonarr' | 'radarr'
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-' + target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title })
});
const data = await resp.json();
if ((resp.ok && data.success) || resp.status === 409) {
removeRtItem(btn);
} else {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error'));
}
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Network error: ' + e.message);
}
}
// A tap anywhere else cancels a pending confirm.
document.addEventListener('click', e => {
if (armedBtn && !e.target.closest('.btn-add')) disarm();
});
/* ── SABnzbd live polling (never cached server-side) ── */
const sabSpeed = document.getElementById('sab-speed');
const sabQueue = document.getElementById('sab-queue');
const sabRemaining = document.getElementById('sab-remaining');
const sabBadge = document.getElementById('sab-badge');
const sabTimeleft = document.getElementById('sab-timeleft');
const sabTimeleftRow = document.getElementById('sab-timeleft-row');
async function pollSabnzbd() {
try {
const resp = await fetch(URL_BASE + '/api/sabnzbd', { cache: 'no-store', credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
if (sabSpeed) sabSpeed.textContent = d.speed;
if (sabQueue) sabQueue.textContent = d.queue_count;
if (sabRemaining) sabRemaining.textContent = d.sizeleft;
if (sabBadge) {
const s = (d.sab_status || '').toLowerCase();
sabBadge.className = 'badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle');
sabBadge.textContent = d.sab_status;
}
const active = (d.sab_status || '').toLowerCase() === 'downloading'
&& d.timeleft && d.timeleft !== '0:00:00';
if (sabTimeleftRow) sabTimeleftRow.style.display = active ? 'inline' : 'none';
if (sabTimeleft) sabTimeleft.textContent = d.timeleft;
const pill = document.getElementById('svc-stat-sabnzbd');
if (pill) pill.textContent = d.speed;
} catch (e) { /* transient network error — try again next tick */ }
}
pollSabnzbd();
setInterval(pollSabnzbd, 3000);
/* ── RT descriptions arrive asynchronously from IMDB ── */
async function loadRtDescriptions() {
try {
const resp = await fetch(URL_BASE + '/api/rt/descriptions', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
for (const [title, desc] of Object.entries(d.descriptions || {})) {
const item = document.querySelector(`.rt-item[data-rt-title="${CSS.escape(title)}"]`);
if (!item || !desc) continue;
const el = item.querySelector('.rt-desc');
if (!el || el.textContent.trim()) continue; // already filled
el.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc;
}
if (d.pending) setTimeout(loadRtDescriptions, 4000);
} catch (e) { /* silently skip */ }
}
setTimeout(loadRtDescriptions, 1500);
/* ── Prowlarr indexer polling ── */
function updateServicePill(name, isUp) {
const pill = document.getElementById('svc-' + name);
const light = document.getElementById('svc-light-' + name);
if (pill) { pill.classList.toggle('up', isUp); pill.classList.toggle('down', !isUp); }
if (light) { light.classList.toggle('up', isUp); light.classList.toggle('down', !isUp); }
}
async function pollProwlarr() {
try {
const resp = await fetch(URL_BASE + '/api/prowlarr', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
const upEl = document.getElementById('prowlarr-up-count');
const totEl = document.getElementById('prowlarr-total');
if (upEl) upEl.textContent = d.up_count;
if (totEl) totEl.textContent = d.total;
d.indexers.forEach(idx => {
const row = document.getElementById('idx-' + idx.id);
const light = document.getElementById('idx-light-' + idx.id);
if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); }
if (light) { light.classList.toggle('up', idx.up); light.classList.toggle('down', !idx.up); }
const badge = document.getElementById('idx-badge-' + idx.id);
if (badge) {
badge.classList.toggle('online', idx.up);
badge.classList.toggle('offline', !idx.up);
badge.textContent = idx.up ? 'Online' : 'Offline';
}
});
updateServicePill('prowlarr', d.up_count === d.total && d.total > 0);
const stat = document.getElementById('svc-stat-prowlarr');
if (stat) stat.textContent = d.up_count + '/' + d.total + ' up';
} catch (e) { /* silently skip */ }
}
pollProwlarr();
setInterval(pollProwlarr, 30000);
/* ── Header service lights ── */
async function pollServices() {
try {
const resp = await fetch(URL_BASE + '/api/services', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
for (const [name, isUp] of Object.entries(d)) updateServicePill(name, isUp);
} catch (e) { /* silently skip */ }
}
pollServices();
setInterval(pollServices, 60000);
/* ── Tautulli live streams ── */
async function pollTautulli() {
try {
const resp = await fetch(URL_BASE + '/api/tautulli', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
const count = d.stream_count;
const sessions = d.sessions || [];
const stat = document.getElementById('svc-stat-tautulli');
if (stat) stat.textContent = count > 0 ? count + ' streaming' : '0 streams';
const countEl = document.getElementById('tautulli-count');
if (countEl) countEl.textContent = count + ' active';
const body = document.getElementById('now-playing');
if (!body) return;
if (sessions.length) {
body.innerHTML = sessions.map(s => {
const icon = s.media_type === 'movie' ? '🎬' : '📺';
const esc = t => String(t).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
return `<div class="stream">
<span class="live-dot"></span>
<span class="stream-icon" aria-hidden="true">${icon}</span>
<span class="stream-info">
<span class="stream-title">${esc(s.title)}</span>
<span class="stream-user">${esc(s.user)}</span>
</span>
</div>`;
}).join('');
} else {
body.innerHTML = '<p class="empty">Nothing streaming right now.</p>';
}
} catch (e) { /* silently skip */ }
}
pollTautulli();
setInterval(pollTautulli, 15000);
/* ── Clear search results when the field is emptied ── */
document.getElementById('sonarr-search-input').addEventListener('input', function () {
if (!this.value.trim()) {
document.getElementById('sonarr-search-result').classList.remove('visible');
document.getElementById('sonarr-search-msg').style.display = 'none';
sonarrSearchResult = null;
}
});
document.getElementById('radarr-search-input').addEventListener('input', function () {
if (!this.value.trim()) {
document.getElementById('radarr-search-result').classList.remove('visible');
document.getElementById('radarr-search-msg').style.display = 'none';
radarrSearchResult = null;
}
});
/* ── Sonarr search ── */
async function searchSonarr() {
const input = document.getElementById('sonarr-search-input');
const btn = document.getElementById('sonarr-search-btn');
const resultEl = document.getElementById('sonarr-search-result');
const msgEl = document.getElementById('sonarr-search-msg');
const title = input.value.trim();
if (!title) return;
btn.disabled = true; btn.textContent = 'Searching…';
resultEl.classList.remove('visible');
msgEl.style.display = 'none';
sonarrSearchResult = null;
try {
const resp = await fetch(URL_BASE + '/api/sonarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
msgEl.textContent = 'Session expired — please refresh the page.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Search'; return;
}
const d = await resp.json();
if (!resp.ok || d.error) {
msgEl.textContent = d.error || 'Search failed.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
} else if (!d.title) {
msgEl.textContent = 'No results found.';
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
} else {
sonarrSearchResult = d;
document.getElementById('sonarr-result-title').textContent = d.title;
document.getElementById('sonarr-result-meta').textContent =
(d.year || '') + (d.network ? ' · ' + d.network : '') + (d.status ? ' · ' + d.status : '');
const addBtn = document.getElementById('sonarr-add-btn');
if (d.exists) {
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
resultEl.classList.add('visible');
}
} catch (e) {
msgEl.textContent = 'Search failed: ' + e.message;
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
}
btn.disabled = false; btn.textContent = 'Search';
}
async function confirmAddSonarr() {
if (!sonarrSearchResult) return;
const addBtn = document.getElementById('sonarr-add-btn');
addBtn.disabled = true; addBtn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-sonarr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ title: sonarrSearchResult.title })
});
const d = await resp.json();
if ((resp.ok && d.success) || resp.status === 409) {
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}
/* ── Radarr search ── */
async function searchRadarr() {
const input = document.getElementById('radarr-search-input');
const btn = document.getElementById('radarr-search-btn');
const resultEl = document.getElementById('radarr-search-result');
const msgEl = document.getElementById('radarr-search-msg');
const title = input.value.trim();
if (!title) return;
btn.disabled = true; btn.textContent = 'Searching…';
resultEl.classList.remove('visible');
msgEl.style.display = 'none';
radarrSearchResult = null;
try {
const resp = await fetch(URL_BASE + '/api/radarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
msgEl.textContent = 'Session expired — please refresh the page.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Search'; return;
}
const d = await resp.json();
if (!resp.ok || d.error) {
msgEl.textContent = d.error || 'Search failed.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
} else if (!d.title) {
msgEl.textContent = 'No results found.';
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
} else {
radarrSearchResult = d;
document.getElementById('radarr-result-title').textContent = d.title;
document.getElementById('radarr-result-meta').textContent =
(d.year || '') + (d.studio ? ' · ' + d.studio : '') + (d.runtime ? ' · ' + d.runtime + ' min' : '');
const addBtn = document.getElementById('radarr-add-btn');
if (d.exists) {
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
resultEl.classList.add('visible');
}
} catch (e) {
msgEl.textContent = 'Search failed: ' + e.message;
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
}
btn.disabled = false; btn.textContent = 'Search';
}
async function confirmAddRadarr() {
if (!radarrSearchResult) return;
const addBtn = document.getElementById('radarr-add-btn');
addBtn.disabled = true; addBtn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-radarr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ title: radarrSearchResult.title })
});
const d = await resp.json();
if ((resp.ok && d.success) || resp.status === 409) {
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}

201
static/js/service.js Normal file
View file

@ -0,0 +1,201 @@
/* ═══════════════════════════════════════════════════════
Page 100 — service behaviour.
Three things: the header clock, the page-number jump (the
signature interaction), and the fastext colour keys. Plus
guarded live polling for the two figures that must be
current-second accurate.
Every element lookup is guarded. Nine pages share this file
and none of them carry all of its targets.
═══════════════════════════════════════════════════════ */
(function () {
'use strict';
var body = document.body;
var BASE = body.dataset.urlBase || '';
var TZ = body.dataset.timezone || 'UTC';
/* ── Clock ────────────────────────────────────────────
Teletext's header carried the transmission clock to the
second. It is the one thing on the page that is always
moving, and it is how you know the page is live. */
var clockEl = document.getElementById('head-clock');
function tick() {
if (!clockEl) return;
var now = new Date();
var d = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ, weekday: 'short', day: '2-digit', month: 'short'
}).format(now);
var t = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ, hour: '2-digit', minute: '2-digit',
second: '2-digit', hour12: false
}).format(now);
clockEl.textContent = d.toUpperCase() + ' ' + t;
}
tick();
setInterval(tick, 1000);
/* ── Page-number jump ─────────────────────────────────
Type three digits anywhere and the service goes to that
page, exactly as a teletext remote did. The digits fill in
the header as you type them, so the interaction shows its
own state instead of happening invisibly. */
var pages = {};
try {
(JSON.parse(body.dataset.pages || '[]')).forEach(function (p) {
pages[p[1]] = p[3]; // "100" -> "/"
});
} catch (e) { /* no nav data: the jump simply does nothing */ }
var entryEl = document.getElementById('page-entry');
var buf = '';
var clearTimer = null;
function paintEntry() {
if (!entryEl) return;
if (!buf) { entryEl.hidden = true; entryEl.textContent = ''; return; }
entryEl.hidden = false;
entryEl.textContent = 'P' + buf + '_'.repeat(3 - buf.length);
}
function resetEntry() {
buf = '';
paintEntry();
}
function typing(e) {
var t = e.target;
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' || t.isContentEditable);
}
document.addEventListener('keydown', function (e) {
if (typing(e) || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'Escape') { resetEntry(); return; }
if (e.key >= '0' && e.key <= '9') {
buf += e.key;
paintEntry();
clearTimeout(clearTimer);
if (buf.length === 3) {
var dest = pages[buf];
if (dest) { window.location.href = BASE + dest; }
else {
// No such page. Teletext just sat on the number and waited.
if (entryEl) entryEl.textContent = 'P' + buf + ' ?';
clearTimer = setTimeout(resetEntry, 1200);
}
buf = '';
return;
}
// A half-typed number times out rather than lingering.
clearTimer = setTimeout(resetEntry, 2000);
return;
}
/* Fastext keys: r / g / y / c, the coloured buttons. */
var k = e.key.toLowerCase();
if ('rgyc'.indexOf(k) !== -1) {
var link = document.querySelector('.fastext a[data-key="' + k + '"]');
if (link) { window.location.href = link.href; }
}
});
/* ── Two-tap add ──────────────────────────────────────
Adding to the library is destructive enough to want a
confirming second press. The button states its own stage
rather than opening a dialog. */
var armed = null;
function disarm() {
if (!armed) return;
armed.classList.remove('armed');
armed.textContent = armed.dataset.label || '+ ADD';
armed = null;
}
window.armAdd = function (btn) {
if (btn.disabled) return;
if (armed && armed !== btn) disarm();
if (armed === btn) {
var title = btn.dataset.title;
var target = btn.dataset.target;
btn.disabled = true;
btn.classList.remove('armed');
btn.textContent = 'SENDING';
fetch(BASE + '/add-to-' + target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok) {
btn.classList.add('done');
btn.textContent = 'ADDED';
} else {
btn.classList.add('armed');
btn.disabled = false;
btn.textContent = 'FAILED';
}
})
.catch(function () {
btn.disabled = false;
btn.classList.add('armed');
btn.textContent = 'FAILED';
});
armed = null;
return;
}
btn.dataset.label = btn.textContent.trim();
btn.classList.add('armed');
btn.textContent = 'CONFIRM?';
armed = btn;
};
document.addEventListener('click', function (e) {
if (armed && !armed.contains(e.target)) disarm();
});
/* ── Live figures ─────────────────────────────────────
The queue and the active streams are the only two numbers
that must be accurate to the second, so they are the only
two that poll. Everything else is cached server-side and
arrives with the page. */
function setText(id, value) {
var el = document.getElementById(id);
if (el) el.textContent = value;
}
function pollSab() {
if (!document.getElementById('sab-queue')) return;
fetch(BASE + '/api/sabnzbd')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || d.status !== 'ok') return;
setText('sab-queue', d.queue_count);
setText('sab-speed', d.speed || '0');
setText('sab-remaining', d.sizeleft || '—');
})
.catch(function () { /* offline is the dev baseline, not an error */ });
}
function pollTautulli() {
if (!document.getElementById('tt-streams')) return;
fetch(BASE + '/api/tautulli')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || d.status !== 'ok') return;
setText('tt-streams', d.active_streams);
})
.catch(function () { /* as above */ });
}
if (document.getElementById('sab-queue')) {
pollSab();
setInterval(pollSab, 5000);
}
if (document.getElementById('tt-streams')) {
pollTautulli();
setInterval(pollTautulli, 15000);
}
})();