Let the reader set type, density and face

The explorer had no way for a person to change how it reads. Colour mode was
the only control, and it is one bit.

@hanzo/appearance stores the preference and puts it on <html>; what the numbers
mean is bound in styles/tokens.css. Type rides the root font size, so every
Tailwind rung — all of them in rem — moves from one declaration and nothing is
restated. Density rides --spacing, the single base every p-*, m-* and gap-*
computes from. Both default to 1, so an install that never opens the panel
renders exactly what it rendered before; measured in a browser against the
production build: 16px root, p-4 at 16px, unchanged.

@hanzo/design's token sheets stay out. They declare --text-*, --radius-* and
--breakpoint-* — the names Tailwind v4 publishes — outside any layer, and v4
compiles utilities to var() references rather than baked values, so importing
them silently reanchors the app: --text-base 16px to 14px, --radius-lg 8px to
12px. Two sets under one set of names is a fork, not a migration. They become
the ramps when Tailwind leaves and there is one ramp to own.

The boot script is hashed for CSP from the same call that renders it, the way
the adbutler scripts are. A pasted literal goes stale on the next release of
the package and fails silently — policy parses, browser drops the script, page
paints at the defaults.
This commit is contained in:
2026-08-28 23:45:54 -07:00
parent 5bbb57995a
commit 793816c7a7
7 changed files with 174 additions and 11 deletions
+11
View File
@@ -1,3 +1,6 @@
import { bootScript } from '@hanzo/appearance/state';
import Base64 from 'crypto-js/enc-base64';
import sha256 from 'crypto-js/sha256';
import type CspDev from 'csp-dev';
import config from 'configs/app';
@@ -81,6 +84,14 @@ export function app(isPrivateMode = false): CspDev.DirectiveDescriptor {
'\'sha256-YiC5bd+aSY6gJKgwwD9kRRZCdn/qi++mRW6ERR4uZ3c=\'',
'\'sha256-wMOeDjJaOTjCfNjluteV+tSqHW547T89sgxd8W6tQJM=\'',
'\'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=\'',
// The appearance boot script (_document). Hashed from the SAME call that
// renders it, the way the adbutler scripts are, rather than pasted as a
// literal: the script is @hanzo/appearance's and changes when that package
// does, and a stale literal fails silently — the policy still parses, the
// browser drops the script, and the page paints at the published defaults
// with the reader's own settings quietly ignored.
`'sha256-${ Base64.stringify(sha256(bootScript())) }'`,
],
'style-src': [
+7
View File
@@ -25,6 +25,13 @@
* Breakpoints match Chakra: sm=415px, lg=1000px, xl=1440px, 2xl=1920px
* Dark mode via .dark class (next-themes "class" strategy)
* Font families: Geist (body/heading), Geist Mono (mono)
*
* The block below is the TAILWIND BRIDGE: names registered so a call site can
* write `text-good` or `bg-gray-100` and get a utility, with the values coming
* from @luxfi/ui. It exists because Tailwind is here and it goes when Tailwind
* goes. The other @theme, in styles/tokens.css, is a different thing with a
* different lifetime — it binds a reader's own settings to the ramps, and it
* outlives Tailwind. Two blocks, two reasons; Tailwind merges them.
*/
@theme {
/* --- Breakpoints --- */
+1
View File
@@ -46,6 +46,7 @@
"@dynamic-labs/ethereum": "4.52.2",
"@dynamic-labs/sdk-react-core": "4.52.2",
"@dynamic-labs/wagmi-connector": "4.52.2",
"@hanzo/appearance": "0.2.2",
"@hanzo/event": "^0.3.21",
"@helia/verified-fetch": "2.6.12",
"@luxfi/bens-types": "1.4.3",
+19
View File
@@ -1,3 +1,4 @@
import { bootScript } from '@hanzo/appearance/state';
import type { DocumentContext } from 'next/document';
import Document, { Html, Head, Main, NextScript } from 'next/document';
import React from 'react';
@@ -44,6 +45,24 @@ class MyDocument extends Document {
return (
<Html lang="en">
<Head>
{ /*
* The reader's own settings, before the first paint.
*
* Type scale, ratio, density, face and measure are stored on the
* device and land as inline custom properties on <html>, which is
* :root — so they outrank the token sheet without needing a
* selector to out-specify it. Every ramp in @hanzo/design already
* multiplies by them, so five properties retune the whole page.
*
* It runs here, synchronously, because anything later paints the
* published defaults first and then jumps, and a jump on every load
* reads as a bug rather than as a setting. The panel re-applies on
* mount (useAppearance in toolkit/next/provider), where an accent
* colour can be validated — which a head script this small will not
* do.
*/ }
<script dangerouslySetInnerHTML={{ __html: bootScript() }}/>
{ /* FONTS */ }
<link
href={ config.UI.fonts.heading?.url ?? 'https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap' }
+21
View File
@@ -60,6 +60,9 @@ importers:
'@dynamic-labs/wagmi-connector':
specifier: 4.52.2
version: 4.52.2(2ec052942d9018144c9e8da474b03fb7)
'@hanzo/appearance':
specifier: 0.2.2
version: 0.2.2([email protected])
'@hanzo/event':
specifier: ^0.3.21
version: 0.3.21([email protected])
@@ -2677,6 +2680,15 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@hanzo/[email protected]':
resolution: {integrity: sha512-HsH2wT82EbjHF1zY/TWubUQv7N5tBolVyBtXuQD8I+3E7xQ+Qoy7u79Xz1lw0EVkBESM2dr6VMaPWWThnytzZg==}
peerDependencies:
react: '>=19'
'@hanzo/[email protected]':
resolution: {integrity: sha512-f+/F5Ftysx29TL/ICm2uo2VGR/tZKrz69qpOFZfwfJEy2VU7u+yuz0fV+74WsIARnloRM1TAFMfdzvObWEq35w==}
hasBin: true
'@hanzo/[email protected]':
resolution: {integrity: sha512-gUEql7ZteyNIkxdX9G44plCNYMNppdU3K9s9uc3IBtwdMjCS6iRM7Z7EP9aOtZm8LqPnAJU9l7IRYYFKhG9cRQ==}
peerDependencies:
@@ -7733,10 +7745,12 @@ packages:
'@xmldom/[email protected]':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xmldom/[email protected]':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
deprecated: this version has critical issues, please update to the latest version
'@xstate/[email protected]':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
@@ -17714,6 +17728,13 @@ snapshots:
protobufjs: 7.3.2
yargs: 17.7.2
'@hanzo/[email protected]([email protected])':
dependencies:
'@hanzo/design': 0.5.25
react: 19.2.4
'@hanzo/[email protected]': {}
'@hanzo/[email protected]([email protected])':
optionalDependencies:
react: 19.2.4
+79 -8
View File
@@ -1,13 +1,84 @@
/*
* Explore rides the canonical Lux token set.
* The token layer.
*
* Every colour the explorer paints resolves through @luxfi/ui/tokens.css — one
* achromatic ladder, two state hues, and the --lux-accent hook the Host header
* retints. This file used to be a 500-line fork of that sheet, which is how it
* drifted its own browns: --color-status-warn #B45309 / #FBBF7A, and the whole
* Chakra orange + yellow badge ramp (#FEEBCB #FFFAF0 #DD6B20 #FFFFF0 #D69E2E
* #FEFCBF #B7791F #C05621 #7B341E #744210 #FAF089).
* COLOUR is @luxfi/ui's. One achromatic ladder, two state hues, and the
* --lux-accent hook a hostname retints. It declares colour and nothing else —
* measured: no space ramp, no type ramp, no radius, no z, no grid among its 348
* properties.
*
* Add a token to the design system, never here.
* MEASURE is Tailwind's, for as long as Tailwind is here. v4 keeps the ramps in
* `@theme` and compiles each utility to a `var()` REFERENCE rather than a baked
* value — `.p-4` is `calc(var(--spacing) * 4)`, `.text-base` is
* `var(--text-base)`. So the ramps are already live custom properties, and a
* preference reaches them by retuning the ramp rather than by restating it.
*
* That reference is also why @hanzo/design's token sheets are NOT imported here.
* They declare --text-*, --radius-* and --breakpoint-* — the same names Tailwind
* publishes — outside any layer, so they win, and the whole app silently
* reanchors: --text-base 16px -> 14px, --radius-lg 8px -> 12px, --breakpoint-sm
* 415px -> 40rem. Measured in the built sheet, not inferred. design's ramps
* become the explorer's ramps when Tailwind leaves and there is one ramp to own;
* until then two sets under one set of names is a silent fork, not a migration.
*/
@import '@luxfi/ui/tokens.css';
/*
* Where a reader's own settings land.
*
* @hanzo/appearance stores the preference and puts it on <html> as inline
* custom properties — before first paint via the head script in _document, and
* again on mount for the axes a 900-byte script will not validate. It writes
* numbers only. What those numbers MEAN is here, so the storage and the
* arithmetic stay in separate hands.
*
* Two axes reach the whole page from one declaration each:
*
* --type-scale the root font size. Every Tailwind rung is in rem, so one
* percentage moves the entire ramp — rungs this file has never
* heard of included, and rungs added later. Nothing is restated,
* so nothing can drift.
*
* --density the spacing base. Every p-*, m-*, gap-* is calc(var(--spacing)
* * n), so the ladder has exactly one rung to retune. It sits on
* TOP of type: larger type carries its layout with it, and
* density is then how tight that layout runs.
*
* Both default to 1, so an install that has never opened the panel renders byte
* for byte what it rendered before.
*
* --font-sans the face. `font` picks one; Tailwind's font-sans utility
* already resolves through this property.
* --primary the accent, which @luxfi/ui components read.
*
* The `ratio` and `modular` axes are inert on this surface and will stay inert
* while Tailwind owns the ramp: both reshape the SPACING BETWEEN rungs, which
* needs the ramp to be parameterised rung by rung the way tokens/typography.css
* writes it. Tailwind's ladder is a flat list of literals — there is no term to
* multiply. `width` (the measure) is inert for the same reason: it moves
* --container-max / --container-prose / --container-wide, which this surface
* does not lay out against.
*
* A reader's accent reaches --primary but not --lux-accent, which is what the
* explorer's own --color-button-solid-bg and --color-link-primary derive from.
* Binding them here would mean `--lux-accent: var(--primary)` against a sheet
* that already says `--primary: var(--lux-accent)` — a cycle, and a cycle in CSS
* is not an error, it is both properties resolving to nothing. The hook belongs
* in @luxfi/ui, one line: give --lux-accent a reader-settable fallback.
*/
@theme {
--spacing: calc(0.25rem * var(--density, 1));
}
:root {
font-size: calc(100% * var(--type-scale, 1));
/*
* The faces `font` can choose between. It makes the switch by pointing
* --font-sans at one of these, so a face it can name has to exist. Sans and
* mono are the explorer's own, declared in the Tailwind theme; serif is the
* one @hanzo/design's fonts.css would have brought, and it is a stack rather
* than a webfont because a reader who asks for a serif wants to read, not to
* wait for a download.
*/
--font-serif: Georgia, 'Times New Roman', serif;
}
+36 -3
View File
@@ -1,5 +1,6 @@
'use client';
import { useAppearance } from '@hanzo/appearance';
import React from 'react';
import {
@@ -7,9 +8,41 @@ import {
type ColorModeProviderProps,
} from './color-mode';
// own test harness wrapper.
export function Provider(props: ColorModeProviderProps) {
/*
* Two independent readings of the same page, kept apart on purpose.
*
* COLOUR MODE is light or dark. It is one bit, it is what the OS already has an
* opinion about, and next-themes owns it — the class on <html> that the token
* sheet switches on.
*
* APPEARANCE is everything else a reader gets to say: how big the type runs, how
* far apart its rungs sit, how tight the spacing is, which face, how wide the
* measure, and the accent. Those are numbers, not a bit, and they multiply ramps
* rather than swapping palettes.
*
* Folding them together is the mistake to avoid. A colour-mode toggle that also
* carried type size would make one control answer two questions, and the axes do
* not even share a storage shape: the mode is per-origin device state, while the
* appearance belongs to the PERSON and follows them across origins once signed
* in (@hanzo/appearance/account).
*
* The head script in _document has already applied the stored preference before
* this mounts. This hook exists for what a script that must run before any
* bundle cannot do — validate an accent colour, and keep the document in step
* when the reader changes a setting.
*/
function ApplyAppearance() {
useAppearance();
return null;
}
export interface ProviderProps extends ColorModeProviderProps {}
export function Provider({ children, ...props }: ProviderProps) {
return (
<ColorModeProvider { ...props }/>
<ColorModeProvider { ...props }>
<ApplyAppearance/>
{ children }
</ColorModeProvider>
);
}