Skip to content

Documentation site: Starlight at /docs, themed to the project site - #4

Open
eins78 wants to merge 22 commits into
feature/dexdfrom
feature/docs-starlight
Open

Documentation site: Starlight at /docs, themed to the project site#4
eins78 wants to merge 22 commits into
feature/dexdfrom
feature/docs-starlight

Conversation

@eins78

@eins78 eins78 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Renders the markdown in docs/ as a Starlight site under dex.ars.is/docs, themed to
packages/website. Starlight is an Astro integration, so the two sites share a toolchain and an
origin: one deploy, one DNS record, and /apt left free for a future package repository.

The site had not been scaffolded, so this branch adds packages/docs and the theme together.

Where the text lives

The markdown stays at the repository root. scripts/sync-docs.mjs copies it into the content
collection and lifts each page's level-one heading into the title frontmatter Starlight requires;
the copy is generated and git-ignored. Starlight validates that title when the collection loads,
before remark plugins run, so a remark plugin cannot supply one.

Keeping docs/ as the source means CODEOWNERS still routes it, scripts/docs-lint.mjs still
checks it, and every page reads correctly on GitHub. The site's own pages, currently the landing
page, live in packages/docs/src/site/.

Theme

packages/docs/src/styles/dex.css. A palette sets seven custom properties, and one mapping block
translates those into the --sl-* properties Starlight reads, written once for both colour
schemes. Adding a palette is copying a block.

Carried over from the project site: the pair recorded in packages/branding, #8ed1de and
#2922c8, monospace body copy, and system-ui at weight 800 on h1 and h2 only. Because the
treatment stops at h2, the glossary's term headings stay monospace, which suits terms a reader
meets in a file or a command.

Added for a documentation site, which has more surface than a one-page site: flat sidebar and
header on the same ground as the text column, separation by hairline, no drop shadows, the current
sidebar item marked by a rule, and rules on both axes in tables.

The colour-scheme control

One button replaces Starlight's light and dark select and steps through three schemes, because the
palettes differ in more than lightness.

Step Glyph Palette Colour scheme Body contrast
Blue ☁︎ brand always dark 5.71:1 — AA
Dark brand-deep dark 9.60:1 — AAA
Light ☀︎ brand-deep light 11.41:1 — AAA

Blue is the project site's colours and is what a reader sees first. From there the cycle goes to
the scheme the reader's system asks for, then to the other one, then back to Blue. An
AAA-contrast alternative is one click away, and the second click covers a reader whose system
setting does not suit the room.

The button shows the glyph alone; each step's name reaches a screen reader through the accessible
name and a pointer through the tooltip. State is in localStorage under dex-view and survives a
reload. ThemeProvider.astro holds it and applies it, inlined in the head so no step flashes
before the stored one.

Verified in a browser under both system settings:

system dark:   Blue -> Dark  -> Light -> Blue
system light:  Blue -> Light -> Dark  -> Blue

Contrast

Five palettes are defined; the cycle uses two and ?palette= previews any of them, colours only.
Body text against background, WCAG 2.1:

?palette= dark light
brand (default) 5.71:1 AA 5.71:1 AA
brand-deep 9.60:1 AAA 11.41:1 AAA
ink 11.79:1 AAA 15.11:1 AAA
amber 9.27:1 AAA 16.11:1 AAA
brand-day 12.39:1 AAA 5.71:1 AA

The dimmed colour in brand first measured 4.24:1, under AA for normal text, and it carries the
table of contents. It has to stay under the body text's 5.71:1 to read as secondary and over
4.5:1 to pass; #8bc0d1 measures 4.89:1.

Community themes

Of the published Starlight themes, most are colour schemes — Catppuccin, Nord, Gruvbox, Rosé Pine,
Flexoki, Rapide — and this project supplies its own. The layout themes bring colour schemes and a
scheme picker to remove again. @shuttering/starlight maps a token contract onto the same --sl-*
properties dex.css does, and carries an organisation's design system this project is not part of.
The arrangement here is one stylesheet with no dependency, matching how the project site overrides
its own base stylesheet.

Checks

  • pnpm build clean, pnpm check 0 errors, 0 warnings, 0 hints
  • node scripts/docs-lint.mjs packages/docs/ 0 errors, 0 warnings
  • docs/lint-allow.txt gains CSS, DNS, WCAG, AAA, CODEOWNERS and KTE, each with a
    reason. node scripts/docs-lint.mjs docs/ AGENTS.md is unchanged by that addition
  • The lint caught seam in the landing-page copy, a coinage the glossary retires in favour of
    gapless

