arr-summary/static/css/teletext.css
Barely Removable bc7779e590 Fix the finish review's 8 material findings on the teletext redesign
Applies the fix round from the finish-reviewer's disposition on commit
4b19cbd, in one batch:

- System's disk table is unreadable below 560px: horizontal-scrolling a
  6-column table hid the usage bar and the temperature, the two
  readings that matter most (discipline 3 — one shared ruler — was
  unauditable as a result). Added a disk_row() macro and a phone-only
  .tt-disk-rows reflow (name + full 20-cell bar + temp on one line,
  device/free/total beneath), CSS-toggled against the existing table
  at the same breakpoint.
- Contrast failure on the highest-severity state: white text on the
  red .tt-field.crit/.tt-toast.error/.tt-btn.armed reversals measured
  4.0:1, under the 4.5:1 floor. Switched to black text (5.25:1),
  matching the black-on-yellow .warn pattern already in use — and
  fixed a second instance of the same bug: .tt-field.crit had no
  .tt-field-meta override, unlike .warn.
- Two type families where the direction contract names one blocky
  monospace: unified on a single self-hosted face (Space Mono, one
  weight, no bold) for both body and the double-height row.
- FIRST VIEWPORT proportion: an unpromised 6-item Just Added grid was
  competing with NOW SHOWING for the fold instead of the contract's
  "lower two-thirds in double-height cyan." Cut to a fixed 3-item,
  fixed-3-column row, which also fixes the orphaned-tile grid defect
  as a side effect (a fixed count can't leave a lone tile beside empty
  tracks).
- Three flat mid-grey fills contradicted discipline 1 (no in-between
  tone): .tt-tile-art, .tt-btn:disabled, and .tt-err pre all painted a
  solid --ink-dimmer background. Centralized the dither .tt-tile-art
  already used correctly into a shared --dither-mid generator and
  applied it to all three.
- Google Fonts dependency on a LAN-only home server: a dropped webfont
  request was a silent identity change, not a graceful fallback.
  Self-hosted Space Mono under static/fonts/ via @font-face; dropped
  the <link> tags from _shell.html and login.html.
- Deliberately deferred: "pages differ in stock" (discipline 5) still
  fires on only one band service-wide. Every other finding was a
  bounded fix to existing code; this one asks for a new mechanism (an
  aggregate status band on P100) rather than a correction, so it's
  flagged for a follow-up rather than rushed into this batch.

Recaptured desktop.png, mobile.png, desktop-system.png and
mobile-system.png (the four files the reviewer's findings cited)
through the same true-390px iframe method as the prior round, and
returned them for the fix round's verdict pass.

Known follow-up: DESIGN.md's typography section (written by the
documenter before this batch) still names the old two-family pairing
and needs a re-run now that the font system changed under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
2026-08-29 21:18:11 -07:00

689 lines
23 KiB
CSS

/* ═══════════════════════════════════════════════════════
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);
/* Black, not white: white-on-red measures 4.0:1 here, under the
4.5:1 floor for 15px uppercase text. Black-on-red clears 5.25:1
and is the correct teletext reversal — every other coloured band
(.warn, a hover row) already reverses to black. */
color: var(--tt-black);
}
.tt-field.crit .tt-field-meta { color: var(--tt-black); }
.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); }
/* ── Disk rows (System, phone reflow of the table above) ──
Below 560px a six-column table stops fitting; scrolling it
horizontally hides the bar and the temperature, the two
readings that matter most here. This is the other rendering
of the same disk data, shown only below that width — see the
media query at the bottom of this file for the swap. */
.tt-disk-rows { display: none; flex-direction: column; }
.tt-disk-row { padding: 0.4em 1ch; }
.tt-disk-row-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4em 1ch;
}
.tt-disk-name {
flex: 0 0 auto;
min-width: 5ch;
color: var(--ink);
letter-spacing: 0.16em;
text-transform: uppercase;
}
.tt-disk-pct {
flex: 0 0 auto;
color: var(--live);
font-variant-numeric: tabular-nums;
}
.tt-disk-row-sub {
margin-top: 0.15em;
color: var(--ink-dim);
}
.tt-disk-row:hover { background: var(--field); }
.tt-disk-row:hover .tt-disk-name,
.tt-disk-row:hover .tt-disk-pct,
.tt-disk-row:hover .tt-disk-row-sub { 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;
}
/* P100's Just Added: a fixed row, not a viewport-dependent auto-fill.
Paired with a fixed item count (see home()), this guarantees a full
row every time rather than leaving a lone tile beside empty tracks —
and it keeps this block compact, so NOW SHOWING above it can hold the
lower two-thirds of the first viewport the direction contract names. */
.tt-tiles-row {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 560px) {
.tt-tiles-row { grid-template-columns: repeat(1, 1fr); }
}
.tt-tile { display: flex; flex-direction: column; min-width: 0; }
.tt-tile-art {
display: block;
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
/* Covers the box while a real poster is still loading, and IS the
fallback for a poster that never arrives — dithered, per
discipline 1, never a flat mid-grey panel. */
background-color: var(--tt-black);
background-image: var(--dither-mid);
}
.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-color: var(--tt-black);
background-image: var(--dither-mid);
color: var(--ink-dim);
cursor: default;
}
.tt-btn.armed { background: var(--alert); color: var(--tt-black); }
.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-color: var(--tt-black);
background-image: var(--dither-mid);
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%; }
/* The System disk table swaps for its row reflow here — see the
"Disk rows" block above. */
.tt-table-scroll { display: none; }
.tt-disk-rows { display: flex; }
}
/* ── 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-black); /* same contrast fix as .tt-field.crit */
}
/* ── 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; }