# fui-lab A study collection of terminal, cyberpunk, minimal, era, interaction, data and typographic web interfaces, assembled from **existing open-source projects rather than rebuilt**. Every specimen runs live and offline against a pinned local copy of the real library. That rule has **one exception, and it is named rather than quietly dropped**: GSAP is proprietary, licensed by GreenSock at no charge for sites that do not charge for access. It stays because it is the animation library the field actually reaches for, and a study of web motion that excluded the dominant tool on a licensing technicality would be describing a field that does not exist. Two more vendored items are source-available rather than open source, react-bits under a Commons Clause and Aceternity's components under no stated licence at all. All three are in [CREDITS.md](CREDITS.md), at the top, for the same reason any other inconvenient measurement in this lab is at the top. Fifty-nine specimens across eleven strands. The tenth is **full composites**: screens that merge strands rather than study one library, because nothing ships alone and what breaks is the seam. The eleventh is **cyberpunk**, and it exists because the HUD strand was called "Cyberpunk / HUD" for five passes while every specimen in it was an instrument performing competence. High tech, low life is a different register: street level, multi-script, worn, and not on your side. Motion is the subject, not a garnish. A screenshot of any of these is a lie about what the technique actually is, so each specimen declares what animates it and what drives that, and the index can be filtered by motion technique across strands. ## Run it ```sh python3 tools/serve.py 8791 open http://localhost:8791/ ``` Needs a server, not `file://` — the specimens are ES modules. **Use `tools/serve.py`, not `python3 -m http.server`.** It reads `_headers`, so the preview sends what production sends, and it 404s everything in `.assetsignore`, so the preview serves the same *set* of files a deploy would. Two of those headers change behaviour rather than caching — the `Content-Type` override is what makes the markdown readable in a tab instead of downloading, and COOP/COEP decide whether `profile.html` gets a real clock or one clamped to 100 µs. The third reason is newer and cost an evening. A bare `http.server` sends **no `Cache-Control` at all**, so a browser caches the lab's own modules indefinitely and keeps running the previous `lab/specimen.js` after you have edited it — on a phone that is indistinguishable from the edit not happening. `_headers` now sets `max-age=0, must-revalidate` on `/lab/*`, `/examples/*` and the HTML, and `serve.py` applies it. `vendor/` keeps the long cache it should have: it is version-pinned and immutable by construction. ## Layout | path | what | | --------------- | ---------------------------------------------------------------- | | `index.html` | the contact sheet: every specimen live in a lazily-mounted iframe | | `audit.html` | runs axe-core over every specimen and measures the contrast switch | | `contrast.html` | attributes every surviving contrast failure to the rule that sets it | | `profile.html` | mounts every specimen ×1, ×4 and ×12 and measures what survives | | `mobile.html` | mounts every specimen at 390×844 under touch emulation | | `announce.html` | computes what a screen reader would actually say, and how fast it changes | | `SURVEY.md` | the written survey of the open-source landscape | | `examples/` | one self-contained directory per specimen | | `lab/` | shared chrome: catalog, grit, legends, cascade, console layout | | `vendor/` | pinned upstream libraries, unmodified | | `scripts/` | `vendor.sh` re-pulls everything; versions live there and nowhere else | | `scripts/vendor_jp.py` | pulls only the CJK unicode-range subsets the signage strand renders | | `scripts/record_cast.py` | records a real PTY session as asciicast v2, for specimen 21 | `lab/catalog.js` is the single source of truth. The index builds itself from it and each specimen page renders its own caption strip from it, so the two cannot drift. ## Adding a specimen 1. Add an entry to `lab/catalog.js` (id must equal the directory name). 2. Create `examples//index.html`. 3. End it with ``. That script injects the grit finish, the two-level content cascade, the accessibility utilities, the contrast toggle and the caption strip automatically. Add `data-sound="on"` to the `` to get the audio control too. Three house rules the shared layer enforces, all for the same reason — the user decides once and everything obeys: - **sound is off until asked for**, and no `AudioContext` exists before that; - **contrast is switchable**, because dim-on-dark is the aesthetic rather than a bug, and the audit measures the switch instead of asserting it. A switch can only move colour that was written as a *token*, so the lab-wide defaults in `lab/contrast.css` are wrapped in `:where()` and **each specimen owns its own high-contrast palette** — only the specimen knows what hue it is in, and the lab-wide fallback turns every dim colour the same blue-grey, which is right for the console strand and quietly wrong for a green terminal. Same hue, same saturation, lightness raised until it clears 4.6:1 on the background it actually sits on, measured with `contrast.html` rather than eyeballed; - **motion is stoppable in-page**, not only through a system setting — which, in a lab whose whole subject is motion, was the switch that should have come first. A second unenforced rule, from the phone profile: **type in `px`, not in `vw`.** `lab/console.css` sizes itself with `clamp(7px, .92vw, 14px)`, and at 390px wide that pins to its 7px floor while every em-relative child falls below it. The four specimens that survive a phone are exactly the four that set type in pixels. A third, from the typography strand and sharpened by the cyberpunk one: **`ch` is the width of a Latin zero**, and it does not turn with the writing mode. Every rail in this lab is sized in `ch`, which works only because the text is Latin and the font is monospace. A CJK glyph is about 1.7 `ch` on this machine, so every one of those measurements silently means less text than intended — see specimen 37, where the number is measured rather than asserted. In `writing-mode: vertical-rl` it sizes a column's *thickness* while appearing to size its length (specimen 42). And one design rule that is not enforced by anything, so it has to be remembered. **Corner treatment says where a panel sits, never which panel it is.** augmented-ui offers every permutation there is, and using a different one per panel makes the geometry meaningless — see finding 23. Body panels clip one diagonal, always the same one; an edge bar clips the two corners on the edge it holds; the viewport clips all four. One size token, and no `-2-clip-x/y` double notches anywhere. And one rule the composite strand added, which nothing enforces either. **A clip is a cut.** `clip-path` clips descendants, so a panel's corner geometry removes content — map canvas, table header, the last digit of a right-aligned number — and every clipped panel has to reserve interior padding equal to its own corner. That is a different claim from the rule above: this one is about the box model, that one is about meaning. ## The shared layer | file | what it gives every specimen | | ---- | ---------------------------- | | `lab/catalog.js` | the single source of truth: strands, motion tags, verdicts | | `lab/grit.css` | the default finish — animated grain, scanlines, vignette (`fine` / `coarse` / `crt` / `off`) | | `lab/cascade.css` | the second level of stagger: panel arrives, then its sections | | `lab/legend.css` | four variants for labels that ride the top border | | `lab/console.css` | the full-screen operator layout: rails, bottom strip, viewport | | `lab/sound.js` | the shared audio channel: default off, one control, lazy context | | `lab/a11y.js` | `stableText` / `stableShards` — separate the performance from the content | | `lab/contrast.css`| the high-contrast switch, and the toggle mounted on every specimen | | `lab/motion.js` | the motion switch: CSS, WAAPI and a `requestAnimationFrame` gate | | `lab/react-host.js` | compiles the React strand's real upstream TSX in the browser | | `vendor/type/` | the lab's own faces: Recursive (variable), IBM Plex, Noto Sans Arabic | | `lab/specimen.js` | wires all of the above up from the directory name | ## The survey [`SURVEY.md`](SURVEY.md) is the written half: every project's licence, size, activity and verdict across eleven strands, a table of when to reach for each motion technique, the findings that cost real time while building, and the gaps this survey does not cover. The seventh pass added eight specimens and shut both gaps the cyberpunk strand had listed as open — a shader specimen that works in the *signal* domain rather than drawing a better CRT (47), and a soundscape that puts §7's own verdict on trial rather than repeating it (49). The other six ask questions the strand had not: what the sheared-slab grammar costs (48), what happens when a control has a price (50), where six labels go once there is a crowd (51), which two numbers decide what your hand meant (52), what a character grid does outside Latin (53), and what an ellipsis removes in a language you did not lay out for (54). Findings 72–84 — and the pattern across five of them is worth the sentence: **the instrument was the finding.** A sampler coarser than its subject, a metric in the wrong frame of reference, an audio driver that lost its own argument by talking too much, a parser that returned NaN and kept going, and a rail that printed its expected answer as a constant. Every one produced a clean, confident, wrong number first. The eighth pass vendored nothing. It **split the era strand** — one specimen carrying five findings became one per sheet (55–59) plus the comparison (18), which keeps the only question that belongs to none of them: which of these sheets are actually interchangeable, measured as a class-vocabulary overlap rather than asserted. Findings 85–92, and the reason the split was worth doing is that §6 had been making three claims written from *reading* the sheets, and counting them showed two were wrong: 7.css's four opt-in classes are Aero capabilities rather than motion classes, and the strand's motion is not a ramp from none to some but four sheets with four different reasons. The instrument pattern held for a third pass — a GIF sampler that composited instead of clearing, and then, once fixed, a `drawImage` that returns frame one forever and reports a visibly spinning image as stopped. Four of its sections are measurements rather than opinions, produced by the tool pages above: - **Accessibility.** Two rules fail across the whole lab. Colour contrast, on 661 elements at 54 specimens, which the high-contrast switch takes to **2** and then to **0** once the specimens have finished arriving. And one ARIA violation, which is **irreducible**: a virtualised table needs a scroll wrapper between `role="grid"` and its rows, and ARIA has no role for one — neither leaving it generic nor marking it `presentation` is legal, and both were tried. It appears in five specimens rather than one, because 38, 39, 40 and 41 each compose the same table: **a library's irreducible defect is inherited by every screen that includes it.** The standing claim that the survivors were vendored greys was wrong, and `contrast.html` is what proved it: of the 83 that used to survive, **76 were the lab's own specimens hard-coding a hex** and 3 were a vendored palette. The same mistake recurred immediately in the new specimens — a Tabulator cell formatter is exactly where you reach for `style="color:…"`, and 20 cells went straight back into the unreachable pile until `contrast.html` caught them. A further **405 elements axe cannot judge at all**, because the grain overlay that makes this aesthetic work also defeats background resolution — so the specimens that report cleanest are the ones measured least. That total is now **990**, and its two leaders are both composites — 41-house-style at 155 and 39-watch-floor at 139: the specimens carrying the most content under the most finish are simultaneously the least legible and the least auditable. And a failure mode no audit here can see at all, found by specimen 39 and measured from `grit.css`'s own gradient stops: **the finish is not uniform, so contrast is a function of position.** One unchanged colour pair reads 10.12∶1 as it renders, 6.21∶1 on a scan line and 3.71∶1 in the corner under the vignette. Every tool in this repo samples a colour *pair*, and the pair is exactly what does not change. That panel also got it wrong first, and the correction is the more useful rule: it *named* its two colours as constants and reported 9.63∶1, while the colour actually behind those rows was `#666` from Tabulator's own theme, showing through rows the specimen had made transparent — a real ratio near 3.0∶1. **A measurement that names its own colours is a claim, not a measurement.** Both are now read off a live element with the background composited up the ancestor chain. - **Performance.** Not re-run since specimen 24; **25–54 are unprofiled**, so the table is a 24-specimen result quoted in a 54-specimen lab. Of what was measured: every WebGL specimen holds 100 % of its frame rate at twelve live copies. The one that collapses, to 30 %, is pure CSS — animating clip-path geometry cannot be composited. Measure on an idle browser: a run taken with a dozen other lab tabs open reported one specimen at *106 %*. - **The phone.** A 27-specimen run, not re-taken since. Thirteen specimens render every piece of text under 9px at 390×844; the measured minimum is 5.2px. Four specimens overflow, worst being 11-engine-bench with 325px off-screen. Tap targets are worse than the layout: 22 of 22 under 24px in 17-audible. Nothing is near the frame-rate limit. - **What gets said.** 1,337 announcements across the lab, computed with the real accname algorithm and cross-checked against Chrome's own accessibility tree. Twelve canvases announce nothing. The worst defect is upstream's: asciinema-player marks its terminal as a polite live region with no name and feeds it **70 text mutations a second**. ## Licence and credits This lab's own work is MIT, see [LICENSE](LICENSE). That covers `lab/`, `examples/`, `scripts/`, the tool pages and [SURVEY.md](SURVEY.md). Everything under `vendor/` is unmodified upstream and stays under its own licence. [CREDITS.md](CREDITS.md) is the inventory: 80 vendored items across 12 licences, generated by `scripts/credits.py` from `scripts/vendor.sh` and the registry rather than written by hand, because a hand-written dependency list is a claim that drifts. Each package's own licence text is mirrored into `vendor/licenses/`. **The site is public, the repository is not.** The lab publishes findings, not a distributable codebase. What is worth reading here is the survey and the specimens running, and both of those are on the web; what is in the repo is those plus 28 MB of other people's pinned libraries, a frozen snapshot nobody should file issues against. Licensing is a small part of that and not the reason: serving a vendored library from a site nobody is charged for is ordinary licensed use for all three of the non-open-source items above.