diff --git a/apps/web/src/lib/components/theme/SiteLayout.svelte b/apps/web/src/lib/components/theme/SiteLayout.svelte index c14ea23..8e1e85f 100644 --- a/apps/web/src/lib/components/theme/SiteLayout.svelte +++ b/apps/web/src/lib/components/theme/SiteLayout.svelte @@ -19,7 +19,7 @@ children: Snippet; } = $props(); - // Colour roles set on the site record replace the theme's whole palette, and + // Color roles set on the site record replace the theme's whole palette, and // pin the site to one scheme: inline custom properties outrank the theme's // prefers-color-scheme rules, so a half-overridden palette cannot survive a // scheme switch intact. diff --git a/apps/web/src/lib/server/render/palette.test.ts b/apps/web/src/lib/server/render/palette.test.ts index 3cfe7bd..8c36664 100644 --- a/apps/web/src/lib/server/render/palette.test.ts +++ b/apps/web/src/lib/server/render/palette.test.ts @@ -44,13 +44,13 @@ describe('sitePalette', () => { ]); }); - it('passes a colour that already clears AA through untouched', () => { + it('passes a color that already clears AA through untouched', () => { // 4.96:1 on the theme's paper. const { tokens } = sitePalette(site({ accent: rgb(34, 120, 60) })); expect(tokens['--accent']).toBe('rgb(34 120 60)'); }); - it('keeps the hue of a colour it has to darken', () => { + it('keeps the hue of a color it has to darken', () => { // 2.89:1 teal: too light for the paper, still recognisably teal after. const [r, g, b] = parse(sitePalette(site({ accent: rgb(20, 160, 160) })).tokens['--accent']); expect(g).toBe(b); diff --git a/apps/web/src/lib/server/render/palette.ts b/apps/web/src/lib/server/render/palette.ts index 41ac38a..9ba94d5 100644 --- a/apps/web/src/lib/server/render/palette.ts +++ b/apps/web/src/lib/server/render/palette.ts @@ -1,13 +1,13 @@ /** - * Builds the colour palette a rendered site paints with. + * Builds the color palette a rendered site paints with. * * The site record carries one `theme.colors` object and no scheme variants, so * any override declares a single palette: the site stops following the reader's - * colour-scheme preference and is pinned to the scheme its background belongs + * color-scheme preference and is pinned to the scheme its background belongs * to. Sites with no override emit no tokens and keep the theme's own two * schemes. * - * Overrides are arbitrary reader-supplied colour, so every pairing that carries + * Overrides are arbitrary reader-supplied color, so every pairing that carries * text is held to WCAG AA (4.5:1) before it reaches the page. */ @@ -52,15 +52,15 @@ function luminance([r, g, b]: Rgb): number { return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); } -/** WCAG contrast ratio, 1 for identical colours through 21 for black on white. */ +/** WCAG contrast ratio, 1 for identical colors through 21 for black on white. */ export function contrast(a: Rgb, b: Rgb): number { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); } /** Splits the range at the luminance of mid grey. */ -function isDark(colour: Rgb): boolean { - return luminance(colour) < 0.21; +function isDark(color: Rgb): boolean { + return luminance(color) < 0.21; } function mix(a: Rgb, b: Rgb, amount: number): Rgb { @@ -83,29 +83,29 @@ const BLACK: Rgb = [0, 0, 0]; const WHITE: Rgb = [255, 255, 255]; /** - * Returns `colour` when it already clears AA against `against`, otherwise the + * Returns `color` when it already clears AA against `against`, otherwise the * nearest step of it toward black or white that does. Hue is kept, so a brand - * colour that misses the floor still reads as itself. + * color that misses the floor still reads as itself. * * Whichever of black and white is further from `against` is the direction * taken; that endpoint always reaches at least 4.58:1, so the walk terminates. */ -function guard(colour: Rgb, against: Rgb): Rgb { - if (contrast(colour, against) >= AA) return colour; +function guard(color: Rgb, against: Rgb): Rgb { + if (contrast(color, against) >= AA) return color; const toward = contrast(BLACK, against) > contrast(WHITE, against) ? BLACK : WHITE; for (let step = 1; step <= 50; step++) { - const candidate = mix(colour, toward, step * 0.02); + const candidate = mix(color, toward, step * 0.02); if (contrast(candidate, against) >= AA) return candidate; } return toward; } -function colourOverrides(site: Record | undefined) { +function colorOverrides(site: Record | undefined) { const theme = site?.theme; if (theme === null || typeof theme !== 'object') return undefined; - const colours = (theme as Record).colors; - if (colours === null || typeof colours !== 'object') return undefined; - const c = colours as Record; + const colors = (theme as Record).colors; + if (colors === null || typeof colors !== 'object') return undefined; + const c = colors as Record; const overrides = { background: rgb(c.background), foreground: rgb(c.foreground), @@ -117,7 +117,7 @@ function colourOverrides(site: Record | undefined) { /** The palette a site paints with, empty when it takes the theme's own. */ export function sitePalette(site: Record | undefined): SitePalette { - const overrides = colourOverrides(site); + const overrides = colorOverrides(site); if (!overrides) return { tokens: {} }; // The declared background decides which of the theme's schemes the rest of diff --git a/apps/web/src/lib/site-view.ts b/apps/web/src/lib/site-view.ts index 0fc7de5..8e8643e 100644 --- a/apps/web/src/lib/site-view.ts +++ b/apps/web/src/lib/site-view.ts @@ -5,10 +5,10 @@ */ export interface SitePalette { - /** CSS custom property name to colour. Empty when the theme's own palette stands. */ + /** CSS custom property name to color. Empty when the theme's own palette stands. */ tokens: Record; /** - * Set when colour overrides pin the site to one scheme; absent when the + * Set when color overrides pin the site to one scheme; absent when the * theme's light and dark schemes both stay live. */ scheme?: 'light' | 'dark'; diff --git a/docs/NEXT.md b/docs/NEXT.md index b8db68c..94d697d 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -37,7 +37,7 @@ The whole design is settled and recorded — `research/2026-08-26-analytics-offe ## Done -- 2026-08-27 — **User colour overrides can no longer render a site unreadable** (PR #39, open as a draft; not merged, not deployed). `theme.colors` set `--paper`/`--ink`/`--accent`/`--accent-ink` through the `style` attribute on `.site`, and inline custom properties outrank every stylesheet rule — so the theme's own `@media (prefers-color-scheme: dark)` block could never fire for an overridden token. A site that declared only a background got that background pinned in dark mode while `--ink` still flipped to `#e9e3d6`: near-white text on near-white paper, ~1.06:1, with a near-black `--paper-sunk` panel sitting in the middle of it. Reproduced in a browser before the fix. The record carries one `colors` object and no scheme variants, so the fix takes that at its word: **any colour override declares a single palette and pins the site to one scheme**, chosen by the background's luminance (or, with only a foreground declared, its inverse — dark text means light paper). A new `apps/web/src/lib/server/render/palette.ts` builds the whole eight-token palette server-side from whatever subset was declared — the derived roles (`--muted`, `--rule`, `--paper-sunk`) are mixed from the *declared* paper and ink instead of stranding at the other scheme's values — and holds every text-bearing pairing (ink/muted/accent on paper, accent-ink on accent) to WCAG AA 4.5:1. **Failing colours are darkened or lightened toward black or white until they clear, rather than dropped** (Jacob's call): most plausible brand colours fail AA on the near-white paper — Bluesky blue is 3.26:1, a mid teal 2.89:1 — so dropping them would have made "I set my accent and nothing happened" the common case. Hue is preserved; the teal renders 24% darker and still unmistakably teal. The walk always terminates: whichever of black and white is further from the background reaches at least 4.58:1. `color-scheme` is pinned alongside the palette (via `html:has(.site.pinned)`, whose specificity beats the theme's own `html` rule regardless of load order) so scrollbars and form controls stop following the OS against a pinned page. Sites with no override emit no tokens at all and keep both schemes — verified unchanged in both directions. 15 new tests including nine hostile palettes (identical fg/bg, mid-grey, white-on-white); verified in a browser through a temporary preview route, deleted before commit. **Known ceiling:** an accent-only override now also pins the scheme, so a user who only wanted a brand colour loses dark mode. The honest upgrade is a `colorsDark` sibling in the `page.mooring.site` lexicon rather than more CSS — the lexicon simply cannot express two schemes today. Its `colors` description was left alone to avoid a republish for prose; fold the pinning semantics in next time that record is republished. +- 2026-08-27 — **User color overrides can no longer render a site unreadable** (PR #39, open as a draft; not merged, not deployed). `theme.colors` set `--paper`/`--ink`/`--accent`/`--accent-ink` through the `style` attribute on `.site`, and inline custom properties outrank every stylesheet rule — so the theme's own `@media (prefers-color-scheme: dark)` block could never fire for an overridden token. A site that declared only a background got that background pinned in dark mode while `--ink` still flipped to `#e9e3d6`: near-white text on near-white paper, ~1.06:1, with a near-black `--paper-sunk` panel sitting in the middle of it. Reproduced in a browser before the fix. The record carries one `colors` object and no scheme variants, so the fix takes that at its word: **any color override declares a single palette and pins the site to one scheme**, chosen by the background's luminance (or, with only a foreground declared, its inverse — dark text means light paper). A new `apps/web/src/lib/server/render/palette.ts` builds the whole eight-token palette server-side from whatever subset was declared — the derived roles (`--muted`, `--rule`, `--paper-sunk`) are mixed from the *declared* paper and ink instead of stranding at the other scheme's values — and holds every text-bearing pairing (ink/muted/accent on paper, accent-ink on accent) to WCAG AA 4.5:1. **Failing colors are darkened or lightened toward black or white until they clear, rather than dropped** (Jacob's call): most plausible brand colors fail AA on the near-white paper — Bluesky blue is 3.26:1, a mid teal 2.89:1 — so dropping them would have made "I set my accent and nothing happened" the common case. Hue is preserved; the teal renders 24% darker and still unmistakably teal. The walk always terminates: whichever of black and white is further from the background reaches at least 4.58:1. `color-scheme` is pinned alongside the palette (via `html:has(.site.pinned)`, whose specificity beats the theme's own `html` rule regardless of load order) so scrollbars and form controls stop following the OS against a pinned page. Sites with no override emit no tokens at all and keep both schemes — verified unchanged in both directions. 15 new tests including nine hostile palettes (identical fg/bg, mid-grey, white-on-white); verified in a browser through a temporary preview route, deleted before commit. **Known ceiling:** an accent-only override now also pins the scheme, so a user who only wanted a brand color loses dark mode. The honest upgrade is a `colorsDark` sibling in the `page.mooring.site` lexicon rather than more CSS — the lexicon simply cannot express two schemes today. Its `colors` description was left alone to avoid a republish for prose; fold the pinning semantics in next time that record is republished. - 2026-08-27 — **The admin and login pages wear the site letterhead** (PR #35, merged and deployed 2026-08-27). The /admin routes and the sign-in page were bare system-ui defaults next to a landing page with a committed identity. The palette and control skin now live once in `apps/web/src/lib/styles/letterhead.css`, class-scoped under `.letterhead` (airmail stripe, paper/ink/terracotta tokens in both color schemes, serif body with the mono-uppercase system voice, one skin for inputs/buttons/links/notices/`code`), imported by a new `admin/+layout.svelte` — masthead nav (Overview / Pages / Hosting, current section marked), container sizing, valediction footer — and by the login page. The admin pages shed their ad-hoc styles and hardcoded colors; per-page "← Admin" back-links gave way to the persistent nav; destructive actions (Delete, Remove, Release, Sign out) wear a quiet outline that warms to `--warn` on hover; the hosting page's paused notice traded its side-stripe for a sunk paper panel. Verified in-browser (login at the real route, admin via a temporary unauthenticated preview route, deleted before commit) in both schemes at desktop and 375px. Shipped with one bug — the white body-margin frame — fixed in the follow-up noted above. - 2026-08-26 — **The landing footer names a human and points at the source** (PR #33, **merged and deployed 2026-08-26**; verified live on mooring.page — both links present and resolving). Jacob's call: the page asked visitors to trust it with their handle while offering no way to find out who wrote it or read the code. The footer row now reads "Made for the Atmosphere by **Jacob Zweifel**" (→ `jzweifel.dev`, itself a Mooring site, so the byline doubles as a second example) and "**Source on Tangled (AGPL-3.0)**" (→ `tangled.org/jzweifel.dev/fahrenheit`, the canonical URL for the `sh.tangled.repo` record in Jacob's PDS; the GitHub mirror is deliberately not linked — the Atmosphere-native forge is the one that fits the pitch). Link styling and the negative-margin tap padding are lifted from the site theme's own footer, and the connecting rule now hides below 40rem the way the theme's does, so the wrapped three-line stack on a phone has no squeezed rule in it. No new colors: the links inherit `--muted`, which clears 4.5:1 on paper in both schemes (5.05 light, 5.41 dark) and stays underlined. **This closes the "substantiate the ownership claim" clause of the queued landing-critique item** ("you've built a rendering engine and put a text ad in front of it" and the rest still stand). Verified at desktop and 375px.