Give every fastext destination a distinct mark; add skip-link, fix aria-label
All checks were successful
Deploy / deploy (push) Successful in 23s

Colours: "if the intent is to have them all different colors than
lets have them all different colors." The locked eight only give five
safe as full text (blue fails contrast, black's invisible on black),
so the other three get their own treatments instead of sharing plain
ink: Watch gets blue as a swatch-only accent (the button cap's colour,
not the label), Activity gets an ordered-dither swatch (the other
legal way to mark something without a ninth colour), and Settings
alone keeps plain ink deliberately, as the one page with no live
status of its own.

Accessibility (items 1 and 3 from the design review, item 2 skipped
per request — the header's download figure stays live and unmuted):
- Added a skip-link, off-screen until focused, jumping past the
  8-item top nav straight to each page's #main-content.
- Renamed the nav's aria-label from "Fastext" (meaningless without
  the visual teletext context) to "Page navigation".
This commit is contained in:
Barely Removable 2026-08-30 09:24:52 -07:00
parent 15936027de
commit 5dd49c505c
4 changed files with 79 additions and 28 deletions

View file

@ -105,18 +105,22 @@ the user's Unraid home server. Server-side project directory is still named
under the header on every page but the index — those pages carry no under the header on every page but the index — those pages carry no
other nav, so it needs no scroll to reach. The index overrides it other nav, so it needs no scroll to reach. The index overrides it
with `HOME_FASTEXT`, the original four-entry row, as its own closing with `HOME_FASTEXT`, the original four-entry row, as its own closing
line, since its body already lists every destination once. The line, since its body already lists every destination once. Every
original four (Index/Library/Discover/System) keep their `r`/`g`/ destination gets its own distinct mark — the locked eight colours
`y`/`c` keyboard shortcut and colour; Requests also gets a colour only give five that read as legible text on black, so getting to
(magenta, `--pending`) without a shortcut — a deliberate fit, not eight took three treatments: five flat colours as text+swatch
an arbitrary fifth pick, since Requests already is "waiting on a (Index/Library/Discover/System keep their `r`/`g`/`y`/`c` shortcut;
human" everywhere else magenta appears. That's the ceiling: the Requests gets magenta/`--pending` with no shortcut — a deliberate
locked eight colours leave exactly five that read as legible text fit, since Requests already is "waiting on a human" everywhere else
on black (blue fails contrast there, black is invisible), so Watch/ magenta appears); Watch gets blue as a swatch-only accent (`.k-blue`
Activity/Settings share plain ink rather than a sixth colour that — blue fails contrast as body text, so only the small cell carries
doesn't exist in this palette. Whichever entry matches the current it, not the row's own label); Activity gets an ordered-dither swatch
page renders as a `.fastext-here` span instead of a link — a page (`.k-dither`, same mechanism as `--dither-mid`) rather than a flat
linking to itself did nothing, so it isn't offered as a click. colour. Settings alone keeps plain ink with no swatch treatment —
deliberately, as the one page with no live status of its own.
Whichever entry matches the current page renders as a
`.fastext-here` span instead of a link — a page linking to itself
did nothing, so it isn't offered as a click.
- **Nothing is clipped, hidden, or sticky.** All seven page numbers and - **Nothing is clipped, hidden, or sticky.** All seven page numbers and
every fastext entry stay visible at every width, reflowing to another every fastext entry stay visible at every width, reflowing to another
row rather than scrolling, truncating, or folding into a menu. No row rather than scrolling, truncating, or folding into a menu. No

34
app.py
View file

@ -1303,24 +1303,34 @@ def service_urls():
} }
# Every destination, for the top of every page but the index. Order # Every destination, for the top of every page but the index, each
# matches nav_items. The locked eight teletext colours leave exactly # with its own distinct treatment. Order matches nav_items. The
# five that read as text on black without inventing a ninth tone or # locked eight teletext colours only give five that read as full-
# borrowing a colour that already means something else here: the four # saturation text on black (blue fails contrast there), so getting all
# with a real keyboard shortcut (r/g/y/c), plus magenta for Requests — # eight distinct without inventing a ninth colour takes three
# not arbitrary, it's the same --pending magenta a request already # treatments, not one:
# shows once approved elsewhere, and Requests IS "waiting on a human". # - Five flat colours, text and swatch both: the four with a real
# Blue fails contrast as text and black is invisible on black, so the # keyboard shortcut (r/g/y/c), plus magenta for Requests — not
# three destinations left over (Watch/Activity/Settings) share plain # arbitrary, it's the same --pending magenta a request already
# ink rather than a sixth colour that doesn't exist in this palette. # shows once approved elsewhere, and Requests IS "waiting on a
# human".
# - Blue as a swatch-only accent (Watch): legible as a small solid
# cell — "the physical coloured button on the remote" — but not as
# the row's own text colour, where it fails contrast.
# - An ordered dither swatch (Activity): the one other legal way to
# get a distinct mark out of this palette per base.css's own first
# discipline, without smuggling in a ninth hue.
# Settings keeps plain ink deliberately — the one purely administrative
# destination with no live status of its own, so it's the one page
# that gets no colour at all rather than an arbitrary last one.
FULL_FASTEXT = [ FULL_FASTEXT = [
("k-red", "r", "100", "Index", "/"), ("k-red", "r", "100", "Index", "/"),
("", "", "101", "Watch", "/watch"), ("k-blue", "", "101", "Watch", "/watch"),
("k-green", "g", "102", "Library", "/library"), ("k-green", "g", "102", "Library", "/library"),
("k-yellow", "y", "103", "Discover", "/discover"), ("k-yellow", "y", "103", "Discover", "/discover"),
("k-magenta", "", "104", "Requests", "/requests"), ("k-magenta", "", "104", "Requests", "/requests"),
("k-cyan", "c", "105", "System", "/system"), ("k-cyan", "c", "105", "System", "/system"),
("", "", "106", "Activity", "/activity"), ("k-dither", "", "106", "Activity", "/activity"),
("", "", "900", "Settings", "/settings"), ("", "", "900", "Settings", "/settings"),
] ]

