Files
zeekay 18f2b5e5aa fix(lux): measure stroke width as the thinnest chord, and give the X its weight
Two prior measurements of the same thing were both wrong, and by a lot.

The horizontal chord across a diagonal is w/sin(theta): it put X at 0.43 and Z
at 0.56 against a 0.30 stem, so the alphabet's spread was mostly an artefact.
Combining the horizontal and vertical chords as 1/w^2 = 1/h^2 + 1/v^2 is exact
only when BOTH cut across the stroke — on an upright stem the vertical chord
runs ALONG it, so that formula was fed the stroke's length and returned 0.2841
for an L stem that measures 0.2977 flat at every height.

The thinnest chord through a point is the pen width, for a stem, a bar, a
diagonal or a curve, with nothing assumed about direction. 24 directions caps
the over-read at 0.2%. Validated against the L: 0.2971 measured, 0.2977 off the
outline.

That made the X readable for the first time. The drawn X is 4.8% thinner than
the drawn L; Zen's was 9% thinner at one weight, so the X was 4 points too
light. diag 790 puts it at +0.5%. Geometry re-solved, since the weight moves
the X's advance.

               drawn      cut
  L stem      0.2957   0.2929   -0.9%
  L bar       0.2643   0.2643    0.0%
  X           0.2814   0.2827   +0.5%     was -9.0%
  U stem      0.3043   0.2933   -3.6%

The U is the floor and it is the glyph, not a setting: its drawn stem/bar ratio
is 1.170 against Zen's 1.104, and its counter closes to 0.16 cap where Zen's
holds 0.30. Cutting the U at its own heavier weight fixes the stem and pushes
the bar out by as much — the worst reading does not move. Redrawing it would.
2026-08-23 23:55:53 -07:00

