--- name: space-registry-ui description: Build a page in Celia's proposed UI for the space object registry (her live site at smartrules.ai/spaceregistry, measured; not the generic design-system folder). Trigger on "Celia's registry UI", "the space registry look", "build a registry page in the adopted style", "apply Celia's UI to X", or any request to extend the registry site's design beyond its built pages. Carries the measured tokens, the shared stylesheet and script, the component recipes, and the measuring loop that proves a copy of one of her pages is exact. --- # Celia's registry UI Frank, 02-09-2026: "this is Celia's proposed UI, much better than my version; make a template/skill out of it." Frank, 03-09-2026, after putting the two side by side: "copy exactly celia's UI and reflect in skill." ## The lesson: measure, never copy by eye The first rebuild read her compiled stylesheet and her markup, and still came out wrong. Frank saw it at a glance: the hero glowed warmer, the globe sat lower, the nav was spaced differently, and the title wrapped onto two lines where hers holds one. Reading a stylesheet tells you what a page DECLARES. Only the browser tells you what it PAINTS, and the two parted company in five places at once. The loop that fixed it, and the loop to run on any new copy: 1. Load her page and ours at the same viewport in the same browser. 2. Dump the SAME named landmarks from both with `getBoundingClientRect` and `getComputedStyle`, not her class names: header, logo, each nav link, the button, the hero box, the globe, the badge, h1 (with its rendered LINE COUNT), the dek, the marquee, the search row, the stats, each section, the cards, the entries, the closing call, every footer part. 3. Print the difference and fix every line of it, then dump again. 4. When the boxes agree, screenshot both full pages and diff the pixels, band by band, so what the boxes cannot say (colour, gradient, weight) shows up as a number. The tool is written and lives with the work: `3 - Projects/space-object-registry/captures-mine/exact/probe.py` (`measure` / `diff` / `shot` / `pixdiff`). It scrolls the whole page before measuring, because her cards, entries and closing call arrive from 30px below as they scroll into view and a dump taken at rest catches them mid-flight. Two habits it enforces. **Compare the notations, not the strings**: Tailwind writes colours in oklab and a full radius as `3.35544e+07px`; plain CSS writes `rgba()` and `9999px`. They paint the same pixels, so the diff normalises them and only real differences survive. **Freeze what moves**: her hero canvas animates and her marquee slides, so exclude the marquee band from the pixel number and say so, and drive the globe page with `prefers-color-scheme`'s sibling `prefers-reduced-motion: reduce`, which stops her globe at phi -1.84 and makes a frame-for-frame comparison possible. ## What was wrong, and what it cost | Element | Hers | Ours before | Ours now | |---|---|---|---| | Type | the browser's default sans stack | Inter, loaded from Google Fonts | her stack, no webfont | | h1 at 1440 | 757.8px wide, ONE line | 768px wide, TWO lines | 757.8px, one line | | Hero background | a three.js canvas: near-black with soft blooms | a strong orange and violet CSS glow | a gradient fitted to her canvas, 1.5 per channel off | | Nav | absolute, centred on the viewport, gap 4px | pushed by space-between | absolute, centred, gap 4px | | Nav link box | 14px on a 20px line, 36px tall | 24px line, 40px tall | 20px line, 36px tall | | Hero column | an overlay 1080px wide, 448px tall, centred on the section | a flex child of the section | her overlay | | Dek | 18px on a 28px line | 18px on a 24px line | 28px | | Stats band | py-20 (80px), wrap max-w-7xl with its own padding | py-16, padding on the grid | hers | | Section h2 | text-5xl, line-height 1 | line-height 1.25 | 1 | | Footer | drawn rules, corner squares, a legal strip of its own | a simplified box | hers | | Page height at 1440 | 3655px | 3651px | 3655px | **The font was the whole title bug.** Her site declares Inter through next/font (eight `@font-face` rules) and never renders it: her theme sets `--font-sans: var(--font-sans)`, a self-reference, so the `font-sans` utility resolves to nothing and everything falls back to the browser default. Proof, read in the browser and not in the CSS: `document.fonts.check('60px Inter')` is false, no face reports `loaded`, and the computed family on body and on the h1 is `ui-sans-serif, system-ui, sans-serif, …`. The same h1 string measures 804.33px in that stack and 894.97px in Inter at 60px/500; with tracking-tight that is 757.83 against 848.5, and her h1 box is `max-w-3xl` (768px). Inter wraps her title; the system stack keeps it on one line. **So: load no webfont.** `--font-display` and `--font-body` in `tokens-celia.css` carry her stack verbatim. A page in her look that links a font is already wrong. ## The files All in `3 - Projects/space-object-registry/`. Link the first two from any new page; add the others when the page needs what they do. | File | What it is | |---|---| | `tokens-celia.css` | the `:root`. Every value cited with where it was read. Link it, never paste it inline (see The checks). | | `celia-ui.css` | her whole page as plain CSS: header, hero, marquee, search, stats, cards, entries, closing call, footer, the globe page, and the document pages. Colours only through `var()`. | | `celia-ui.js` | the four things her page does: the marquee at 50 px/s, the stats and the latest entries from the data file, the reveal, the mobile sheet. | | `celia-globe.js` | the globe page's turning Earth, chips and record dialog. | | `celia-orbit.js` | our own hero variation, the ring of types (not hers). | | `cobe-land-map.js` | cobe's own 256x128 land map, lifted from her chunk. | | `celia-data.js` | her eight entries with every field, her four orbits, her seven types, her four numbers. **Never `registry-data.js`**, which is the site's own data file. | ## The pages | Page | What it is | Live | |---|---|---| | `celia-template.html` | her home, exactly | /space-registry/celia-template.html | | `celia-template-the-globe.html` | her `/the-globe/`, exactly | /space-registry/celia-template-the-globe.html | | `celia-template-v2.html` | her `/v2/`, the violet variant, exactly | /space-registry/celia-template-v2.html | | `celia-template-halfglobe.html` | her home, hero variation: the Earth's night limb instead of the dotted globe | /space-registry/celia-template-halfglobe.html | | `celia-template-orbit.html` | her home, hero variation: the seven types circle the globe as icons | /space-registry/celia-template-orbit.html | | `celia-template-orbit-images.html` | the same ring, as cut-out photographs | /space-registry/celia-template-orbit-images.html | | `celia-template-orbit-images-small.html` | the same ring, smaller photographs | /space-registry/celia-template-orbit-images-small.html | | `unoosa1/index.html` | our own page in her look: the three cards for UNOOSA | /space-registry/unoosa1/ | | `unoosa1/note/index.html` | the concept note "How the data reaches UNOOSA" in her look | /space-registry/unoosa1/note/ | Her export also holds `/home2/`, a banking-template scaffold mixed into the same build. It is not part of her registry design; skip it. ## How exact the copies are Mean absolute difference per channel, full page, marquee band excluded, measured against the live pages on 03-09-2026: | Page | 1440x900 | 375x812 | |---|---|---| | home | 1.05 (morning 03-09) → 1.11 (afternoon 03-09, re-matched) | 0.83 → 1.02 | | /v2/ | 0.98 → 1.01 | 0.83 → 0.96 | | /the-globe/ (both frozen) | 0.81 | 1.15 | Band by band on the home page at 1440: stats 0.00, features 0.12, latest 0.37, closing call 1.47, footer 0.01. What is left is the hero, where her canvas animates and ours stands still, and the entry thumbnails, where her PNGs and our JPEGs of the same photographs differ by a few compression pixels. ## She keeps changing the page under us: re-measure, don't assume Frank, 03-09-2026 afternoon: her live page moved again the same day it was first matched, mid-session, while unrelated dark-mode work was running. Caught only because that work re-dumped her page in light mode too and the numbers had drifted from the morning dump. **A copy proven exact this morning is not proven exact this afternoon: she edits the live site same day, more than once.** Re-run the loop before trusting any number more than a few hours old. What moved, this pass (morning value → afternoon value, both read live, not guessed): | Element | Morning (03-09, AM) | Afternoon (03-09, PM) | |---|---|---| | h1 text | "Registry of outer space objects" | "Registry of space objects" (her `` still says "outer": not changed) | | h1 width at 1440 | 757.8px | 618.2px | | Footer frame (`footer > div:nth-of-type(2) > div`) | `max-width: 1080px` (max-w-270) | `max-width: 1152px` (max-w-6xl) | | Nav | 4 items: The register · The globe · How to register · Specification | 6 items: + **Acts to the UN** (`/acts-to-un`) and **The process** (`/the-process`), inserted between "How to register" and "Specification": same two links also added to the footer's Guidance column | | Dek's closing phrase | plain text, "...Outer Space Affairs." | a link to `https://www.unoosa.org/` (`target="_blank" rel="noreferrer"`), text now "...Outer Space Affairs (UNOOSA)." | | `.hero-globe` image opacity | 0.70 | 0.75 | | `.fcard-globe img` opacity (the globe feature-card thumbnail) | 0.72 | 0.90 | | Search row wrapper tag (cosmetic, no pixel effect) | `<div>` | `<form>`: not mirrored; changing our div to a form with no matching submit-prevention risks breaking the working search JS for zero visual gain | The repair: `celia-ui.css` for the shared kit values (`.foot-col` max-width now `var(--w-6xl)` instead of `var(--w-hero)`; `.hero-globe` and `.fcard-globe img` opacity; a new `.hero .dek a` link style, `--white-85` added to `tokens-celia.css` next to its siblings, `--white-35` reused for the hover underline) so every page follows at once; the h1 text, the dek's link, and the two nav/footer entries are per-page (each page carries its own copy of the header, dek and footer markup). Landed on all seven pages that share this header/footer/dek: `celia-template.html`, `celia-template-v2.html`, `celia-template-halfglobe.html`, `celia-template-orbit.html`, `celia-template-orbit-images.html`, `celia-template-orbit-images-small.html` (all six also got the h1/dek text), and `celia-template-the-globe.html` (nav only: its own h1 "The globe" and dek are hers, unchanged, and it carries no footer, on her page either). The two new nav/footer links point at this project's own `acts.html` and `process.html`, the same pattern the original four links already used (pointing at our own pages, not hers) , matching page titles already existed for both ("Acts furnished to the United Nations", "From the operator to the United Nations"). ## The one thing deliberately not copied - **The animated canvases stand still.** Her hero and her closing call are painted by three.js: slow blooms of colour moving over near-black, and a faint wash over the paper. A shader that cannot be read cannot be replayed, so ours are CSS gradients fitted to hers (below). A still frame matches; the motion does not exist. ## Dark mode Frank, 03-09-2026, after the exact copy: her pages carry one control ours lacked, the small circle bottom-right. "Add it, exactly as hers, to all nine pages." It is now on all nine. **What her switch does**, read live and by clicking it: a button, `w-10 h-10 rounded-full bg-muted text-foreground opacity-30 hover:opacity-100 shadow-lg hover:shadow-xl`, fixed `bottom-6 right-6 z-50`, a lucide moon icon that becomes a lucide sun. Click adds a class named `dark` to `<html>` - nothing else, no data attribute - and writes `localStorage["theme"]` as `"dark"` or `"light"`. Her compiled CSS then repaints six custom properties on `.dark`: `--background #070712`, `--foreground #fafafa`, `--muted #0f0f18`, `--muted-foreground #a3a3a3`, `--border #262626`; `--accent` and `--ring` stay `#d66a2a` in both modes on the default route. `/v2/` alone carries `.dark .site-v2{--ring:#a78bfa;--accent:#a78bfa}` - her purple lightens for the dark background, the orange route does not change at all. One exception found by diffing the rendered markup for every `dark:` class on the page (there is exactly one): "Latest entries" carries `bg-muted dark:bg-background`, so that section alone drops its alternating fill once the switch is on. **Why ours is class-based, not `prefers-color-scheme`.** Until 14-09-2026 the house rule was light theme always, and `check-tokens.py` refused a `prefers-color-scheme` block. Since then a dark page is allowed when it also offers a light mode, so both routes pass; ours stays as built because her switch already IS class-based (next-themes with `attribute="class"`); the one thing not copied from her behaviour is that ours never reads the OS preference on first load - a fresh visit is always light, exactly the deliberate half of her behaviour, never the automatic half. A returning visitor's choice still persists, because `celia-ui.js` reads `localStorage["theme"]` and applies the class before `start()` runs, same key, same values, hers. **How ours is built**, so a correction lands once. `tokens-celia.css` adds six dark-value tokens next to their light twins (`--bg-dark`, `--fg-dark`, `--surface-dark`, `--on-surface-dark`, `--muted-dark`, `--border-dark`, plus `--accent-purple-dark` for `/v2/`) and one rule, `var()` only: `html.dark{--bg:var(--bg-dark); --fg:var(--fg-dark); --surface:var(--surface-dark); --on-surface:var(--on-surface-dark); --muted:var(--muted-dark); --border:var(--border-dark);}`. Every other colour in `celia-ui.css` is already written as `var(--bg)`, `var(--fg)` and so on, so this one rule repaints the whole kit. The one place that needed a second look: seven tokens (`--fg-90` down to `--fg-10`) used to be flat `rgba(10,10,10,N)`, correct for light but frozen wrong for dark. Her compiled CSS never writes these flat either - a Tailwind alpha modifier like `text-foreground/70` compiles to `color-mix(in oklab,var(--foreground) 70%,transparent)`, so the alpha step re-reads the live custom property. Rewriting the seven the same way means `html.dark` reassigning `--fg` is the only place any of them are set, and the computed colour on every stat label, footer link and CTA button now matches hers character for character, oklab notation included (verified with `getComputedStyle`, not eyeballed). The one section-level exception above is its own rule, `html.dark .section.on-surface{background:var(--bg)}`, next to `.section.on-bg`/`.section.on-surface` in `celia-ui.css`. The button markup lives in `celia-ui.js` (`themeToggle()`, called once from `start()`, builds the `.theme-toggle-wrap`/`.theme-toggle` pair, swaps the inline SVG between the moon and sun paths, sets `aria-label`/`aria-pressed`); its rest-state styling is in `celia-ui.css` next to "her reveal". A page that does not already load `celia-ui.js` needs one script tag added - true of `unoosa1/index.html` and `unoosa1/note/index.html`, which carried none before this pass. **The one field left an approximation.** The closing-call canvas repaints for dark too, and not by a plain palette swap - sampled the same way as the light fields (eight frames, dark switch on, globe/words hidden, averaged), its blooms sit measurably dimmer than the light set recoloured. Refit by eye against that average (`--cta-field-dark` and its three blooms in `tokens-celia.css`), landing near 6/channel off hers in the closing-call band - worse than every other band (stats 0.00, features/latest under 0.5) but the same kind of shader approximation the light fields already are, not a new gap. `/v2/`'s own closing call reuses its light gradient in dark mode unmodified, the same category of approximation, not separately refit. **Measuring it**: `probe.py measure <hers|ours> <url> <w> <h> <out.json> dark` and `probe.py shot <url> <w> <h> <out.png> full dark` click the real switch (`.fixed.bottom-6.right-6 button` on her page, `.theme-toggle` on ours) before the usual settle-and-capture, so the numbers prove the control works, not just the CSS under it. ## The fields (the hero and the closing call) Her canvases were sampled with the globe and the words hidden, eight frames 1.3 s apart, averaged, and a base gradient plus four blooms fitted to that average by coordinate descent. The fits and the errors: | Field | Fit error, per channel | |---|---| | home hero, 1440 | 1.34 | | home hero, 375 | 1.06 | | home closing call | 1.39 | | /v2/ hero, 1440 | 1.27 | | /v2/ hero, 375 | 1.07 | | /v2/ closing call | 0.94 | They live as whole-value tokens, `--hero-field`, `--hero-field-sm`, `--cta-field`, `--hero-field-v2`, `--hero-field-v2-sm`, `--cta-field-v2`, each built from named colour tokens, so a page paints its field with one `var()` and every colour still traces to `:root`. Her canvas is a different shape below lg, which is why there are two fits: the desktop set on a phone is 5.44 per channel off, the phone set 1.06. ## The globe page Her globe is **cobe**, bundled by webpack into a React chunk that also carries Next's runtime, so the compiled file cannot run on its own. Everything cobe needs is readable in that chunk, so `celia-globe.js` **redraws it in plain JavaScript** rather than reusing the chunk. What was copied verbatim: - the settings: phi starts at -1.84 and gains 0.0022 a frame, theta 0.16, dark 1, diffuse 1.4, mapSamples 24000, mapBrightness 6, mapBaseBrightness 0.05, baseColor `[.16,.075,.02]`, markerColor `[.8,.72,.38]`, glowColor `[.3,.13,.032]`, opacity 0.98 - the drag: longitude gains dx/220, latitude dy/220 clamped to ±1.25, both eased at 0.09 a frame; the reset button puts phi back to -1.84 - the clip: `circle(40.2% at 50% 50%)`, which cuts cobe's outer halo away, so the halo never needs drawing - the shading, line for line out of the fragment shader: `q = p * mapBrightness * z^diffuse`, colour `= base * (q + 0.1) + (1 - z)^4 * glow`, all of it times `(1 + opacity) / 2`, with `p = 1` on land and `0.05` at sea - the land map: cobe's own 256x128 one-bit image, `cobe-land-map.js` - the four orbits and each entry's orbit and phase, in `celia-data.js` cobe draws the dots backwards, one screen pixel at a time asking which lattice point is nearest. We draw them forwards. **The trap there:** a plain 2.39996-radian Fibonacci spiral is the same family turning the OTHER WAY, and the dots then land between hers. cobe's lattice is `z = 1 - 2j/k` with longitude `2*pi*frac(j*0.618034)`, read off the shader's bit-by-bit golden expansion. Getting that right dropped the globe's pixel error from 3.7 to 2.5; the dot size and softness were then swept against her frozen frame and settled at 2.2 css px on a 780px stage with a 1.5x soft sprite, 2.26 per channel off with the same amount of ink (10.97 against her 11.41). **Two builds live in the export, and the live page runs the newer one.** `524487feeb4cfec2.js` gives Aphrodite Probe phase 2.45, baseColor `.18`, glowColor `.36` and draws four faint orbit rings in svg. `7e5a65229a3f22e0.js`, which the live page loads, gives phase 2.2, `.16`, `.3`, and no rings at all (checked in the browser: the page holds no svg). The live page is the truth; ours has no rings. ## The rules of the style, what makes it hers - **An off-white paper, never pure white.** `--bg` is `#fafaf8`; `--surface` (`#f5f3ee`) is one step further, used for card fills at 50% and for alternating bands. Two sections never sit on the same fill twice running. - **Restrained radius.** Cards and thumbnails round at `.25rem`/`.375rem`. Only controls (search, buttons, nav pill, badges, dots) go fully round. - **The dotted globe is the signature**, always in the accent, never grey. - **Two accents, same everything else.** Orange (default) and violet (`/v2/`) never mix on one page and change no other token. - **Wide, quiet spacing.** Sections run `py-24`/`py-32`; content sits in `max-w-6xl` (1152px) or the wider hero measure `max-w-270` (1080px). - **Type-first cards.** A bordered rectangle, a visual on top, text below. No gradient, no shadow at rest. The globe's chip is the one exception, because it floats over the Earth. - **Search is a pill**, and the one control that appears on the dark hero. - **The header blends.** `mix-blend-mode: exclusion` is hers, and it is why the same header reads white over the dark hero and dark over the paper below. Copy it; do not swap it for two colour states. - **She reveals on scroll.** Cards, entries and the closing call arrive from 30px below as they come into view. `celia-ui.js` does it with an IntersectionObserver, and the hiding is switched on by script so a page with no script still shows everything. ## Building a new page in her look 1. Copy `celia-template.html` and cut what the page does not need, or, for a page that is read rather than browsed, copy `unoosa1/note/index.html`, which uses the `.strip` / `.page-head` / `.doc` block from `celia-ui.css`. 2. Link `tokens-celia.css` and `celia-ui.css`. Add nothing to the page's own `<style>` but what is unique to it. 3. Never write a colour outside `:root`. Never write a size that is not a `var()` from the scale. 4. Run the three checks at zero. 5. If the page copies one of hers, run the measuring loop and put the numbers in the report. ## The checks ``` python3 "9 - System/ui-ux/check-tokens.py" <page.html> python3 "9 - System/ui-ux/token-audit.py" <page.html> python3 ~/.claude/hooks/slop-check.py <page.html> ``` **Link `tokens-celia.css`, never paste its `:root` inline** when the page sits in a project that already has its own `tokens.css` for a different look (as `3 - Projects/space-object-registry/` does, Frank's earlier "Meridia" palette). `check-tokens.py` compares an inline `:root` against whatever `tokens.css` it finds walking up from the page; a linked stylesheet is read for its slots but not diffed against a same-named sibling. `token-audit.py` reads only the page's own `<style>` block, so every `font-size`, `margin`, `padding`, `gap`, `line-height`, `letter-spacing` and `max-width` written there must be a `var()`. Reach for the spacing ladder (`--sp-1` … `--sp-32`) and the type scale (`--fs-xs` … `--fs-6xl`) before adding a token. A trap when writing citation comments inside `tokens-celia.css`: a comment that reads dash-dash-name immediately followed by a colon is misread by `check-tokens.py`'s parser as a real declaration and can swallow the next real one. Cite her variable names in prose, never with the colon straight after. ## Recipe: the orbit ring (our own variation) Not hers. The shape is the studio shelf's **Orbit carousel** (`9 - System/ui-ux/inspiration/presenting-images.html`). `celia-orbit.js` places each type on an ellipse (rx = min(44vw, 560px), ry = 120px, 90px under 640px) at angle = time / 48s + i / n. Depth = sin(angle): scale 0.7 to 1, opacity 0.3 to 1, class `back` (z-index 0, behind the globe) or `front` (z-index 2, above the text). It pauses on hover and under reduced motion. The page says what rides the ring with `data-orbit="icons"` or `data-orbit="pictures"` on the `.orbit` element, and sets the picture size in its own style block. In the hero, the marquee band is replaced by an `.orbit-space` spacer so the rhythm below it is unchanged. ## Her components: measured from her live pages, never from the generic design-system folder `9 - System/ui-ux/designstudio/` is UNCTAD's generic Digital Government Design System; nothing in it names Celia or the registry, and her compiled CSS carries none of its tokens (checked 04-09-2026). Her look is her live site. A screen her site does not have (a form, a desk, an agent's card, a door) reuses every element her site has, measured there (the search input on the home, the buttons, the status and type labels on /registry/ and on an entry page, the list rows, the cards, the headings, the footer), and derives the rest from her nearest measured element: same radius, border, heights, type scale, spacing rhythm, one accent. Write down, per screen, "hers, measured from <page>" or "derived from <her element>". `tokens-celia.css` grows only with measured values, one citation each. Frank, 04-09-2026, on the twin's first screens: "we have a skill with Celia's UI, apply properly."