View file

@ -8,6 +8,26 @@
RULER (every bar is --bar-cells wide, no exceptions). RULER (every bar is --bar-cells wide, no exceptions).
═══════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════ */
/* ── Skip-link ──────────────────────────────────────────
Off-screen until it receives focus, then it's a plain field row
like any other — a solid colour ground, no border — sitting above
everything else so tabbing past it reaches the header next. */
.skip-link {
position: absolute;
top: -999px;
left: 0;
z-index: 1;
padding: 0.5em 1ch;
background: var(--field);
color: var(--ink);
text-transform: uppercase;
letter-spacing: 0.16em;
}
.skip-link:focus {
top: 0;
outline: none;
}
/* ── Header row ───────────────────────────────────────── /* ── Header row ─────────────────────────────────────────
Teletext's top line, unchanged for forty years: the page Teletext's top line, unchanged for forty years: the page
number you are on, who is transmitting, and the clock. It number you are on, who is transmitting, and the clock. It
@ -512,8 +532,10 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* The key's colour is carried by a solid cell at its head — the /* The key's identity is carried by a cell at its head — the physical
physical coloured button on the remote. */ coloured button on the remote. Solid for most; k-blue and k-dither
override its background below, since a flat swatch can't carry
every one of the eight destinations without repeating a colour. */
.fastext a > span, .fastext-here > span { .fastext a > span, .fastext-here > span {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -538,6 +560,15 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
.fastext .k-yellow { color: var(--voice); } .fastext .k-yellow { color: var(--voice); }
.fastext .k-cyan { color: var(--live); } .fastext .k-cyan { color: var(--live); }
.fastext .k-magenta { color: var(--pending); } .fastext .k-magenta { color: var(--pending); }
/* Blue fails contrast as body text on black, so it marks the swatch
only — the button cap's colour, not the printed label — while the
row's own text stays legible ink. */
.fastext .k-blue::before { background: var(--field); }
.fastext .k-blue:hover, .fastext .k-blue:focus-visible { background: var(--field); }
/* The one other legal way to get a distinct mark out of eight colours
without a ninth: an ordered dither, same mechanism as --dither-mid,
in place of a flat swatch. */
.fastext .k-dither::before { background: var(--dither-mid); }
.fastext a:hover, .fastext a:focus-visible { .fastext a:hover, .fastext a:focus-visible {
background: currentColor; background: currentColor;
outline: none; outline: none;

View file

@ -17,6 +17,12 @@
</head> </head>
<body data-timezone="{{ timezone }}" data-url-base="{{ request.script_root }}" <body data-timezone="{{ timezone }}" data-url-base="{{ request.script_root }}"
data-pages='{{ nav_items | tojson }}'> data-pages='{{ nav_items | tojson }}'>
{#- Skip-link: every page but the index now opens with a full nav
block before any real content — a keyboard or screen-reader visitor
would otherwise tab through all eight destinations on every single
visit. Off-screen until focused, then it's the first thing tabbing
into the page reaches. -#}
<a class="skip-link" href="#main-content">Skip to content</a>
<!-- <!--
THESIS: Media Centre is a broadcast information service, not a dashboard. It THESIS: Media Centre is a broadcast information service, not a dashboard. It
refuses the bordered-card grid every self-hosted dashboard ships, this one refuses the bordered-card grid every self-hosted dashboard ships, this one
@ -56,7 +62,7 @@ provenance.
index's own body already is the full nav. Everywhere else it leads, index's own body already is the full nav. Everywhere else it leads,
right under the header, since it's the only nav those pages carry. -#} right under the header, since it's the only nav those pages carry. -#}
{% set fastext_nav %} {% set fastext_nav %}
<nav class="fastext{{ '' if page == 'home' else ' fastext-top' }}" aria-label="Fastext"> <nav class="fastext{{ '' if page == 'home' else ' fastext-top' }}" aria-label="Page navigation">
{% for cls, key, num, label, href in fastext %} {% for cls, key, num, label, href in fastext %}
{% if num == here[1] %} {% if num == here[1] %}
<span class="fastext-here" aria-current="page"><span>{{ num }} {{ label }}</span></span> <span class="fastext-here" aria-current="page"><span>{{ num }} {{ label }}</span></span>
@ -71,7 +77,7 @@ provenance.
{% if page != 'home' %}{{ fastext_nav }}{% endif %} {% if page != 'home' %}{{ fastext_nav }}{% endif %}
<main class="tt-page"> <main class="tt-page" id="main-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>