Skip to content
AstroCraft Docs
On this theme

Navigation & Fastext

TVfolio has no navigation bar. It has six coloured fastext keys along the bottom of the tube and a PAGES drop-up that lists eleven channels — the navigation of a BBC Ceefax set, which gave viewers four coloured fast-access keys. This cabinet has six.

All of it comes out of one file, src/config/navData.json.ts, and the interesting part is how little is stated twice.

The six keys

const fastextKeys: readonly FastextKey[] = [
  { fkey: 1, label: "Work",    colour: "red",     href: "/work/",    compact: true },
  { fkey: 2, label: "About",   colour: "green",   href: "/about/",   compact: true },
  { fkey: 3, label: "Contact", colour: "yellow",  href: "/contact/", compact: true },
  { fkey: 4, label: "CV",      colour: "cyan",    href: "/cv/" },
  { fkey: 5, label: "Blog",    colour: "magenta", href: "/blog/" },
  { fkey: 6, label: "Pages",   colour: "white",                      compact: true },
];

The colour is the key on a teletext remote, so it lives in the data beside the label rather than being chosen by the component that draws the bar.

A key with no href renders inert — a legend with no link, not a dead one. PAGES is the only key without one, because on a real set it navigates nowhere itself: the ▲ opens the channel index below, so FastextBar renders it as a disclosure.

compact: true marks the keys the mobile frame keeps. The 374px cabinet draws four keys — WORK, ABOUT, CONTACT, PAGES — rather than squeezing six; CV and BLOG are dropped, and they are still reachable through the PAGES index.

The eleven channels

export const channels: readonly Channel[] = [
  { ch: "01", label: "Home",      href: "/" },
  { ch: "02", label: "Work",      href: "/work/" },
  { ch: "03", label: "Project",   via: "Work" },
  { ch: "04", label: "Blog",      href: "/blog/" },
  { ch: "05", label: "Bulletin",  via: "Blog" },
  { ch: "06", label: "About",     href: "/about/" },
  { ch: "07", label: "Contact",   href: "/contact/" },
  { ch: "08", label: "CV",        href: "/cv/" },
  { ch: "09", label: "Audio" },
  { ch: "10", label: "Episode" },
  { ch: "--", label: "No signal" },
];

A teletext set numbers every page it can tune to, so this is the site map — and it is a superset of the six keys. HOME, PROJECT, BULLETIN, AUDIO and EPISODE have a channel but no button.

Three entries are deliberately href-less in ways worth understanding:

PROJECT and BULLETIN name a page kind, not a page. An index row cannot point at one slug, so they carry no href even though /work/<slug>/ and /blog/<slug>/ routes exist. Instead they carry via, which names the key that reaches them.

CH -- is the 404 — a state, not a page, which is why its number is drawn as dashes. It has no href even though 404.astro exists, because a static build emits it as the host’s not-found document and linking to it directly is a route that only accidentally resolves.

The three helpers

This is where the design pays off. None of the joins between keys and channels is stated twice.

channelFor(label)

channelFor("About")  // "06"

Every page declares its name once and the channel bar looks the number up. A route that hard-coded channel="02" would mirror a fact this file already owns, and the two drift silently the day the index is renumbered.

It throws rather than returning undefined, and the reasoning is specific: it runs in Astro frontmatter, so an unknown label fails the build — which is the right moment to find out a page has no channel. A fallback would ship a tube tuned to nothing.

fkeyFor(label)

fkeyFor("Contact")  // 3
fkeyFor("Home")     // undefined — home has a channel, not a button

Matched on label rather than stored on the channel, so the drop-up’s right-hand column and the key row are the same fact read twice. Rename a key and the index follows.

fkeyLabelFor(channel)

fkeyLabelFor({ ch: "03", label: "Project", via: "Work" })  // "F1"
fkeyLabelFor({ ch: "01", label: "Home" })                  // "—"

The F-number column as the index draws it. Both surfaces that draw that column — the PAGES drop-up and the 404’s lineup — read this, so they cannot answer the same question differently.

It also owns via, which is the one rule those two surfaces used to disagree about. The 404 held it as a page-local map, which put it beyond the reach of navData.test.ts — the check that exists for exactly this join.

assertChannelLabels(labels, what)

A build-time throw for surfaces that key a map by channel label, such as the 404’s lineup. A label naming no channel would silently drop its row, which is indistinguishable from the deliberate omissions those maps rely on — AUDIO and EPISODE are left out on purpose. This separates the two: what is present must be real, what is absent is a choice.

The comment explains why it is a runtime throw rather than a derived literal union: as const would type the labels, but it would also split channels into eleven differently-shaped literal objects, and every consumer reading channel.href then breaks on the five that have none.

The current-page states

FastextBar takes two props, and the distinction between them is subtle and correct:

current is the page you are on. That key renders inert and pressed, carries aria-current, and is bracketed on a wide enough cabinet. It is the page’s name, not necessarily a key’s — / passes "Home", which has a channel but no key.

section is the key whose section this page lives under, when that is not the page itself. A project page is CH 03, which sits inside WORK, so the WORK key is drawn held down — but it stays a live link, because it is the way back to the index rather than the page you are on.

Getting that pair right is what makes the navigation feel like a real receiver rather than a menu with a highlighted item.

The PAGES drop-up

The channel index the F6 key opens. Three things about its implementation are worth stealing.

It is a real <details>, so the index works with scripting off. The bundled script adds only what <details> has no answer for — Escape and click-outside — and is delegated on the document, so it binds once and survives <ClientRouter /> swaps.

It renders the PAGES key itself, not just the panel, because <summary> must be its <details>’s first child. FastextBar passes the key’s computed classes in as props (keyClass, fkeyClass) so the pressed and raised mouldings stay defined in exactly one place.

The <details> is left position: static on purpose, so the panel and its scrim resolve against the nav instead. That is what makes right: 0 mean the bar’s right edge — where the design puts it — and width: min(100%, …) mean the bar’s width, so the panel can never grow wider than the picture. Anything overflowing the tube is clipped by .tv-screen’s own overflow: clip, exactly as the design clips it.

The check

navData.test.ts covers the joins, and reports on a clean run:

navData: 11 channels, 6 keys, 11 live href(s) — ok

It exists because the key-to-channel join is the kind of thing that goes wrong silently — a renamed key leaves an index row pointing at nothing, and the page still renders.

Adding a page to the navigation

Add the channel in the same commit that adds the route:

{ ch: "11", label: "Uses", href: "/uses/" },

The page then declares const page = "Uses", channelFor finds it, and the row appears in the drop-up and the 404 lineup automatically.

Giving it a key is a different decision. There are six, they are physical buttons on a real remote, and the mobile cabinet only draws four. Adding a seventh is a design change, not a config edit — and the colours are the six teletext colours, so there is no seventh colour to give it.

The rule the file states, and the one that will save you a 404: give a key or channel its href in the same commit that adds its page. With trailingSlash: "always", a missing route is a hard 404 rather than a soft landing.

NEXT STEPSEO & Structured Data