Files
zooqueen 1e87b5b98d the names this document owns lose the prefix
Fifty-four names carried the brand and said nothing a reader needed: action,
tap, pill, title, more, lede, display, prose, run, flag, rise, leaf, field,
dock, rail, mode, tab, dot, ask, caret, claim, eyebrow, spec, marquee, scatter,
pulse, blink, the twelve of the help panel, and five custom properties — plus
`hzRise` and `hzRun`, which wore it in camelCase. They are this site's, so they
lose it. The gate that counts them and its ledger lose it too.

The rule that decides which go is @hanzo/ui's own, read the other way. Its
motion.css says why every class in it is prefixed: the sheet used to claim
`.row`, `.mono`, `.tnum` and three more ordinary words at the document level, in
a package an app imports at its root, and an app with its own `.row` got no
warning — it got whichever rule the cascade preferred, in a stylesheet it never
opened. A library's classes are its namespace, so they carry its name. This site
is the document. It imports the libraries and nothing imports it, so a plain
word here can only meet sheets it can read, and it can check them.

SEVEN KEEP THE PREFIX because they are not ours to rename. `hz-composer`,
`--hz-composer-rest` and `--hz-composer-lift` are @hanzo/composer's published
surface — `composer.css` paints the ring at `.hz-composer::before` and the halo
at `::after`. `hz-round` is sized by that same sheet with
`html:root .hz-composer :is(button,a).hz-round{width:…!important}`. `hz-mono`,
`hz-tnum` and `hz-row` are stamped onto elements by @hanzo/ui's own DataTable
and Metric, and this file deleted its copies of those rules when 8.2.0 started
shipping them. Rename any of the seven and the selector in a sheet this repo
cannot edit stops matching: the layout holds, the styling goes, nothing reports
it. A comment at each site says so.

THREE NAMES WERE ALREADY SPENT, and only one of the three was visible in the
built stylesheet before it was taken:

  `glass` is @hanzo/ui's frosted material, live in this bundle inside
    `@supports (backdrop-filter:blur(8px))`. The chrome fill is `chrome`, which
    is also what its two custom properties are called, so the material and its
    knobs are one word instead of two.
  `grid` is Tailwind's own `display:grid`. The ruled backdrop is `lattice`.
  `scrollbar-none` is Tailwind's too — v4 ships `scrollbar-none`/`-thin`/`-auto`
    — and a utility is only emitted once something wears it, so the name looked
    free right up until it was taken. It put `scrollbar-width:none` in the
    bundle TWICE, once in the utility layer and once ours, which is harmless
    only while the two agree. That row is `bare`.

TWO GATES READ THE PREFIX AND BOTH WOULD HAVE STOPPED WORKING. They asked the
same question — which names are this app's — and both answered it with four
characters. The fact behind the proxy is that our stylesheets DECLARE the name,
so scripts/names.mjs reads it from them and both gates read it from there.

  tailwind-floor.mjs matched `OURS = /^(hz-|is_|t_|_|font_)/`. Every role class
    would have stopped looking like ours and started counting as Tailwind: the
    floor rises by hundreds with no tailwind added, and `--check` fails on a
    rename. It still recognises a PACKAGE by its prefix, which is what keeps
    `hz-mono` and `hz-tnum` — worn here, declared in @hanzo/ui — off the
    migration's bill. Measured: 32,460 across 278 files before and after, the
    same number to the unit. Its own className reader is deliberately left
    alone; it miscounts ternaries, and that is a real defect but not this
    commit's, because a floor is only comparable to the one recorded under it.
  consumers.mjs (was hz-consumers.mjs) found a wearer by grepping the raw text
    for `hz-`. Against `run`, `more`, `title` and `action` that matches prose,
    imports and identifiers, so it would have called every rule worn forever and
    passed because it COULD NOT FAIL. It reads class position now, and that cost
    a real reader rather than a tighter regex: `rate-wide` is applied as
    `className={c.wide ? 'rate-wide' : …}`, and the first version called a live
    rule an orphan — the one mistake it must never make, since the line it
    prints says to delete it. 53 rules declared, 53 worn, none orphaned.

Not one pixel moves. Three of the four compiled sheets are a byte-for-byte pure
rename; the fourth differs in four rules because shorter selectors change the
arithmetic on whether factoring a shared declaration saves bytes, and expanding
every grouped selector shows all 1,637 receiving identical declarations. Across
the whole export, 111,978 class attributes on 956 pages are identical after the
map is applied. Thirteen pages render byte-identical at 1280x900; the three that
do not are `/analytics`, `/enso` and `/overview`, which do not match THEMSELVES
across two renders — measured first, not assumed.

Three rules ship that nothing in the export wears: `.run`, `.marquee` and
`.spec`. They are left where they are and reported, not deleted — the components
that wear them exist, they are simply not on a built page, and that is a
decision rather than a defect.
2026-08-27 17:29:15 -07:00

109 lines
5.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Every rule this site's stylesheets declare has a consumer, as a number that
* may only go down.
*
* node scripts/consumers.mjs # print the orphans
* node scripts/consumers.mjs --write # record them in orphans.json
* node scripts/consumers.mjs --check # fail on any new one
*
* WHY THIS EXISTS. Plain CSS is where styling lands when it is neither a gui prop
* nor a token, and that is the right place for it — a utility vocabulary of our
* own would just be Tailwind wearing our name. What a global stylesheet cannot do
* is notice when a rule's LAST consumer leaves: it has no importer, so a class
* outlives the component that wore it and nothing errors, nothing warns, and the
* bytes ship forever.
*
* WHAT CHANGED WITH THE PREFIX. This gate used to find its subject by grepping
* for `hz-`, and it found a wearer the same way — the string anywhere in a file.
* Both halves moved to scripts/names.mjs, which reads the sheets for what they
* DECLARE and reads a file for what it WEARS in class position. The second half
* is not a tidy-up but the condition of the first: `run`, `more`, `title` and
* `action` are ordinary words, and a raw grep for them matches prose, imports and
* identifiers, so a gate that kept the old scan against the new names would have
* reported every rule worn forever and passed because it COULD NOT FAIL.
*
* Reading class position instead costs a real reader, not a tighter regex, and
* `rate-wide` is why. It is applied as `className={c.wide ? 'rate-wide' : …}` in
* components/pricing/kit.tsx, and a reader that wants a quote right after the `=`
* finds none — so the first version of this gate called a live rule an orphan,
* which is the one mistake it must never make: the instruction it prints is to
* delete the rule. names.mjs reads through the braces for that reason.
*
* Measured after the rename: 53 rules declared, 53 worn, none orphaned.
*
* NODE_MODULES IS A CONSUMER, and forgetting that is how this gate would be wrong.
* `hz-mono`, `hz-tnum` and `hz-row` are @hanzo/ui's interface to its HOST: the
* package writes the class and expects our sheet to carry the rule. They keep the
* prefix precisely because they are the package's names, not ours. Scanning only
* our own source reports them dead and invites a delete that breaks the component
* library.
*
* A ratchet, not a deadline, for the same reason tailwind-floor.mjs is one: the
* orphans that exist are a decision each, and a gate that demands they all go
* today gets turned off. This one only refuses NEW ones.
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, extname } from 'node:path'
import { names, classes, SHEETS } from './names.mjs'
const LEDGER = 'orphans.json'
// Where a class can be WORN. node_modules is deliberate — see the header.
const CONSUMERS = ['app', 'components', 'lib', 'content', 'e2e', 'node_modules/@hanzo', 'node_modules/@hanzogui']
const WEARABLE = new Set(['.tsx', '.ts', '.jsx', '.js', '.mjs', '.md', '.mdx', '.html', '.css'])
const walk = (dir, out = []) => {
let entries
try { entries = readdirSync(dir) } catch { return out }
for (const e of entries) {
if (e === 'node_modules' && !dir.includes('node_modules')) continue
const p = join(dir, e)
let st
try { st = statSync(p) } catch { continue }
if (st.isDirectory()) walk(p, out)
else if (WEARABLE.has(extname(p))) out.push(p)
}
return out
}
const defined = names()
const worn = new Set()
for (const root of CONSUMERS) {
if (!existsSync(root)) continue
for (const file of walk(root)) {
if (SHEETS.some(s => file.endsWith(s))) continue
let text
try { text = readFileSync(file, 'utf8') } catch { continue }
for (const c of classes(text)) worn.add(c)
}
}
const orphans = [...defined].filter(c => !worn.has(c)).sort()
const write = process.argv.includes('--write')
const check = process.argv.includes('--check')
const prior = existsSync(LEDGER) ? JSON.parse(readFileSync(LEDGER, 'utf8')).orphans ?? [] : []
console.log(`rules: ${defined.size} defined, ${defined.size - orphans.length} worn, ${orphans.length} orphaned`)
for (const c of orphans) console.log(` .${c}`)
if (write) {
writeFileSync(LEDGER, JSON.stringify({ orphans, note: 'may only shrink — see scripts/consumers.mjs' }, null, 2) + '\n')
console.log(`\n>> recorded ${orphans.length} in ${LEDGER}`)
} else if (check) {
const fresh = orphans.filter(c => !prior.includes(c))
const fixed = prior.filter(c => !orphans.includes(c))
if (fresh.length) {
console.error(`\n${fresh.length} rule(s) lost their last consumer:`)
for (const c of fresh) console.error(` .${c} — delete the rule, or give it a caller`)
process.exit(1)
}
if (fixed.length) {
console.error(`\n${fixed.length} orphan(s) resolved — run --write so the ratchet holds the gain:`)
for (const c of fixed) console.error(` .${c}`)
process.exit(1)
}
console.log('\n>> every rule has a consumer, or is a recorded orphan')
}