134 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Wordmarks, cut from Zen.
*
* Two of the three brands already set their wordmark in live type — Hanzo's is
* `<text font-family="Zen" font-weight="600">Hanzo</text>` beside the H mark, and
* Zoo's is two spans (800 / 300) beside the Venn. Lux's is the odd one: 633 bytes
* of drawn geometry, which is why LUX CREDIT set in Zen used to sit next to it
* rather than with it. Zen reaches it now — see `zen lux`, and the note below.
*
* This emits each wordmark AS Zen outlines, so a brand can adopt one file and have
* every lockup beside it match by construction instead of by eye. It writes real
* path data — not a `<text>` element — because a logo must render identically
* where the font is absent: an email client, a partner's deck, a favicon.
*
* The cutting is `crates/zen`, invoked as `zen mark`. It used to be two Python
* scripts, `_outline.py` for the plain cut and `_shape.py` for the shaped one —
* the same job twice, and only one of them could produce the Lux mark.
*
* THE U TUCKS UNDER THE X. The drawn mark overlaps those two letters — the X
* starts before the U ends — and no uniform tracking produces an overlap, so it
* takes a pair kern (`ux`). Tightening one pair shortens the run, so `scaleX`
* rises from 1.540 to 1.576 to hold the mark at the drawn width.
*
* THE MARK IS THICKENED; A LOCKUP IS NOT. `thicken` is exact on a horizontal bar
* and steps a terminal that is not flat, so LUX takes it cleanly — L, U and X
* terminate flat — while C, G, S and A notch. "LUX CREDIT" and its siblings are
* set at the same weight, width and tracking with `thicken: 0`; their bars run a
* little lighter than the mark's, which is the ordinary relationship between a
* logotype and the words beside it.
*
* Run: node sites/wordmark.mjs -> sites/dist/wordmark/*.svg
*/
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = join(HERE, '..')
const OUT = join(HERE, 'dist', 'wordmark')
const FONT = join(ROOT, 'packages/zen/dist/fonts/zen-sans/Zen-Variable.ttf')
const ZEN = join(ROOT, 'crates/zen/target/release/zen')
if (!existsSync(ZEN)) {
console.error(`no ${ZEN}\n cargo build --release --manifest-path crates/zen/Cargo.toml`)
process.exit(2)
}
/* Each entry is the setting the brand uses, so these are not new decisions.
*
* lux FITTED to the drawn mark, feature by feature, by `zen lux`. Every
* measurement lands within 3.2% — stems were 22% fat and bars 21-29%
* thin at the old 845/1.40. `thicken` is what made it reachable: the
* drawn LUX is nearly monoline (bar/stem 0.90 against Zen's 0.58) and
* no weight or width setting gets there.
* hanzo what brand/assets/logo/wordmark.svg already states
* zoo lowercase — z, o and o are all x-height and the two o are the same
* circle, so the mark is symmetrical by construction rather than by
* optical adjustment. Caps would need the Z kerned against two round
* forms; lowercase needs nothing.
*/
/* The LUX cut, solved against the drawing rather than chosen.
*
* The drawn mark is a 63x17 grid, and reading it off the artwork gives four
* targets no single control reaches: the L is 0-18 so its foot runs right up to
* the U at 18; the U's right stem ends at 38.728; the X's arm clears the U by
* 0.102 cap at the top; the whole thing is 63/17 wide.
*
* foot lengthens the L's arm, stem untouched -> L width
* lu closes what is left -> the foot touches
* ux the X's distance from the U -> the top gap
* scaleX the run's total width -> 3.7059
*
* Solved together, because each moves the others: `foot` pushes the U right,
* `ux` pushes the X right, and both change the width that `scaleX` sets.
*
* ux is POSITIVE. "The X tucks under the U" was read off the artwork, where the
* X's arms spring from the U's right stem — they are one fused outline, not two
* letters overlapping. A font X kerned to overlap crowds the U instead: the gap
* came out 0.020 against the drawing's 0.102, five times too tight.
*/
/* Stroke widths are the THINNEST CHORD through a point, measured the same way
* on the drawing and on the cut, which is what makes the two comparable:
*
* drawn cut
* L stem 0.2957 0.2929 -0.9%
* L bar 0.2643 0.2643 0.0%
* X 0.2814 0.2827 +0.5%
* U stem 0.3043 0.2933 -3.6%
*
* The X sets its own weight because the drawn X is 4.8% thinner than the drawn
* L, and Zen's is 9% thinner at one weight. diag 790 lands it at 0.5%.
*
* The U is the floor and it is a letterform, not a setting: the drawn U's
* stem/bar ratio is 1.170 against Zen's 1.104, and its counter closes to 0.16
* cap where Zen's holds 0.30. No weight reproduces a different ratio — giving
* the U its own heavier instance fixes the stem and pushes the bar out by as
* much, so the worst reading does not improve. Redrawing the glyph would.
*/
const LUX = {
wght: 650, scaleX: 1.4861, track: -0.095, flatten: 1, diag: 790,
thicken: 92, ux: 0.01949, foot: 32.59, lu: -0.00454,
}
const MARKS = [
{ name: 'lux', text: 'LUX', ...LUX, head: 3 },
{ name: 'hanzo', text: 'Hanzo', wght: 600, scaleX: 1.000, track: -0.0286, flatten: 0, thicken: 0, diag: 0, ux: 0, foot: 0, lu: 0, head: 0 },
{ name: 'zoo', text: 'zoo', wght: 800, scaleX: 1.000, track: -0.025, flatten: 0, thicken: 0, diag: 0, ux: 0, foot: 0, lu: 0, head: 0 },
// The Lux family: the MARK, then a word beside it. `head` is where the mark
// ends. Only the mark takes the bar amount — thicken steps any terminal that
// is not flat, so LUX takes it cleanly while a C, S, G or R notches. Measured
// on "LUX CREDIT" at d=90: the LUX comes out right and the C's terminals and
// the R's bowl visibly break. So the LUX here is the same LUX as the mark,
// letter for letter, and the word beside it is the clean cut.
...['LUX CREDIT', 'LUX FINANCE', 'LUX BANK', 'LUX FUND', 'LUX EXCHANGE',
'LUX LINK', 'LUX DAO', 'LUX MARKET', 'LUX TRADER', 'LUX PRO',
'LUX AI', 'LUX CHAT', 'LX'].map((text) => ({
name: text.toLowerCase().replace(/ /g, '-'), text, ...LUX,
head: text.startsWith('LUX') ? 3 : 2,
})),
]
mkdirSync(OUT, { recursive: true })
for (const m of MARKS) {
const svg = execFileSync(ZEN, ['mark', FONT, m.text,
String(m.wght), String(m.scaleX), String(m.track), String(m.flatten),
String(m.thicken), String(m.diag), '1.0', String(m.ux ?? 0),
String(m.foot ?? 0), String(m.lu ?? 0), String(m.head ?? 0)],
{ encoding: 'utf8', maxBuffer: 8 << 20 })
writeFileSync(join(OUT, `${m.name}.svg`), svg)
console.log(` ${m.name.padEnd(6)} ${m.text.padEnd(6)} wght ${m.wght} · ${m.scaleX}× · ` +
`${m.track}em${m.thicken ? ` · thicken ${m.thicken}` : ''} ${(svg.length / 1024).toFixed(1)} KB`)
}