diff --git a/apps/web/src/lib/components/theme/SiteLayout.svelte b/apps/web/src/lib/components/theme/SiteLayout.svelte
index d806d17..c14ea23 100644
--- a/apps/web/src/lib/components/theme/SiteLayout.svelte
+++ b/apps/web/src/lib/components/theme/SiteLayout.svelte
@@ -19,18 +19,16 @@
children: Snippet;
} = $props();
- // Colour roles set on the site record override the theme's own.
+ // Colour 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.
const overrides = $derived(
- Object.entries({
- '--paper': site.colors.background,
- '--ink': site.colors.foreground,
- '--accent': site.colors.accent,
- '--accent-ink': site.colors.accentForeground
- })
- .filter(([, value]) => value)
+ Object.entries(site.palette.tokens)
.map(([name, value]) => `${name}:${value}`)
.join(';')
);
+ const pinned = $derived(site.palette.scheme);
const nav = $derived(site.pages.filter((page) => page.listed));
@@ -48,17 +46,19 @@
- {#if site.colors.background}
-
- {@html ``}
-
+ {#if pinned}
+
+ {@html ``}
+
{:else}
{/if}
-
+
Skip to content
@@ -174,8 +174,11 @@
);
}
+ /* A pinned palette already replaces every token below through the style
+ attribute, which outranks this block; the :not() keeps that true if a
+ token is ever added here without a matching one in the palette. */
@media (prefers-color-scheme: dark) {
- .site {
+ .site:not(.pinned) {
--paper: #101520;
--paper-sunk: #161c29;
--ink: #e9e3d6;
diff --git a/apps/web/src/lib/server/render/palette.test.ts b/apps/web/src/lib/server/render/palette.test.ts
new file mode 100644
index 0000000..3cfe7bd
--- /dev/null
+++ b/apps/web/src/lib/server/render/palette.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { contrast, sitePalette } from './palette';
+
+const site = (colors: Record) => ({ theme: { colors } });
+const rgb = (r: number, g: number, b: number) => ({ r, g, b });
+
+/** Reads a token back out as the numeric triple the contrast math wants. */
+function parse(token: string): [number, number, number] {
+ const [r, g, b] = token.slice(4, -1).split(' ').map(Number);
+ return [r, g, b];
+}
+
+describe('sitePalette', () => {
+ it('leaves a site with no overrides on the theme’s own two schemes', () => {
+ expect(sitePalette(undefined)).toEqual({ tokens: {} });
+ expect(sitePalette({ name: 'Wind & Wing' })).toEqual({ tokens: {} });
+ expect(sitePalette(site({}))).toEqual({ tokens: {} });
+ expect(sitePalette(site({ accent: 'terracotta' }))).toEqual({ tokens: {} });
+ expect(sitePalette(site({ accent: rgb(0, 0, 300) }))).toEqual({ tokens: {} });
+ });
+
+ it('pins the scheme to the declared background', () => {
+ expect(sitePalette(site({ background: rgb(255, 255, 255) })).scheme).toBe('light');
+ expect(sitePalette(site({ background: rgb(12, 12, 20) })).scheme).toBe('dark');
+ });
+
+ it('reads a lone foreground as the inverse of the page behind it', () => {
+ // Dark text is a declaration that the paper under it is light.
+ expect(sitePalette(site({ foreground: rgb(20, 20, 20) })).scheme).toBe('light');
+ expect(sitePalette(site({ foreground: rgb(240, 240, 240) })).scheme).toBe('dark');
+ });
+
+ it('emits the theme’s full token set, not just the overridden roles', () => {
+ const { tokens } = sitePalette(site({ accent: rgb(30, 90, 160) }));
+ expect(Object.keys(tokens).sort()).toEqual([
+ '--accent',
+ '--accent-ink',
+ '--airmail-blue',
+ '--ink',
+ '--muted',
+ '--paper',
+ '--paper-sunk',
+ '--rule'
+ ]);
+ });
+
+ it('passes a colour 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', () => {
+ // 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);
+ expect(g).toBeGreaterThan(r * 4);
+ expect(g).toBeLessThan(160);
+ });
+
+ const hostile: Record[] = [
+ { accent: rgb(255, 255, 0) },
+ { accent: rgb(20, 160, 160) },
+ { background: rgb(255, 255, 255), foreground: rgb(250, 250, 250) },
+ { background: rgb(0, 0, 0), foreground: rgb(5, 5, 5) },
+ { background: rgb(128, 128, 128) },
+ { background: rgb(128, 128, 128), foreground: rgb(130, 130, 130), accent: rgb(127, 127, 127) },
+ { background: rgb(247, 243, 234), accent: rgb(255, 255, 255) },
+ { accent: rgb(16, 133, 254), accentForeground: rgb(20, 137, 255) },
+ { background: rgb(10, 10, 40), accent: rgb(12, 12, 42), foreground: rgb(11, 11, 41) }
+ ];
+
+ it.each(hostile)('holds every text pairing at AA for %j', (colors) => {
+ const { tokens } = sitePalette(site(colors));
+ const paper = parse(tokens['--paper']);
+ for (const role of ['--ink', '--muted', '--accent']) {
+ expect(contrast(parse(tokens[role]), paper)).toBeGreaterThanOrEqual(4.5);
+ }
+ expect(contrast(parse(tokens['--accent-ink']), parse(tokens['--accent']))).toBeGreaterThanOrEqual(
+ 4.5
+ );
+ });
+});
diff --git a/apps/web/src/lib/server/render/palette.ts b/apps/web/src/lib/server/render/palette.ts
new file mode 100644
index 0000000..41ac38a
--- /dev/null
+++ b/apps/web/src/lib/server/render/palette.ts
@@ -0,0 +1,151 @@
+/**
+ * Builds the colour 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
+ * 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
+ * text is held to WCAG AA (4.5:1) before it reaches the page.
+ */
+
+import type { SitePalette } from '$lib/site-view';
+
+type Rgb = [number, number, number];
+
+interface Scheme {
+ paper: Rgb;
+ ink: Rgb;
+ accent: Rgb;
+ accentInk: Rgb;
+ airmailBlue: Rgb;
+}
+
+/** The theme's own two schemes, and the starting point for a custom palette. */
+const LIGHT: Scheme = {
+ paper: [247, 243, 234],
+ ink: [22, 29, 43],
+ accent: [156, 50, 24],
+ accentInk: [253, 251, 246],
+ airmailBlue: [28, 63, 110]
+};
+
+const DARK: Scheme = {
+ paper: [16, 21, 32],
+ ink: [233, 227, 214],
+ accent: [212, 113, 79],
+ accentInk: [16, 21, 32],
+ airmailBlue: [58, 110, 168]
+};
+
+const AA = 4.5;
+
+function channel(value: number): number {
+ const s = value / 255;
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+}
+
+/** WCAG relative luminance, 0 for black through 1 for white. */
+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. */
+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 mix(a: Rgb, b: Rgb, amount: number): Rgb {
+ return a.map((c, i) => Math.round(c * (1 - amount) + b[i] * amount)) as Rgb;
+}
+
+function css([r, g, b]: Rgb): string {
+ return `rgb(${r} ${g} ${b})`;
+}
+
+function rgb(value: unknown): Rgb | undefined {
+ if (value === null || typeof value !== 'object') return undefined;
+ const { r, g, b } = value as Record;
+ if (typeof r !== 'number' || typeof g !== 'number' || typeof b !== 'number') return undefined;
+ if ([r, g, b].some((c) => !Number.isFinite(c) || c < 0 || c > 255)) return undefined;
+ return [Math.round(r), Math.round(g), Math.round(b)];
+}
+
+const BLACK: Rgb = [0, 0, 0];
+const WHITE: Rgb = [255, 255, 255];
+
+/**
+ * Returns `colour` 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.
+ *
+ * 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;
+ 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);
+ if (contrast(candidate, against) >= AA) return candidate;
+ }
+ return toward;
+}
+
+function colourOverrides(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 overrides = {
+ background: rgb(c.background),
+ foreground: rgb(c.foreground),
+ accent: rgb(c.accent),
+ accentForeground: rgb(c.accentForeground)
+ };
+ return Object.values(overrides).some(Boolean) ? overrides : 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);
+ if (!overrides) return { tokens: {} };
+
+ // The declared background decides which of the theme's schemes the rest of
+ // the palette is built from. With only a foreground declared the reading
+ // inverts: dark text means the page behind it is light.
+ let dark = false;
+ if (overrides.background) dark = isDark(overrides.background);
+ else if (overrides.foreground) dark = !isDark(overrides.foreground);
+ const base = dark ? DARK : LIGHT;
+
+ const paper = overrides.background ?? base.paper;
+ const ink = guard(overrides.foreground ?? base.ink, paper);
+ const accent = guard(overrides.accent ?? base.accent, paper);
+ const accentInk = guard(overrides.accentForeground ?? base.accentInk, accent);
+ // Secondary text sits between ink and paper, then is pulled back to AA.
+ const muted = guard(mix(ink, paper, 0.38), paper);
+
+ return {
+ scheme: dark ? 'dark' : 'light',
+ tokens: {
+ '--paper': css(paper),
+ '--paper-sunk': css(mix(paper, ink, 0.06)),
+ '--ink': css(ink),
+ '--muted': css(muted),
+ '--accent': css(accent),
+ '--accent-ink': css(accentInk),
+ '--rule': css(mix(paper, ink, 0.18)),
+ '--airmail-blue': css(base.airmailBlue)
+ }
+ };
+}
diff --git a/apps/web/src/lib/server/render/render.test.ts b/apps/web/src/lib/server/render/render.test.ts
index 8e2ab40..d02e839 100644
--- a/apps/web/src/lib/server/render/render.test.ts
+++ b/apps/web/src/lib/server/render/render.test.ts
@@ -3,7 +3,7 @@ import type { AtmosphereContext } from '../atmosphere/xrpc';
import { markdownExcerpt, renderMarkdown } from './markdown';
import { resolveSections } from './sections';
import { resolveSite } from './resolve';
-import { loadPages, themeColors } from './site';
+import { loadPages } from './site';
import { loadPageView } from './page';
const DID = 'did:plc:test123';
@@ -200,30 +200,6 @@ describe('resolveSections', () => {
});
});
-describe('themeColors', () => {
- it('maps rgb roles to CSS colours', () => {
- const site = {
- theme: {
- colors: {
- background: { r: 250, g: 250, b: 245 },
- accent: { r: 30, g: 90, b: 160 }
- }
- }
- };
- expect(themeColors(site)).toEqual({
- background: 'rgb(250 250 245)',
- foreground: undefined,
- accent: 'rgb(30 90 160)',
- accentForeground: undefined
- });
- });
-
- it('returns nothing for a site with no theme', () => {
- expect(themeColors(undefined)).toEqual({});
- expect(themeColors({ name: 'Wind & Wing' })).toEqual({});
- });
-});
-
describe('loadPages', () => {
it('keeps public and unlisted pages, marking which are listed', async () => {
const pages = await loadPages(
diff --git a/apps/web/src/lib/server/render/site.ts b/apps/web/src/lib/server/render/site.ts
index 5f3a44e..00d36de 100644
--- a/apps/web/src/lib/server/render/site.ts
+++ b/apps/web/src/lib/server/render/site.ts
@@ -17,13 +17,14 @@ import {
fetchSifaProfile,
fetchSifaSkills,
} from '../atmosphere';
+import { sitePalette } from './palette';
import type { SourceId } from '../atmosphere/sources';
import { readPages, readSite } from '../mooring';
import { resolveSections, type SectionSpec } from './sections';
import { markdownExcerpt } from './markdown';
-import type { PageLink, RenderedSection, SiteView, ThemeColors } from '../../site-view';
+import type { PageLink, RenderedSection, SitePalette, SiteView } from '../../site-view';
-export type { PageLink, RenderedSection, SiteView, ThemeColors };
+export type { PageLink, RenderedSection, SitePalette, SiteView };
const DEFAULT_POST_LIMIT = 5;
const DEFAULT_DOCUMENT_LIMIT = 5;
@@ -45,28 +46,6 @@ function oneLine(text: string): string {
.join(' · ');
}
-function rgb(value: unknown): string | undefined {
- if (value === null || typeof value !== 'object') return undefined;
- const { r, g, b } = value as Record;
- if (typeof r !== 'number' || typeof g !== 'number' || typeof b !== 'number') return undefined;
- return `rgb(${r} ${g} ${b})`;
-}
-
-/** Theme colour overrides from the site record; absent roles use the theme's own. */
-export function themeColors(site: Record | undefined): ThemeColors {
- const theme = site?.theme;
- if (theme === null || typeof theme !== 'object') return {};
- const colors = (theme as Record).colors;
- if (colors === null || typeof colors !== 'object') return {};
- const c = colors as Record;
- return {
- background: rgb(c.background),
- foreground: rgb(c.foreground),
- accent: rgb(c.accent),
- accentForeground: rgb(c.accentForeground)
- };
-}
-
function toPageLink(rkey: string, value: Record): PageLink | undefined {
const path = typeof value.path === 'string' ? value.path : undefined;
const title = typeof value.title === 'string' ? value.title : undefined;
@@ -172,7 +151,7 @@ export async function loadSiteView(
signOff: typeof site?.signOff === 'string' ? site.signOff : undefined,
iconUrl: iconUrlFor(ctx, site, profile?.avatarUrl),
discoverable: site?.discoverable !== false,
- colors: themeColors(site),
+ palette: sitePalette(site),
avatarUrl: profile?.avatarUrl,
profile,
sifaProfile,
diff --git a/apps/web/src/lib/site-view.ts b/apps/web/src/lib/site-view.ts
index c043ebf..0fc7de5 100644
--- a/apps/web/src/lib/site-view.ts
+++ b/apps/web/src/lib/site-view.ts
@@ -4,11 +4,14 @@
* reaching into theme markup — and so theme components need no server import.
*/
-export interface ThemeColors {
- background?: string;
- foreground?: string;
- accent?: string;
- accentForeground?: string;
+export interface SitePalette {
+ /** CSS custom property name to colour. Empty when the theme's own palette stands. */
+ tokens: Record;
+ /**
+ * Set when colour overrides pin the site to one scheme; absent when the
+ * theme's light and dark schemes both stay live.
+ */
+ scheme?: 'light' | 'dark';
}
export interface PageLink {
@@ -124,7 +127,7 @@ export interface SiteView {
/** Site icon: the record's icon blob, falling back to the profile avatar. */
iconUrl?: string;
discoverable: boolean;
- colors: ThemeColors;
+ palette: SitePalette;
avatarUrl?: string;
profile?: ProfileView;
sifaProfile?: SifaProfileView;
diff --git a/docs/NEXT.md b/docs/NEXT.md
index f4f5a4a..3f1cdb8 100644
--- a/docs/NEXT.md
+++ b/docs/NEXT.md
@@ -15,11 +15,10 @@ Feasibility is done and the verdict was **build it** (see `FEASIBILITY.md`). All
Done so far: OAuth login (loopback dev client; hosted-client path ready pending a real key + deploy) with D1-backed state/session stores; lexicon convention tests; **read-only adapters** for Bluesky (profile + posts, replies filtered), standard.site (documents/publications, `pub.leaflet.document` fallback only when no standard.site docs exist), and sifa (profile/positions/education/skills, defensively parsed) — fetch-injected modules in `apps/web/src/lib/server/atmosphere/` with unit tests, plus `detectSources` (drives the ADR 0012 default section order) and a source-overview admin page; **site/page authoring** — record builders, PDS writes through the OAuth session, and the `/admin` + `/admin/pages` CRUD routes; **the professional-presence theme** — the render pipeline in `apps/web/src/lib/server/render/` and the public routes at `/s/[handle]`. Remaining, roughly in dependency order:
- **Post embed rendering** (queued 2026-08-25, Jacob's call): render Bluesky post embeds instead of the "· view on Bluesky" cue that currently marks `hasEmbed` posts (critique 4: "the inconsistency reads as a glitch even though it's signal"). Scope: external-link cards (embed `app.bsky.embed.external` — title/description/thumb, thumb blob via the `/blob` route), image thumbnails (`app.bsky.embed.images` — blobs via `/blob`, alt text from the record), and quote posts (`app.bsky.embed.record` — needs a second `getRecord` for the quoted post; render author/text compactly, tolerate deleted/blocked targets). Adapter work in `atmosphere/bluesky.ts` (embed extraction, defensively parsed like facets), view-model additions in `site-view.ts`, theme rendering in `SiteSections.svelte` posts notes. The `/blob` width allowlist (256/768/1600) probably gains a thumb size. Keep the "view on Bluesky" cue only for embed kinds not rendered.
-- **Theme UX polish, remaining tail** (all mechanical findings from four critique rounds are fixed and deployed; what remains is design-decision territory — mock on a canvas before coding, per this push's working pattern):
+- **Theme UX polish, remaining tail** (all mechanical findings from four critique rounds are fixed and deployed; the contrast guards landed 2026-08-27, see Done; what remains is design-decision territory — mock on a canvas before coding, per this push's working pattern):
1. *Record-level career curation fields* (decided 2026-08-24, staged): hide/order/feature for positions, education, skills — e.g. de-emphasizing a high-school entry or dropping generic imported skills ("Software Development", "Windows 7"); becomes `careerSection` fields once the admin grows a section editor. Framing from the reviews: "honest by default, curatable by choice."
- 2. *Contrast guards for user color overrides*: `theme.colors` can set any `--accent`/background; nothing protects the AA math or the dark scheme (a single background override pins both schemes).
- 3. *Volume behavior*: every section is an uncapped list — decide caps/pagination ("latest N") before a user with 40 posts finds the missing answer. Posts/writing already honor per-section `limit` in the site record; the question is defaults and UI.
- 4. *Residual*: no RSS; postcard hero still unexercised by a real record (flip `heroSection.style` on a site record to demo); mono-metadata density (the detector's standing flat-type finding, 10.9–11.5px cluster — deliberate system, revisit only if it keeps bothering reviewers).
+ 2. *Volume behavior*: every section is an uncapped list — decide caps/pagination ("latest N") before a user with 40 posts finds the missing answer. Posts/writing already honor per-section `limit` in the site record; the question is defaults and UI.
+ 3. *Residual*: no RSS; postcard hero still unexercised by a real record (flip `heroSection.style` on a site record to demo); mono-metadata density (the detector's standing flat-type finding, 10.9–11.5px cluster — deliberate system, revisit only if it keeps bothering reviewers).
- **Thin deletion-honoring cache** over the direct PDS reads (ADR 0010 §4) — deliberately reordered below hosting on 2026-08-07: rendering reads live per request, which is correct until there is traffic to cache. The demo-first landing page (PD-10) raises the pressure: the apex now invites uncached live reads for arbitrary handles, so abuse or traffic on the lookup form is the trigger. Revisit once sites are actually served; when it comes up, start from Jetstream v2's snapshot+tail replay (`research/2026-08-14-bluesky-protocol-services.md`) rather than a relay consumer.
- **Landing-page critique follow-ups, design-decision tier** (queued 2026-08-25; snapshot `.impeccable/critique/2026-08-26T01-52-10Z__apps-web-src-routes-page-svelte.md`, scored 21/40 — the three P1s and the mechanical P2s are fixed; what remains needs product calls): gloss or drop "standard.site"/"sifa" jargon for the non-technical launch audience; a pricing signal ("free subdomain" reads as an unpriced paywall); accept a pasted DID in the lookup (currently lowercased and rejected as a typo); distinguish resolver outages from typos in the error copy; and the big swing — show a real rendered site on the landing page instead of describing one ("you've built a rendering engine and put a text ad in front of it").
- **Explore extracting themeability, and a second theme — possibly nautical** (queued 2026-08-25, Jacob's hunch): "Mooring" reads as boats to plenty of people before it reads as airships, and the professional-presence theme is currently the only theme, its palette and motifs woven through `SiteLayout`/`SiteSections` rather than sitting behind a seam. Two questions to explore together: (1) what a theme contract would look like (tokens? component set? the `theme.colors` override mechanism already hints at one) and whether extracting it is worth the indirection while there is exactly one theme; (2) whether a harbor/nautical variant (same letter-and-postmark bones, different motif and palette) is a cheap second theme that meets boat-minded visitors where they land. Mock on the design canvas before building; ADR 0008 scope discipline applies — this is exploration, not a committed v1 item.
@@ -38,6 +37,8 @@ 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 TBD, merged/deployed TBD). `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 — **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.
- 2026-08-26 — **The sign-off postmark stopped cutting off long handles** (PR #33, **merged and deployed 2026-08-26**; verified live at `mooring.page/s/timheffley.xyz` — the handle sits whole inside the ring, server-rendered `--handle-chars:14`, and no `text-overflow` remains in the served CSS). A new user's site — `timheffley.xyz`, 14 characters — rendered as "TIMHEFFLEY.X…" in the postmark ring. The ring's type was fixed at 0.62rem with `white-space: nowrap` + `text-overflow: ellipsis`, so every handle wider than the ring was guaranteed to truncate; `timheffley.xyz` was simply the first one to cross the line. The handle span now carries `--handle-chars` from `site.handle.length` and sizes itself: `clamp(0.45rem, calc(5.9rem / (0.74 * var(--handle-chars))), 0.62rem)` — the design's size for short handles, shrinking through the middle, floored where the mono type stops being readable, and past the floor `overflow-wrap: anywhere` wraps to a second line instead of truncating. The ellipsis is gone rather than layered over. The arithmetic only works because the ring is monospace (character count *is* text width at ~0.74em per character including tracking); the usable 5.9rem is the 7.5rem diameter less the 2px ring, which `box-sizing: border-box` folds in, and the 0.5rem padding — a first pass at 6.3rem overflowed by 1.4px and wrapped. Verified in a browser against the live render of `/s/timheffley.xyz`: 14 characters on one line inside the ring, `jacobzweifel.bsky.social` (24) and a 31-character DID fallback wrapping to two, none clipped. The landing page's postmark reads a hardcoded `mooring.page` and the OG card is a static PNG, so neither shares the fault.
--
2.51.2
From b2d457f545bbd902ac8837b35cc9113b3ef08b93 Mon Sep 17 00:00:00 2001
From: Jacob Zweifel
Date: Thu, 27 Aug 2026 13:24:46 -0400
Subject: [PATCH 2/3] NEXT: record the PR number for the contrast guards
---
docs/NEXT.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/NEXT.md b/docs/NEXT.md
index 3f1cdb8..b8db68c 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 TBD, merged/deployed TBD). `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 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 — **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.
--
2.51.2
From e685cc871a0f014d36ff8660c2c52c67037c405b Mon Sep 17 00:00:00 2001
From: Jacob Zweifel
Date: Thu, 27 Aug 2026 13:58:29 -0400
Subject: [PATCH 3/3] Settle on one spelling of color
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The repo is American everywhere it is not this PR: the lexicon field is
colors, the CSS property is color, and docs/ has no occurrences. Three
British spellings survived on main, all of them in the theme-color code
this PR touches, and following them locally spread the second spelling
into new identifiers — one statement managed both at once.
Co-Authored-By: Claude Opus 5
---
.../lib/components/theme/SiteLayout.svelte | 2 +-
.../web/src/lib/server/render/palette.test.ts | 4 +--
apps/web/src/lib/server/render/palette.ts | 32 +++++++++----------
apps/web/src/lib/site-view.ts | 4 +--
docs/NEXT.md | 2 +-
5 files changed, 22 insertions(+), 22 deletions(-)
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.