Not in this branch

No deployment workflow. .github/workflows/pages.yml publishes packages/website alone, and
serving both at one origin is a separate change.

eins78 added 22 commits August 17, 2026 19:49
packages/dexd was a gitlink to github.com/eins78/dexd, a repository
that no longer exists. The player crate is placed directly in the
monorepo instead of as a submodule (story decision 2026-08-17):
submodule friction — the crate's text, packaging, CI and the OS image
change in lockstep, and a submodule boundary got in the way of that.
Source: home-workspace experiments/2026-08-12-4k-hevc-perfect-loop/dex-loop
at commit 71711ec1 (dex-loop's own history, log -1 on that path).

Copied with deploy/, renamed dex-loop -> dexd throughout: crate name,
binary, Debian package, systemd unit, man pages, lintian overrides,
maintainer scripts, changelog head, test binary paths, and every
in-source reference (usage text, log/error prefixes, doc comments).
The ingest sidecar checker sidecar-check.rs -> dex-sidecar.rs, per the
story's naming decision (helpers keep the dex- prefix; the main
binary/package/unit becomes dexd). build.rs's git-HEAD paths adjusted
for the crate now sitting two levels below the repo root
(packages/dexd) instead of one.

Not copied: IMPLEMENTATION-PLAN.md, PLAN.md, soak-24h/ -- private
experiment records that stay in home-workspace. README.md travels for
now (it still says dex-loop throughout) and is replaced with rewritten,
outsider-readable docs in Phase 2 of the extraction plan; same for the
historically-accurate old-name mentions in deploy/changelog's older
entry text and a doc comment in dex-sidecar.rs describing the tool's
pre-cargo-workspace history.

No prose was rewritten and no behaviour changed: cargo check/clippy/
test --lib, cargo deny check, reuse lint and shellcheck all pass
unmodified from source, same as before the move.
Copied from home-workspace's ci/dex-loop-deb.yml (same source commit as
the crate, 71711ec1). Renamed dex-loop -> dexd throughout: workflow
name, job/artifact names, concurrency group, cache key, systemd unit,
apt package name, binary paths, tag pattern (dex-loop-v* -> dexd-v*).

