Files
zooqueen 5db45f3468 a preset names a weight, and the docs name this family
The presets carried what they were fitted against — a licensed text face, a
licensed display one, and the residuals of each search. That is our own working
metadata, not the licence, and it is not what a preset IS: book is the reading
weight, medium the step up before display, wide is widened and weighted to hold
the widening. Each now says that and nothing else, and the numbers a search
produced stay out of the shipped data, the stylesheet comments, the types and
the crate.

A preset also stopped naming another brand's surface. One typeface serves
several brands, and a page for one of them should read as though the others do
not exist — so the five are described by what the setting does, and the page
says which of them that brand uses.

The prose was still the upstream project's: it credited another company's design
team, pointed downloads at their releases, told you to `npm install zen`, and
listed seven other foundries' typefaces under Inspiration. Every import path in
font.d.ts declared module "zen/font", which is not this package's name, so the
types matched nothing a consumer could write.

LICENSE.txt is untouched. The copyright notice travels with each copy, and that
is where a licence belongs.
2026-08-24 14:36:55 -07:00

138 lines
5.5 KiB
JavaScript

/* The Zen presets, defined once and emitted four ways.
*
* Each is a point in Zen's own parameter space that a surface has settled on, so
* it names the voice ("wide") rather than restating three numbers that then drift
* between the stylesheet and the component. The same five reach CSS, JS, the
* TypeScript types and the Rust crate from this file.
*
* Run: node scripts/presets.mjs (writes dist/presets.{css,js,d.ts} and crates/zen/src/presets.rs)
*/
import { writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist')
export const PRESETS = {
air: {
wght: 220, scaleX: 1, track: -0.03,
note: 'Thin display. Set large, tracked in.',
},
book: {
wght: 497, scaleX: 1, track: 0,
note: 'Text. The reading weight.',
},
medium: {
wght: 606, scaleX: 1, track: 0,
note: 'UI emphasis. A step up from book without becoming display.',
},
wide: {
// Widening is horizontal, so it reaches the uprights and leaves the
// horizontals where they were — which is why this carries a weight as well
// as a scale. Weight alone cannot widen; scale alone comes out light.
wght: 650, scaleX: 1.4861, track: -0.095,
note: 'Monumental caps. Widened, and weighted to hold the widening.',
},
round: {
wght: 900, scaleX: 1, track: -0.018,
note: 'Heavy and friendly at large sizes.',
},
}
/* A surface MIGRATING off a previous text face multiplies its font-size by this;
a surface simply using Zen does not. It is deliberately not baked into
`book`/`medium` — the preset is a voice, this is a migration correction, and
braiding them would apply it to type that was always Zen.
DIVIDE BY THE EM, NOT THE CAP, and that is the whole subtlety. `font-size` and
`size-adjust` both scale the em, so matching x-heights means matching
x-per-em, and a ratio of cap heights answers a question no CSS property asks. */
export const XHEIGHT_FACTOR = 0.9321
const decl = (p) => {
const out = [`font-variation-settings:'wght' ${p.wght}`]
if (p.track) out.push(`letter-spacing:${p.track}em`)
if (p.scaleX !== 1) out.push(
`transform:scaleX(${p.scaleX})`, 'transform-origin:left center', 'display:inline-block')
return out.join(';')
}
const css = `/* Zen presets — generated by scripts/presets.mjs. Do not edit.
*
* import '@hanzo/font/css' the faces
* import '@hanzo/font/presets.css' these
*
* A preset sets weight, and where the voice needs it, tracking and a horizontal
* scale. Anything that scales is inline-block with a left origin, and its LAYOUT
* box stays the untransformed width — a transform does not reflow, so give
* .zen-wide room or clip its container.
*/
${Object.entries(PRESETS).map(([k, p]) =>
`\n/* ${p.note} */\n.zen-${k}{${decl(p)}}`).join('\n')}
:root{
${Object.entries(PRESETS).map(([k, p]) =>
` --zen-${k}-wght:${p.wght};` +
(p.track ? `--zen-${k}-track:${p.track}em;` : '') +
(p.scaleX !== 1 ? `--zen-${k}-scale-x:${p.scaleX};` : '')).join('\n')}
--zen-xheight-factor:${XHEIGHT_FACTOR};
}
`
const js = `// Generated by scripts/presets.mjs. Do not edit.
export const PRESETS = ${JSON.stringify(PRESETS, null, 2)}
export const XHEIGHT_FACTOR = ${XHEIGHT_FACTOR}
/** Style object for a preset — for a surface that styles in code, not CSS. */
export function preset(name) {
const p = PRESETS[name]
if (!p) throw new Error(\`no Zen preset "\${name}" — have \${Object.keys(PRESETS).join(', ')}\`)
return {
fontFamily: 'Zen',
fontVariationSettings: \`'wght' \${p.wght}\`,
...(p.track ? { letterSpacing: \`\${p.track}em\` } : {}),
...(p.scaleX !== 1
? { transform: \`scaleX(\${p.scaleX})\`, transformOrigin: 'left center', display: 'inline-block' }
: {}),
}
}
`
const dts = `export interface ZenPreset {
wght: number
scaleX: number
track: number
note: string
}
export declare const PRESETS: Record<'air' | 'book' | 'medium' | 'wide' | 'round', ZenPreset>
export declare const XHEIGHT_FACTOR: number
export declare function preset(name: keyof typeof PRESETS): Record<string, string | number>
`
/* The Rust crate needs these numbers too — the CLI prints them and the wasm
build serves them — and a second hand-maintained copy is a second thing to
forget. It is generated here rather than read at run time because the crate
compiles to wasm with no filesystem. */
/* A Rust float literal: 1 is not f32 there, 1.0 is. */
const f = (n) => (Number.isInteger(n) ? `${n}.0` : String(n))
const rs = `// Generated by packages/zen/scripts/presets.mjs. Do not edit.
use crate::Preset;
pub const PRESETS: [Preset; ${Object.keys(PRESETS).length}] = [
${Object.entries(PRESETS).map(([k, p]) =>
// Emit the number, not a rounding of it. `toFixed(2)` shipped the crate
// scale_x 1.49 while the CSS got 1.4861 — the same fact at two precisions,
// which is the whole failure this file exists to prevent. `f` keeps a whole
// number a float, since Rust will not read `1` as f32.
` Preset { name: ${JSON.stringify(k)}, wght: ${f(p.wght)}, ` +
`scale_x: ${f(p.scaleX)}, track: ${f(p.track)} },`).join('\n')}
];
`
writeFileSync(join(DIST, '..', '..', '..', 'crates', 'zen', 'src', 'presets.rs'), rs)
writeFileSync(join(DIST, 'presets.css'), css)
writeFileSync(join(DIST, 'presets.js'), js)
writeFileSync(join(DIST, 'presets.d.ts'), dts)
console.log('wrote dist/presets.{css,js,d.ts} and crates/zen/src/presets.rs —', Object.keys(PRESETS).join(' '))