Files
buzz/desktop-next/scripts/check-type.mjs
morgmart 30847f42ff feat(desktop-next): add the type system, fix unreachable border roles
The design system site rendered black borders and dividers while the tokens
said `#d4d4d4`. The border roles were declared in layer 2 and never registered
with Tailwind, so `border-primary` silently resolved to `--color-primary` — the
*text* colour — and every hairline drew at text contrast. Tailwind's `--color-*`
is a single namespace and text/borders share the emphasis names while holding
different values, so borders now register as `--border-color-*`.

Add typography as the second foundation, same three layers as colour: a nine-step
size ramp plus leading, tracking, and weight ramps in layer 1; ten public roles
in layer 2, each carrying its whole setting via Tailwind v4's
`--text-<name>--<property>` convention, so `text-body` alone produces correctly
set text and two supposedly identical labels cannot drift apart. Inter Variable
and JetBrains Mono, both already shipped in every current Buzz client. Every
size derives from one virtual rem, so keyboard zoom and the font-size preference
work by construction.

Soften the documentation surface to match where the product is going: regions
separated by soft fills rather than outlines, hairlines only for genuine
boundaries and never above `border-secondary`, no page-wide gradient behind
reading columns, and no all-caps labels anywhere.

The `/design/typography` page renders from the registry, so it cannot drift from
the system. Add `scripts/check-type.mjs` enforcing the four rules a token cannot
express — no arbitrary sizes (px or rem), no `uppercase`, no hand-applied
tracking, no size role paired with a weight or leading utility.

Also collapse the nav to a wrapped row below `lg`; the fixed 256px column
overflowed the viewport under 420px.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-03 12:50:39 -07:00

126 lines
4.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Type-system guard.
*
* Three rules from DESIGN.md § Type, enforced rather than trusted. Each one has
* already cost the existing client real work:
*
* 1. No arbitrary text sizes — `text-[15px]`, `text-[0.9rem]`, `font-size:`.
* Fixed px froze against keyboard zoom and shipped a message-timeline
* regression; arbitrary rem re-fragments the scale we just consolidated.
* 2. No `uppercase` / `tracking-*` utilities. All-caps labels are less legible
* and read as enterprise chrome, and tracking is corrected per ramp step.
* 3. No size role paired with a weight or leading utility. A role carries its
* whole setting; pairing one with `font-medium` is how two supposedly
* identical labels drift apart.
*
* Run: pnpm check:type
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const SRC = new URL("../src", import.meta.url).pathname;
/** Size roles a component may use. Kept in sync with typography.css. */
const SIZE_ROLES = [
"display",
"title",
"heading",
"subheading",
"body-lg",
"body",
"label",
"caption",
"meta",
"code",
];
const RULES = [
{
id: "arbitrary-text-size",
// text-[...] with any unit, plus raw CSS font-size declarations.
pattern: /\btext-\[[^\]]*(?:px|rem|em|pt|%)[^\]]*\]|font-size\s*:/g,
message:
"arbitrary text size — use a named role (text-body, text-label, …). px freezes against zoom; arbitrary rem re-fragments the scale.",
},
{
id: "uppercase",
// Only inside a className/class string — otherwise prose describing the
// rule trips it, and a guard that cries wolf gets ignored.
pattern: /class(?:Name)?=(?:"|'|`)[^"'`]*\buppercase\b/g,
message:
"all-caps text — DESIGN.md § Type forbids it. A quiet label uses text-meta on text-tertiary instead.",
},
{
id: "manual-tracking",
pattern: /\btracking-(?:tighter|tight|normal|wide|wider|widest|\[)/g,
message:
"hand-applied tracking — the ramp already corrects tracking per step.",
},
{
id: "role-plus-weight",
// A size role sharing a *className string* with a weight or leading
// utility. Anchoring to the attribute keeps the ramp's own
// `--text-body--line-height` declarations out of it — those are the
// definitions, not a component overriding one.
pattern: new RegExp(
`class(?:Name)?=(?:"|'|\`)[^"'\`]*\\btext-(?:${SIZE_ROLES.join("|")})\\b[^"'\`]*\\b(?:font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)|leading-)`,
"g",
),
message:
"size role paired with a weight/leading utility — a role already carries its whole setting.",
},
];
function walk(dir) {
return readdirSync(dir).flatMap((entry) => {
const full = join(dir, entry);
if (statSync(full).isDirectory()) return walk(full);
return /\.(tsx?|css)$/.test(full) ? [full] : [];
});
}
const failures = [];
for (const file of walk(SRC)) {
const source = readFileSync(file, "utf8");
// The typography ramp itself is the one place literals are legal — it is
// layer 1, where values live by design.
const isRamp = file.endsWith("typography.css");
const lines = source.split("\n");
lines.forEach((line, index) => {
// Skip comment-only lines: the docs quote the very patterns they forbid.
const trimmed = line.trim();
if (
trimmed.startsWith("*") ||
trimmed.startsWith("//") ||
trimmed.startsWith("/*")
) {
return;
}
for (const rule of RULES) {
if (isRamp && rule.id === "arbitrary-text-size") continue;
rule.pattern.lastIndex = 0;
const match = rule.pattern.exec(line);
if (match) {
failures.push(
`${relative(process.cwd(), file)}:${index + 1} ${rule.id}\n ${trimmed}\n → ${rule.message}`,
);
}
}
});
}
if (failures.length > 0) {
console.error(
`\n✗ Type system: ${failures.length} violation${failures.length === 1 ? "" : "s"}\n`,
);
console.error(`${failures.join("\n\n")}\n`);
process.exit(1);
}
console.log("✓ Type system: no violations");