CRATE_DIR points at packages/dexd instead of the experiment's nested
path; the changes-detection path list matches packages/dexd/** and
this workflow file. Added "feature/**" to the branch push triggers so
this branch's own pushes run it. Dropped the "Shellcheck the bench
harness" step: it shellchecked scripts/*.sh under the experiment
directory, which is harness code that intentionally stays in
home-workspace and has no equivalent path in this repo.

No prose was rewritten beyond substituting the renamed identifiers and
paths; the design-rationale comments are unchanged.
…aid dex-loop

The 0.1.0-2 entry body named dex-loop.service; the changelog ships publicly
as /usr/share/doc/dexd/changelog.gz and the dexd package never had a unit by
that name. Name substitution only, no wording change.
Anyone with a raw HEVC stream needs a sidecar before dexd will play it, and
until now the only thing that could write one lived in a private repository
(scripts/make-sidecar.sh). The crate shipped a checker but no writer, so the
README's answer was a hand-rolled printf with shasum in it. The writer now
lives next to the parser it has to satisfy.

The CLI has two subcommands:

  dex-sidecar check <sidecar.json> <stream.265>
  dex-sidecar write <stream.265> [--fps F] [--out FILE] [--force]

check is the old behaviour and the old exit codes, unchanged. The bare
two-argument form is gone; it prints usage instead. Nothing had released it,
and nothing in the tree called it.

Ported from the shell script, rule for rule:

  - sha256 over the exact bytes on disk, the same read the player does.
  - The frame rate comes from ffprobe's r_frame_rate, and is believed only
    between 1 and 1000 fps. Outside that, ffprobe is reporting its internal
    timebase (1200000/1) rather than a rate, which is what a raw stream with
    no timing in its headers produces. Then, and when ffprobe is absent,
    --fps is required rather than guessed.
  - An explicit --fps that sits more than 0.02 fps from a detected rate is
    refused unless --force is given. A typo there binds a wrong rate into a
    sidecar that verifies perfectly forever.
  - An existing sidecar is not replaced without --force.
  - Width and height come from the same ffprobe call, and are left out when
    it cannot report them.
  - Before anything is written, the sidecar is parsed back with the crate's
    own Sidecar::from_json and verified against the stream bytes. --fps is
    validated the same way, by building a sidecar around it, so there is no
    second copy of the rate format anywhere in this file.

Tests: tests/sidecar_write.rs ports the shell suite's cases against real
streams made with ffmpeg — write then check, a flipped byte failing check,
overwrite refused then forced, a typo'd --fps refused then forced, --fps
required on a stream whose encoder wrote no timing, and the malformed
command lines. They skip loudly when ffmpeg is missing; CI now installs it
and fails the step if ffprobe is absent, so they cannot skip their way to
green. Rate parsing, the tolerance, the plausible-range bound, and argument
parsing have unit tests in the bin.
… the tests

Follow-ups from the port's verification:

- write_atomically removes its temp file when the write itself fails, not
  only when the rename fails; the shell script's exit trap covered both.
- The test suite no longer skips silently without ffmpeg: the test runner
  hides stderr from passing tests, so the old "loud skip" was invisible
  under a plain `cargo test`. Missing tools now fail with the install
  command; DEXD_ALLOW_MEDIA_SKIP=1 opts a machine into skipping.
- New test for the ffprobe-absent path: --fps is required, and the written
  sidecar carries no width/height it could not learn. Needs no ffmpeg.
- rustfmt on both files.

Also recorded here, since the port's report implied stdout parity with
make-sidecar.sh: `write` prints only the sidecar JSON on stdout (the "OK …"
line of the round-trip check goes to stderr), so stdout is parseable.
…t owner

docs/glossary.md holds the 147 terms the documentation may use without
explanation (user tier and developer tier), each reviewed and approved
one by one on 2026-08-18. AGENTS.md carries the writing rules for
everything an outsider can read, the names that were decided, the table
of retired words with what to write instead, and the glossary governance:
agents propose, the owner approves; CODEOWNERS routes every change to the
glossary, the rules and the lint tables to him. CLAUDE.md includes it.
…S.md

scripts/docs-lint.mjs (Node 20, no dependencies) checks the rules a
machine can check: plan codes, references to private documents, dates as
structure, caps emphasis against the glossary's acronyms, intensifiers,
retired coinages (docs/lint-coinages.tsv), codenames, first person,
process talk, over-long comment blocks, headings. 82 tests. It reads
docs/lint-allow.txt, where every exception must carry a reason.

CI: the changes job now also reports a `docs` flag; a docs-lint job runs
when docs/, AGENTS.md or the tool changed, and the required gate treats
its skip as legitimate only when nothing docs-related changed. Scope is
docs/ and AGENTS.md for now; the crate's comments and README join when
their rewrite lands.
…upervisor thread, plain messages

The vocabulary review settled the project's names; the table in AGENTS.md
("Names that were decided") is the specification. This applies the four that
reach into code, with the tests that assert them. Renames only: no behaviour
change, no dependency change, no prose rewrite.

- The heartbeat field is `loops=`, not `wraps=`. The identifiers built on the
  old coinage follow: WRAP_COUNT -> LOOP_COUNT, the Heartbeat field
  `wraps` -> `loops`. Byte-position arithmetic in chunk.rs still wraps back to
  byte 0 — that is the mechanism, not the coinage, and is untouched.
- The test-rig flags say what they are and are grouped under one prefix:
  --bench-no-sidecar -> --test-rig-no-sidecar,
  --bench-wedge-after-secs -> --test-rig-hang-after-secs,
  --force-recovery-after-secs -> --test-rig-force-recovery-after-secs.
  Their identifiers follow, the marker "BENCH ONLY" becomes "(test rig only)",
  and "wedge/wedged" in identifiers and shipped lines becomes hangs/unresponsive
  — never "hung".
- The thread that supervises playback is the supervisor thread everywhere,
  which is what health.rs already called it.
- The message that told an operator to "re-ingest the asset" now names the
  command that does it: "prepare the video again with dex-sidecar write". The
  three nal.rs messages whose remedy is a re-encode say "prepare the video
  again with a closed-GOP encode" instead, since dex-sidecar write does not
  encode. Exit codes and the rest of every message are unchanged.

deploy/dexd.service passes none of these flags and needs no edit. README.md and
deploy/man/dexd.1 got the mechanical substitution only — both are rewritten
later.
Written fresh from the content inventory and the approved glossary under
the rules in AGENTS.md; each went through two review rounds (outsider
readability, vocabulary and lint, faithfulness to inventory rows), a
copy-edit pass and a final gate. Both lint clean. Calibration pair for
the register: one user guide, one design document.
…ea per paragraph, no history, short

From the calibration review of the first two documents: content right, tone still the model's own. Reference points are the owner's manual and a component reference he rates well; the section states the rules and shows five draft sentences rewritten.
dexd reads /opt/dex/exhibit.yaml, then /opt/dex/exhibit.json. Nothing reads
/etc/dex any more, and the .deb ships no exhibit config: no conffile, no
default written by postinst, no /etc/dex directory. A missing config makes
the player refuse to start and name the file to create, with the two lines
that file needs.

/opt/dex is the mount point of the dex card's data partition, so the card in
a computer shows the video, its sidecar and exhibit.yaml together and a
technician edits the config there. The unit waits for that mount.

A relative `asset` resolves against the directory the config file is in, so
`asset: artwork.265` beside /opt/dex/exhibit.yaml names /opt/dex/artwork.265.
Absolute paths are unchanged, and the cross-check against a command-line
asset compares the resolved path.
Follow-ups from the verification of the config move: the postinst notice
now says what a player needs — the video, its sidecar and exhibit.yaml,
all in /opt/dex — instead of nagging about one file name; a config left
under /etc/dex, which dexd no longer reads, is called out once at install
time with the command to move it; and the no-asset refusal shows the
same bare-filename example the no-config refusal shows.
…rocess references

The owner's read of the branch: the CI workflow and Cargo.toml comments
narrate history and pass verdicts on their own design ("the cost is real
and deliberate", "what it buys", "DECLARED, not avoided"). A new subsection
under Tone says what a comment is for and shows five of his rewrites. The
lint allowlist and coinage table carried review-process references in
their reasons; those are plain now.
packages/docs renders the markdown in docs/ as a Starlight site under
dex.ars.is/docs. Starlight is an Astro integration, so it shares a
toolchain and an origin with packages/website.

The markdown stays at the repository root. scripts/sync-docs.mjs copies
it into the content collection and lifts each page's level-one heading
into the title frontmatter Starlight requires; the copy is generated and
git-ignored. Starlight validates that title when the collection loads,
before remark plugins run, so a remark plugin cannot supply it. The
site's own pages, currently the landing page, live in src/site/.

src/styles/dex.css carries the brand. A palette sets seven custom
properties and one mapping block translates those into the --sl-*
properties Starlight reads, written once for both colour schemes. The
recorded pair, the monospace body and the grotesque h1 and h2 follow
packages/website; the flat chrome, the hairline separation and the
absent drop shadows are what a sidebar and a header need that a one-page
site does not.

Five palettes preview with ?palette=, four of them the ones the project
site already offers. The parameter is palette because Starlight uses
data-theme for its light and dark toggle. Contrast is measured and
recorded per palette: the dimmed colour in the default has to sit
between 4.5:1 and the body text's 5.71:1, and #8bc0d1 measures 4.89:1.
CSS, DNS, WCAG, AAA, CODEOWNERS and KTE, each with its reason. The
contrast figures recorded per palette need WCAG and AAA; the rest appear
in packages/docs/README.md.
The header button replaces Starlight's light and dark select and steps
through bold, quiet, quiet-flipped. Bold is the recorded pair on its
full-saturation ground and is always dark; both quiet steps are
brand-deep, the first in the colour scheme the reader's system asks for
and the second in the other one.

The palettes differ in more than lightness, so a light/dark pair does not
describe them. The order puts the project site's colours first and an
AAA-contrast alternative one click away, with a second click for a reader
whose system setting does not suit the room.

ThemeProvider.astro holds the state and applies it, inlined in the head
so no step flashes before the stored one; ThemeSelect.astro is the
button, wired in the header and the mobile menu alike. The state lives in
localStorage under dex-view. The button's swatch is drawn from the live
custom properties, so it follows a palette change without being told.

?palette= still previews any of the five, colours only; the step decides
the colour scheme.
The control shows one character and nothing else: cloud for Blue, moon
for Dark, sun for Light. The names moved to the button's accessible name
and its tooltip, so a screen reader and a pointer both get "Colour
scheme: Blue. Change it." while the header stays quiet.

The glyphs are text presentations, so each takes the button's colour
instead of drawing itself in an emoji font's own palette. U+263E has no
emoji presentation to suppress; U+2601 and U+2600 carry U+FE0E, the text
variation selector.

U+2601 draws in the lower half of its em box while the other two fill
theirs, so on a shared centre it reads as sitting low. The script writes
the step onto data-scheme and the stylesheet lifts that one glyph.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant