From 04493fdb4bcfa0a98da3a682208ada8be6a65473 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 04:03:23 +0700 Subject: [PATCH 01/12] Shell: do not save the layout before the saved one is restored A save timer queued before the async startup (settings, profiles, workspaces) finished fired on the still-empty dock and overwrote the stored layout, so a shell reload came back with one fresh Home tab. Saves now wait for layoutReady. Co-Authored-By: Claude Opus 5.5 --- packages/shell/src/frame/Shell.tsx | 2 ++ packages/shell/src/frame/state.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shell/src/frame/Shell.tsx b/packages/shell/src/frame/Shell.tsx index 36abe2a..c0ff9ed 100644 --- a/packages/shell/src/frame/Shell.tsx +++ b/packages/shell/src/frame/Shell.tsx @@ -832,6 +832,8 @@ export default function Shell() { } // A restored deck tab counts: deck sessions come back to their deck, not a new Home tab. if (!api.panels.some(panel => isWeb(panel) || (panel.params as TabParams | undefined)?.kind === 'deck')) addWeb(shell.homeUrl); + shell.layoutReady = true; + saveLayoutSoon(); api.onDidLayoutChange(() => { markTitlebarGroups(); scheduleSync(); saveLayoutSoon(); }); api.onDidAddGroup(() => requestAnimationFrame(markTitlebarGroups)); diff --git a/packages/shell/src/frame/state.ts b/packages/shell/src/frame/state.ts index 443586b..4dc5bf7 100644 --- a/packages/shell/src/frame/state.ts +++ b/packages/shell/src/frame/state.ts @@ -39,6 +39,8 @@ export const shell = { closed: [] as Array<{ url: string; profile?: string; title?: string }>, profiles: defaultProfiles() as ProfileSettings, restoring: false, + /** False until the saved layout was read back: an earlier save would overwrite it with an empty dock. */ + layoutReady: false, /** Toolbar actions implemented by Shell.tsx (split view, the app's panel). */ split: (_id: string, _direction: 'right' | 'below') => {}, openPanel: (_from?: string) => {}, @@ -277,7 +279,7 @@ let saveTimer: ReturnType | undefined; export function saveLayoutSoon() { clearTimeout(saveTimer); saveTimer = setTimeout(() => { - if (!shell.api || shell.restoring) return; + if (!shell.api || shell.restoring || !shell.layoutReady) return; try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(shell.api.toJSON())); } catch { /* Session restore is best effort. */ } }, 400); } From ec68721cbe8130d9ba9f6ee075eda67c9e0e6f46 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 04:09:11 +0700 Subject: [PATCH 02/12] Icon names for X's newer rail items: history, studio, articles Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeIcons.ts | 1 + packages/waybetter/icons.schema.json | 9 +++++++++ packages/waybetter/theme.schema.json | 9 +++++++++ 3 files changed, 19 insertions(+) diff --git a/packages/runtime/src/themeIcons.ts b/packages/runtime/src/themeIcons.ts index 90f12c7..f73e8af 100644 --- a/packages/runtime/src/themeIcons.ts +++ b/packages/runtime/src/themeIcons.ts @@ -21,6 +21,7 @@ export const ICON_NAMES = [ 'reply', 'repost', 'repost.on', 'like', 'like.on', 'views', 'bookmark', 'bookmark.on', 'share', 'verified', 'logo', 'caret', 'back', 'close', 'search', 'settings', 'media', 'gif', 'poll', 'emoji', 'schedule', 'location', 'pinned', 'repostContext', 'lock', + 'history', 'studio', 'articles', ] as const; export type IconName = typeof ICON_NAMES[number]; export const ICON_GROUPS = ['nav', 'actions', 'composer', 'misc'] as const; diff --git a/packages/waybetter/icons.schema.json b/packages/waybetter/icons.schema.json index d10e6b5..2c2ac7e 100644 --- a/packages/waybetter/icons.schema.json +++ b/packages/waybetter/icons.schema.json @@ -211,6 +211,15 @@ }, "lock": { "$ref": "#/$defs/glyph" + }, + "history": { + "$ref": "#/$defs/glyph" + }, + "studio": { + "$ref": "#/$defs/glyph" + }, + "articles": { + "$ref": "#/$defs/glyph" } }, "maxProperties": 64, diff --git a/packages/waybetter/theme.schema.json b/packages/waybetter/theme.schema.json index f790856..7d97d51 100644 --- a/packages/waybetter/theme.schema.json +++ b/packages/waybetter/theme.schema.json @@ -4126,6 +4126,15 @@ }, "lock": { "$ref": "#/$defs/glyph" + }, + "history": { + "$ref": "#/$defs/glyph" + }, + "studio": { + "$ref": "#/$defs/glyph" + }, + "articles": { + "$ref": "#/$defs/glyph" } }, "maxProperties": 8 From 4d39a28ac29771d8ba270b0f17bd0a1071924852 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 04:29:27 +0700 Subject: [PATCH 03/12] Store: status words follow the theme (ok/warn/danger ink tokens), with a token test A literal #3ecf8e green read 1.03:1 on the Windows 98 silver card (theme QA r1). Status text now mixes the status hue toward --wb-text; store.css may hold no literal text colours outside its token defaults. Co-Authored-By: Claude Opus 5.5 --- packages/shell/src/store.css | 32 +++++++++++++----------- packages/shell/tests/themeTokens.test.ts | 23 +++++++++++++++++ 2 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 packages/shell/tests/themeTokens.test.ts diff --git a/packages/shell/src/store.css b/packages/shell/src/store.css index dabb097..55d3432 100644 --- a/packages/shell/src/store.css +++ b/packages/shell/src/store.css @@ -16,6 +16,10 @@ --wb-hover: rgb(238 240 247 / .06); --wb-press: rgb(238 240 247 / .1); --wb-accent: #1d9bf0; --wb-accent-rgb: 29 155 240; --wb-accent-soft: rgb(29 155 240 / .14); --wb-accent-ink: #cfe6ff; --wb-on-accent: #fff; --wb-ok: #3ecf8e; --wb-warn: #ffb020; + /* Status words on any card: the status hue pulled toward the text colour, so green/amber text stays + readable on light and silver themes too (a raw #3ecf8e was 1.03:1 on Windows 98 silver). */ + --wb-ok-ink: color-mix(in oklab, var(--wb-ok) 42%, var(--wb-text)); --wb-warn-ink: color-mix(in oklab, var(--wb-warn) 42%, var(--wb-text)); + --wb-danger-ink: color-mix(in oklab, #ff5c6c 50%, var(--wb-text)); --wb-font: system-ui, sans-serif; --wb-font-mono: ui-monospace, monospace; --wb-shadow-pop: 0 18px 44px -14px rgba(0, 0, 0, .75), 0 3px 10px rgba(0, 0, 0, .35); --wb-ring: 0 0 0 2px rgb(29 155 240 / .6); @@ -58,13 +62,13 @@ background: var(--wb-field); box-shadow: inset 0 0 0 1px var(--wb-border); } .wbi-source > svg { flex: none; color: var(--wb-muted); } -.wbi-source.is-store > svg { color: #3ecf8e; } +.wbi-source.is-store > svg { color: var(--wb-ok-ink); } .wbi-source > div { min-width: 0; flex: 1; display: grid; gap: 2px; } .wbi-source strong { font-weight: 600; } .wbi-source code { font: 12px/1.4 var(--wb-font-mono); color: var(--wb-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wbi-verified { flex: none; display: inline-flex; align-items: center; gap: 5px; padding-block: 4px; padding-inline: 7px 9px; border-radius: 999px; - font: 600 11.5px/1 var(--wb-font); color: #3ecf8e; background: rgb(62 207 142 / .12); box-shadow: inset 0 0 0 1px rgb(62 207 142 / .35); + font: 600 11.5px/1 var(--wb-font); color: var(--wb-ok-ink); background: rgb(62 207 142 / .12); box-shadow: inset 0 0 0 1px rgb(62 207 142 / .35); } .wbi-desc { margin: 0; color: var(--wb-text-2); } @@ -87,17 +91,17 @@ justify-self: start; padding: 3px 9px; border-radius: 999px; font: 600 11.5px/1.3 var(--wb-font); background: rgb(var(--wb-text-rgb) / .07); color: var(--wb-text); box-shadow: inset 0 0 0 1px var(--wb-border-strong); } -.wbi-perms li.is-warn .wbi-chip { background: rgb(255 176 32 / .12); color: #ffc55c; box-shadow: inset 0 0 0 1px rgb(255 176 32 / .35); } -.wbi-perms li.is-ok .wbi-chip { background: rgb(62 207 142 / .12); color: #58d9a0; box-shadow: inset 0 0 0 1px rgb(62 207 142 / .32); } +.wbi-perms li.is-warn .wbi-chip { background: rgb(255 176 32 / .12); color: var(--wb-warn-ink); box-shadow: inset 0 0 0 1px rgb(255 176 32 / .35); } +.wbi-perms li.is-ok .wbi-chip { background: rgb(62 207 142 / .12); color: var(--wb-ok-ink); box-shadow: inset 0 0 0 1px rgb(62 207 142 / .32); } .wbi-note { display: flex; gap: 8px; margin: 10px 0 0; padding: 9px 11px; border-radius: 10px; color: var(--wb-text-2); background: rgb(var(--wb-text-rgb) / .035); font-size: 12.5px; } -.wbi-note svg { flex: none; margin-top: 2px; color: #ffc55c; } +.wbi-note svg { flex: none; margin-top: 2px; color: var(--wb-warn-ink); } .wbi-findings { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; font-size: 12.5px; color: var(--wb-text-2); } .wbi-findings strong { margin-inline-end: 2px; } -.wbi-findings span { padding: 2px 8px; border-radius: 6px; background: rgb(255 176 32 / .1); color: #ffc55c; } +.wbi-findings span { padding: 2px 8px; border-radius: 6px; background: rgb(255 176 32 / .1); color: var(--wb-warn-ink); } .wbi-theme { display: grid; gap: 8px; } .wbi-theme-preview { border-radius: 12px; overflow: hidden; box-shadow: 0 0 0 1px var(--wb-border); } -.wbi-warnings { margin: 0; padding-inline-start: 18px; color: #ffc55c; font-size: 12.5px; } +.wbi-warnings { margin: 0; padding-inline-start: 18px; color: var(--wb-warn-ink); font-size: 12.5px; } .wbi-code { direction: ltr; border-radius: 12px; background: var(--wb-ground); box-shadow: inset 0 0 0 1px var(--wb-border); overflow: hidden; } .wbi-code header { display: flex; justify-content: space-between; align-items: baseline; padding: 9px 12px; border-bottom: 1px solid var(--wb-border-soft); font-weight: 600; } @@ -126,8 +130,8 @@ .wbi-state .wbi-muted { color: var(--wb-muted); font-size: 12.5px; } .wbi-state-actions { display: flex; gap: 10px; margin-top: 14px; } .wbi-state-icon { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; } -.wbi-state-icon.is-ok { color: #3ecf8e; background: rgb(62 207 142 / .12); box-shadow: inset 0 0 0 1px rgb(62 207 142 / .35); } -.wbi-state-icon.is-warn { color: #ffc55c; background: rgb(255 176 32 / .12); box-shadow: inset 0 0 0 1px rgb(255 176 32 / .35); } +.wbi-state-icon.is-ok { color: var(--wb-ok-ink); background: rgb(62 207 142 / .12); box-shadow: inset 0 0 0 1px rgb(62 207 142 / .35); } +.wbi-state-icon.is-warn { color: var(--wb-warn-ink); background: rgb(255 176 32 / .12); box-shadow: inset 0 0 0 1px rgb(255 176 32 / .35); } .wbi-spinner { width: 34px; height: 34px; border-radius: 50%; border: 3px solid rgb(var(--wb-text-rgb) / .1); border-top-color: var(--wb-accent); animation: wbi-spin .8s linear infinite; } @keyframes wbi-spin { to { transform: rotate(360deg); } } @@ -167,7 +171,7 @@ .wbs-cats-end { flex: 1; } .wbs-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--wb-muted); } .wbs-status.is-ok svg { color: var(--wb-ok); } -.wbs-status.is-warn { color: var(--wb-warn); } +.wbs-status.is-warn { color: var(--wb-warn-ink); } .wbs-updates { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 10px 14px; border-radius: 12px; background: var(--wb-accent-soft); border: 1px solid rgb(var(--wb-accent-rgb) / .35); color: var(--wb-text-2); @@ -196,11 +200,11 @@ .wbs-desc { margin: 0; color: var(--wb-text-2); font-size: 12.5px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .wbs-perms { display: flex; flex-wrap: wrap; gap: 5px; list-style: none; margin: 2px 0 0; padding: 0; } .wbs-perms li { padding: 2px 8px; border-radius: 999px; font-size: 11px; line-height: 1.5; color: var(--wb-text-2); background: rgb(var(--wb-text-rgb) / .06); border: 1px solid var(--wb-border); } -.wbs-perms li.is-warn { color: var(--wb-warn); border-color: color-mix(in srgb, var(--wb-warn) 40%, transparent); background: color-mix(in srgb, var(--wb-warn) 10%, transparent); } -.wbs-perms li.is-ok { color: var(--wb-ok); border-color: color-mix(in srgb, var(--wb-ok) 35%, transparent); background: color-mix(in srgb, var(--wb-ok) 9%, transparent); } +.wbs-perms li.is-warn { color: var(--wb-warn-ink); border-color: color-mix(in srgb, var(--wb-warn) 40%, transparent); background: color-mix(in srgb, var(--wb-warn) 10%, transparent); } +.wbs-perms li.is-ok { color: var(--wb-ok-ink); border-color: color-mix(in srgb, var(--wb-ok) 35%, transparent); background: color-mix(in srgb, var(--wb-ok) 9%, transparent); } .wbs-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: auto; padding-top: 8px; } .wbs-state { font-size: 12px; color: var(--wb-muted); margin-inline-end: auto; } -.wbs-state.is-ok { color: var(--wb-ok); } +.wbs-state.is-ok { color: var(--wb-ok-ink); } .wbs-foot { margin: 4px 0 0; color: var(--wb-muted); font-size: 12.5px; } .wbs-foot code { font-family: var(--wb-font-mono, ui-monospace, monospace); color: var(--wb-text-2); } @@ -215,7 +219,7 @@ .wb-btn:disabled { opacity: .5; cursor: default; } .wb-btn.is-primary { background: var(--wb-accent); border-color: transparent; color: var(--wb-on-accent); } .wb-btn.is-ghost { background: transparent; border-color: transparent; color: var(--wb-text-2); } -.wb-btn.is-danger { background: color-mix(in srgb, #f4212e 16%, transparent); border-color: color-mix(in srgb, #f4212e 45%, transparent); color: #ff7b85; } +.wb-btn.is-danger { background: color-mix(in srgb, #f4212e 16%, transparent); border-color: color-mix(in srgb, #f4212e 45%, transparent); color: var(--wb-danger-ink); } .wb-icon-btn { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 8px; background: transparent; color: var(--wb-muted); cursor: pointer; } .wb-icon-btn:hover { background: var(--wb-hover); color: var(--wb-text); } .wb-field { height: 32px; padding: 0 12px; border-radius: 9px; border: 1px solid var(--wb-border); background: var(--wb-field); color: var(--wb-text); font: 13px var(--wb-font); } diff --git a/packages/shell/tests/themeTokens.test.ts b/packages/shell/tests/themeTokens.test.ts new file mode 100644 index 0000000..f1ed69f --- /dev/null +++ b/packages/shell/tests/themeTokens.test.ts @@ -0,0 +1,23 @@ +// Theme QA bug class "fixed status colour on a themed card": the Store and install screen are themed through +// --wb-* tokens, so no text colour there may be a literal (a raw #3ecf8e green was 1.03:1 on Windows 98 +// silver). Literals are allowed only in the token default block; text uses --wb-*-ink or other tokens. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const css = readFileSync(join(import.meta.dirname, '../src/store.css'), 'utf8'); + +test('store.css: no literal text colours outside the token defaults', () => { + const body = css.replace(/:where\(\.wbs, \.wbi-backdrop, \.wbi-drop\) \{[\s\S]*?\n\}/, '').replace(/\/\*[\s\S]*?\*\//g, ''); + const literal = [...body.matchAll(/(? m[0]); + const allowed = literal.filter(d => !/#fff\b|#ffffff|rgba?\(0,? 0,? 0/i.test(d)); + assert.deepEqual(allowed, [], 'use var(--wb-ok-ink)/var(--wb-warn-ink) or another --wb-* token'); +}); + +test('store.css: status words use the ink tokens', () => { + for (const sel of ['.wbs-state.is-ok', '.wbs-perms li.is-ok', '.wbs-perms li.is-warn', '.wbs-status.is-warn']) { + const rule = css.slice(css.indexOf(sel)).split('}')[0]; + assert.match(rule, /color: var\(--wb-(ok|warn)-ink\)/, sel); + } +}); From 68217c818eb077409686926ba8caf02e30c29dc1 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 04:56:04 +0700 Subject: [PATCH 04/12] Theme engine v3 step 4: inkOn, the contrast solver themeInk.ts: OKLCH lightness search toward the pole with headroom, hue kept, chroma cut only for the gamut; several fills (gradient ends, glass grounds); compositing. reach() now runs on it (8% headroom when a colour has to move, for the site's tints mixed from these surfaces). Property test: 5,000 random pairs reach 4.5:1 and 3:1; the live 1.0.2 cases ("New" pill 1.19:1, HoverLabel 2.7:1, like pink 3.84:1) pass. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeInk.ts | 116 ++++++++++++++++++++++++ packages/runtime/src/themeSpec.ts | 21 ++--- packages/runtime/tests/themeInk.test.ts | 67 ++++++++++++++ 3 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 packages/runtime/src/themeInk.ts create mode 100644 packages/runtime/tests/themeInk.test.ts diff --git a/packages/runtime/src/themeInk.ts b/packages/runtime/src/themeInk.ts new file mode 100644 index 0000000..6c2c8b3 --- /dev/null +++ b/packages/runtime/src/themeInk.ts @@ -0,0 +1,116 @@ +// The contrast solver of theme engine v3 (.omc/plans/theme-engine-v3.md 3.3): one function for the +// derivation, the site remap, the layer guard, the shell and the Theme Builder. Pure, no DOM. +// +// inkOn(fill, preferred, target) keeps `preferred` when it reads on `fill`; otherwise it moves the +// colour in OKLCH lightness toward the pole (black or white) with more headroom on that fill, keeping +// the hue and giving up chroma only where the sRGB gamut forces it, and stops at the first lightness +// that reaches the target (binary search). Ratios are WCAG 2.x; APCA is not a gate in v3.0. + +import { contrastRatio, hexToRgb } from './themeApi.ts'; + +export type InkResult = { ink: string; adjusted: boolean; ratio: number }; +export type InkOptions = { + /** Hue and chroma are always kept where the gamut allows; the flag marks a meaning colour (like, repost) for notes. */ + keepHue?: boolean; + /** A translucent `fill` (`#rrggbbaa`) is composited over this opaque colour first. */ + over?: string; +}; + +/** WCAG targets used across the engine. */ +export const TARGET = { text: 4.5, large: 3, nonText: 3, high: 7 } as const; + +// --------------------------------------------------------------------------------------------- +// sRGB <-> OKLab / OKLCH + +const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const fromLinear = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); + +export type Oklch = { l: number; c: number; h: number }; + +export function hexToOklch(hex: string): Oklch { + const [r, g, b] = hexToRgb(hex).map(v => toLinear(v / 255)) as [number, number, number]; + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + return { l: L, c: Math.hypot(A, B), h: Math.atan2(B, A) }; +} + +/** Linear sRGB of an OKLCH colour (may be out of gamut). */ +function oklchToLinear({ l, c, h }: Oklch): [number, number, number] { + const A = c * Math.cos(h), B = c * Math.sin(h); + const l_ = (l + 0.3963377774 * A + 0.2158037573 * B) ** 3; + const m_ = (l - 0.1055613458 * A - 0.0638541728 * B) ** 3; + const s_ = (l - 0.0894841775 * A - 1.291485548 * B) ** 3; + return [ + 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_, + -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_, + -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_, + ]; +} + +const inGamut = (rgb: number[]) => rgb.every(v => v >= -1e-4 && v <= 1 + 1e-4); +const hex2 = (v: number) => Math.round(Math.min(1, Math.max(0, fromLinear(Math.min(1, Math.max(0, v))))) * 255).toString(16).padStart(2, '0'); + +/** OKLCH to `#rrggbb`, reducing chroma (never hue or lightness) until the colour is in sRGB. */ +export function oklchToHex(color: Oklch): string { + let rgb = oklchToLinear(color); + if (!inGamut(rgb)) { + let lo = 0, hi = color.c; + for (let i = 0; i < 16; i++) { + const mid = (lo + hi) / 2; + if (inGamut(oklchToLinear({ ...color, c: mid }))) lo = mid; else hi = mid; + } + rgb = oklchToLinear({ ...color, c: lo }); + } + return `#${rgb.map(hex2).join('')}`; +} + +// --------------------------------------------------------------------------------------------- +// Compositing + +/** `#rrggbbaa` over an opaque `#rrggbb` (sRGB space, as browsers composite). `#rrggbb` passes through. */ +export function composite(fill: string, over: string): string { + if (!/^#[0-9a-f]{8}$/i.test(fill)) return fill.toLowerCase(); + const alpha = parseInt(fill.slice(7, 9), 16) / 255; + const top = hexToRgb(fill.slice(0, 7)), bottom = hexToRgb(over); + return `#${top.map((v, i) => Math.round(v * alpha + bottom[i]! * (1 - alpha)).toString(16).padStart(2, '0')).join('')}`; +} + +// --------------------------------------------------------------------------------------------- +// The solver + +/** The worst ratio of `ink` over every fill. */ +const worst = (ink: string, fills: string[]) => Math.min(...fills.map(fill => contrastRatio(ink, fill))); + +/** + * The ink closest to `preferred` that reaches `target` on every one of `fills` (all opaque). + * Several fills: the ink has to read on each (a gradient's two ends, glass over two grounds). + */ +export function inkOnEvery(fills: string[], preferred: string, target: number, options: InkOptions = {}): InkResult { + const grounds = fills.map(fill => composite(fill, options.over ?? '#ffffff')); + const start = preferred.toLowerCase(); + const startRatio = worst(start, grounds); + if (startRatio >= target) return { ink: start, adjusted: false, ratio: startRatio }; + // The pole with more headroom: the one that reads better on the worst ground. + const toWhite = worst('#ffffff', grounds) >= worst('#000000', grounds); + const pole = toWhite ? '#ffffff' : '#000000'; + const poleRatio = worst(pole, grounds); + if (poleRatio < target) return { ink: pole, adjusted: true, ratio: poleRatio }; + const base = hexToOklch(start); + // Binary search on lightness between the colour and the pole; the ratio grows monotonically along it. + let lo = base.l, hi = toWhite ? 1 : 0, best = pole; + for (let i = 0; i < 12; i++) { + const mid = (lo + hi) / 2; + const candidate = oklchToHex({ l: mid, c: base.c, h: base.h }); + if (worst(candidate, grounds) >= target) { best = candidate; hi = mid; } else lo = mid; + } + return { ink: best, adjusted: true, ratio: worst(best, grounds) }; +} + +/** The ink closest to `preferred` that reaches `target` on `fill`. */ +export function inkOn(fill: string, preferred: string, target: number, options: InkOptions = {}): InkResult { + return inkOnEvery([fill], preferred, target, options); +} diff --git a/packages/runtime/src/themeSpec.ts b/packages/runtime/src/themeSpec.ts index 5e251f7..98e8b19 100644 --- a/packages/runtime/src/themeSpec.ts +++ b/packages/runtime/src/themeSpec.ts @@ -16,8 +16,9 @@ // system fonts, "Geist" / "Geist Mono" (bundled and registered from bytes: `FontFace(ArrayBuffer)` is // not a fetch), or Google Fonts families the owner downloaded (`source: "google"`, fonts.ts). -import { FONT_ROLE_NAMES, MONO_FAMILIES, contrastRatio, hexToRgb, mix, relativeLuminance, type FontRoleName } from './themeApi.ts'; +import { FONT_ROLE_NAMES, MONO_FAMILIES, hexToRgb, mix, type FontRoleName } from './themeApi.ts'; import { deriveColors } from './themeTokens.ts'; +import { inkOnEvery } from './themeInk.ts'; import { ACTION_NAMES, sanitizeComponents, type ActionColors, type ThemeComponents } from './themeComponents.ts'; import { sanitizeDecor, type ThemeDecor } from './themeDecor.ts'; import { sanitizeThemeIcons, type ThemeIcons } from './themeIcons.ts'; @@ -151,19 +152,17 @@ export const isHex = (value: unknown): value is string => typeof value === 'stri /** `#rrggbb` + alpha as `rgba()`. */ export const rgba = (hex: string, alpha: number) => `rgba(${hexToRgb(hex).join(', ')}, ${+alpha.toFixed(3)})`; -/** Moves `color` toward black or white until it reaches `ratio` against every surface. */ +/** + * The colour closest to `color` (OKLCH lightness, hue kept) that reaches `ratio` against every surface + * (themeInk.ts). A colour that has to move gets 8% headroom: the site's hover and pressed tints are mixed + * from these surfaces, and an exact solver would leave the ink a hair under the target on them. + */ export function reach(color: string, surfaces: string[], ratio: number): string { - const worst = (c: string) => Math.min(...surfaces.map(s => contrastRatio(c, s))); - if (worst(color) >= ratio) return color; - const lightSurfaces = surfaces.reduce((sum, s) => sum + relativeLuminance(s), 0) / surfaces.length > 0.18; - const pole = lightSurfaces ? '#000000' : '#ffffff'; - for (let t = 0.04; t <= 1; t += 0.04) { - const next = mix(color, pole, t); - if (worst(next) >= ratio) return next; - } - return pole; + const kept = inkOnEvery(surfaces, color, ratio); + return kept.adjusted ? inkOnEvery(surfaces, color, ratio * 1.08).ink : kept.ink; } + /** * Guarantees legibility for any palette (built-in, imported or overridden): text, secondary text * and links reach WCAG AA (4.5:1) on every surface, including the page canvas, and the site's filled diff --git a/packages/runtime/tests/themeInk.test.ts b/packages/runtime/tests/themeInk.test.ts new file mode 100644 index 0000000..4b8f4b9 --- /dev/null +++ b/packages/runtime/tests/themeInk.test.ts @@ -0,0 +1,67 @@ +// Theme engine v3, step 4: the contrast solver (themeInk.ts). Property test over random palettes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { contrastRatio } from '../src/themeApi.ts'; +import { composite, hexToOklch, inkOn, inkOnEvery, oklchToHex, TARGET } from '../src/themeInk.ts'; +import { reach } from '../src/themeSpec.ts'; + +/** Deterministic PRNG (mulberry32): the property test is reproducible. */ +function rng(seed: number) { + return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} +const randomHex = (r: () => number) => `#${Array.from({ length: 3 }, () => Math.floor(r() * 256).toString(16).padStart(2, '0')).join('')}`; + +test('inkOn keeps an ink that already reads, unchanged', () => { + const result = inkOn('#ffffff', '#000000', TARGET.text); + assert.deepEqual([result.ink, result.adjusted], ['#000000', false]); +}); + +test('5,000 random fill and ink pairs reach 4.5:1 after inkOn, 3:1 for non-text', () => { + const r = rng(20261002); + for (let i = 0; i < 5000; i++) { + const fill = randomHex(r), preferred = randomHex(r); + for (const target of [TARGET.text, TARGET.nonText]) { + const { ink, ratio } = inkOn(fill, preferred, target); + assert.ok(contrastRatio(ink, fill) >= target - 1e-9, `${preferred} on ${fill}: ${ratio.toFixed(2)} < ${target} (${ink})`); + } + } +}); + +test('an adjusted ink keeps the hue of the preferred colour (meaning colours stay themselves)', () => { + // The live 1.0.2 failure: navy accent text on Creator Studio's indigo pill, 1.19:1. + const { ink, adjusted, ratio } = inkOn('#1e1b4b', '#0000a8', TARGET.text); + assert.ok(adjusted && ratio >= 4.5, `${ink} ${ratio}`); + const hue = (hex: string) => hexToOklch(hex).h; + assert.ok(Math.abs(hue(ink) - hue('#0000a8')) < 0.15, `hue drifted: ${ink}`); + // X's like pink on white was 3.84:1 as a count. + const pink = inkOn('#ffffff', '#f91880', TARGET.text, { keepHue: true }); + assert.ok(pink.ratio >= 4.5 && Math.abs(hue(pink.ink) - hue('#f91880')) < 0.15, pink.ink); +}); + +test('the "More" HoverLabel: black on X grey 82 gets a readable ink', () => { + const { ratio } = inkOn('#525252', '#000000', TARGET.text); + assert.ok(ratio >= 4.5); +}); + +test('every fill of a gradient or glass is honoured', () => { + // The 98 title bar is bold chrome type: large-text target on both ends of its gradient. + const { ink } = inkOnEvery(['#000080', '#1084d0'], '#c0c0c0', TARGET.large); + for (const fill of ['#000080', '#1084d0']) assert.ok(contrastRatio(ink, fill) >= 3, `${ink} on ${fill}`); + // No ink reaches 4.5:1 on both: the solver says so instead of pretending (the deriver then darkens the end). + const none = inkOnEvery(['#000080', '#1084d0'], '#ffffff', TARGET.text); + assert.ok(none.adjusted && none.ratio < 4.5); + assert.equal(composite('#00000080', '#ffffff'), '#7f7f7f'); + const glass = inkOn('#ffffff80', '#808080', TARGET.text, { over: '#000000' }); + assert.ok(contrastRatio(glass.ink, '#808080') >= 4.5 - 0.05); +}); + +test('OKLCH round trip is exact for sRGB colours', () => { + const r = rng(7); + for (let i = 0; i < 500; i++) { const hex = randomHex(r); assert.equal(oklchToHex(hexToOklch(hex)), hex); } +}); + +test('reach runs on the solver and still guarantees every surface', () => { + const out = reach('#888888', ['#ffffff', '#f4f4f4', '#dfdfdf'], 4.5); + for (const s of ['#ffffff', '#f4f4f4', '#dfdfdf']) assert.ok(contrastRatio(out, s) >= 4.5); + assert.equal(reach('#000000', ['#ffffff'], 7), '#000000'); +}); From 013e1ff98f83330fa1c3c29517f3aeb7fa9b299d Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 05:00:02 +0700 Subject: [PATCH 05/12] Theme engine v3 step 1: themeInvariants, the numeric invariant probe (I1-I8, I10) A pure measurement over a rendered document (fixtures and live): text and icon contrast over the painted backdrop, X colour leakage, truncated bar/nav/button labels, bar geometry and hit tests, X line icons under a pack, empty and nested frames, popover bounds, target sizes. Grew out of theme-qa/probe.js. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeInvariants.ts | 228 ++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 packages/runtime/src/themeInvariants.ts diff --git a/packages/runtime/src/themeInvariants.ts b/packages/runtime/src/themeInvariants.ts new file mode 100644 index 0000000..cf5877e --- /dev/null +++ b/packages/runtime/src/themeInvariants.ts @@ -0,0 +1,228 @@ +// Theme invariants (theme engine v3, plan 6.2): a pure measurement over a rendered document that +// says, with numbers, whether a theme is correct. Runs in the page (fixtures over Playwright, the live +// app over CDP); it never changes the page. Grew out of verification/theme-qa/probe.js. +// +// I1 text contrast 4.5:1 (3:1 large; 7:1 with access high) of ink over its painted backdrop +// I1b non-text contrast icons (svg, masked glyphs) 3:1 over their backdrop +// I2 X colour leakage computed colours equal to an X palette key that is not whitelisted +// I3 truncated labels in bar, nav, tabs, buttons and menu rows (X's own long-name truncation allowed) +// I4 bar geometry a horizontal bar: equal item widths with labels, gaps <= one item, hit-testable +// I5 icon provenance in pack contexts: X's own line paths still visible (xLine) +// I6 empty frames a framed box (edge or own fill) with nothing rendered inside, taller than 2px +// I7 frame nesting a framed box holding another framed box of the same fill, or a whole column +// I8 popover bounds menus, hover cards, dropdowns, tooltips inside the viewport minus the bar inset +// I10 targets interactive elements in bar, action bar, composer >= 24px (44 with access high) +// I9 (forced :hover) needs CDP and I11 is static; both live in the runners. + +export type InvariantId = 'I1' | 'I1b' | 'I2' | 'I3' | 'I4' | 'I5' | 'I6' | 'I7' | 'I8' | 'I10'; +export const INVARIANT_IDS: InvariantId[] = ['I1', 'I1b', 'I2', 'I3', 'I4', 'I5', 'I6', 'I7', 'I8', 'I10']; + +export type Rect = [number, number, number, number]; +export interface Finding { id: InvariantId; sel: string; rect: Rect; detail: string; value?: number } +export interface InvariantOptions { + /** Colours (`rgb(r, g, b)`) that are X's own palette; default: X's light and dark neutrals and blue. */ + xColourKeys?: string[]; + /** The X default theme keeps X's colours: I2 off. */ + exemptLeak?: boolean; + /** The theme draws an icon pack: I5 on. */ + pack?: boolean; + access?: 'standard' | 'high'; + /** Cap of findings kept per invariant (counts are always complete). */ + keep?: number; +} +export interface InvariantReport { + counts: Record; + findings: Finding[]; + /** Measured but not failures: text over images, X icons with no slot, X's allowed truncation. */ + notes: { unknownBackdrop: number; allowedTruncation: number; bar: 'none' | 'rail' | 'horizontal'; barMode?: 'labels' | 'icons' }; + viewport: [number, number]; +} + +/** X's own palette (Default and Lights out neutrals, X blue): the leak list until the engine exports X_COLOUR_KEYS. */ +export const DEFAULT_X_KEYS = ['rgb(29, 155, 240)', 'rgb(26, 140, 216)', 'rgb(15, 20, 25)', 'rgb(83, 100, 113)', 'rgb(113, 118, 123)', 'rgb(239, 243, 244)', + 'rgb(247, 249, 249)', 'rgb(231, 233, 234)', 'rgb(22, 24, 28)', 'rgb(32, 35, 39)', 'rgb(47, 51, 54)', 'rgb(20, 20, 20)', 'rgb(207, 217, 222)', 'rgb(51, 54, 57)']; + +type RGBA = [number, number, number, number]; +const parse = (c: string | null | undefined): RGBA | null => { const m = /rgba?\(([\d.]+),? ([\d.]+),? ([\d.]+)(?:,? ?\/? ?([\d.]+))?/.exec(c || ''); return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]] : null; }; +const lum = ([r, g, b]: RGBA) => { const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; +export const contrast = (a: RGBA, b: RGBA) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); }; +const blend = (top: RGBA, under: RGBA): RGBA => top[3] >= 1 ? top : [0, 1, 2].map(i => top[i] * top[3] + under[i] * (1 - top[3])).concat(1) as RGBA; +const css = (c: RGBA) => `rgb(${c.slice(0, 3).map(Math.round).join(', ')})`; + +export function runInvariants(doc: Document = document, opts: InvariantOptions = {}): InvariantReport { + const win = doc.defaultView as Window & typeof globalThis; + const vw = win.innerWidth, vh = win.innerHeight; + const keep = opts.keep ?? 12; + const high = opts.access === 'high'; + const counts = Object.fromEntries(INVARIANT_IDS.map(id => [id, 0])) as Record; + const findings: Finding[] = []; + const notes: InvariantReport['notes'] = { unknownBackdrop: 0, allowedTruncation: 0, bar: 'none' }; + const cs = (e: Element) => win.getComputedStyle(e); + const up = (e: Element): Element | null => e.parentElement ?? ((e.parentNode as ShadowRoot | null)?.host ?? null); + const rectOf = (e: Element): Rect => { const r = e.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)]; }; + const sel = (el: Element) => { + const p: string[] = []; + for (let e: Element | null = el; e && e !== doc.body && p.length < 4; e = up(e)) { + let s = e.tagName.toLowerCase(); const t = e.getAttribute('data-testid'); + if (t) { p.unshift(`${s}[data-testid="${t}"]`); break; } + if (e.id) { p.unshift(`#${e.id}`); break; } + const c = [...e.classList].filter(c => !/^(css|r)-/.test(c)).slice(0, 2); if (c.length) s += '.' + c.join('.'); + const role = e.getAttribute('role'); if (role) s += `[role="${role}"]`; + p.unshift(s); + } + return p.join(' > '); + }; + const add = (id: InvariantId, el: Element, detail: string, value?: number) => { + counts[id]++; + if (findings.filter(f => f.id === id).length < keep) findings.push({ id, sel: sel(el), rect: rectOf(el), detail, ...(value === undefined ? {} : { value: +value.toFixed(2) }) }); + }; + const hidden = (el: Element) => { for (let e: Element | null = el; e; e = up(e)) { const s = cs(e); if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity < 0.05) return true; } return false; }; + const onScreen = (el: Element) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1 && r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw; }; + const visible = (el: Element) => onScreen(el) && !hidden(el); + const pageColor = (): RGBA => { for (const e of [doc.body, doc.documentElement]) { const c = parse(cs(e).backgroundColor); if (c && c[3] > 0) return blend(c, [255, 255, 255, 1]); } return [255, 255, 255, 1]; }; + /** The painted backdrop under an element: the alpha stack up to the first opaque fill; `image` when an image or gradient paints first. */ + const backdrop = (el: Element): { color: RGBA; image: boolean } => { + const stack: RGBA[] = []; + for (let e: Element | null = el; e && e !== doc.documentElement; e = up(e)) { + const s = cs(e); + if (s.backgroundImage !== 'none' && !/^linear-gradient\(rgba?\([^)]*\), rgba?\([^)]*\)\)$/.test(s.backgroundImage)) return { color: pageColor(), image: true }; + const c = parse(s.backgroundColor); + if (c && c[3] > 0) { stack.push(c); if (c[3] >= 1) break; } + } + let base = pageColor(); + for (const c of stack.reverse()) base = blend(c, base); + return { color: base, image: false }; + }; + const ownText = (el: Element) => [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim(); + const all = [...doc.querySelectorAll('body *')]; + + // I1 text contrast, I2 leakage, I3 truncation + const xKeys = new Set(opts.xColourKeys ?? DEFAULT_X_KEYS); + const whitelisted = (el: Element) => !!el.closest('[data-testid="icon-verified"],[data-testid="unlike"],[data-testid="unretweet"],[data-testid="removeBookmark"],img,video,canvas'); + const LABEL_CONTEXT = 'nav[role="navigation"], header[role="banner"], [role="tablist"], [role="tab"], button, [role="button"], [role="menuitem"], .dv-tab'; + const ALLOWED_TRUNC = '[data-testid="User-Name"], [data-testid="trend"], .bt-exact-time, [data-testid="UserName"], [data-testid="UserCell"]'; + for (const el of all) { + if (el.closest('[aria-hidden="true"]') || el.closest('svg') && el.tagName.toLowerCase() !== 'svg') continue; + const text = ownText(el); + if (!visible(el)) continue; + const st = cs(el); + if (!opts.exemptLeak && !whitelisted(el)) { + for (const prop of ['color', 'backgroundColor', 'borderTopColor', 'borderBottomColor', 'fill', 'outlineColor'] as const) { + const v = st[prop] as string; if (!xKeys.has(v)) continue; + if (prop.startsWith('border') && parseFloat(prop === 'borderTopColor' ? st.borderTopWidth : st.borderBottomWidth) === 0) continue; + if (prop === 'outlineColor' && (st.outlineStyle === 'none' || parseFloat(st.outlineWidth) === 0)) continue; + if (prop === 'color' && !text) continue; + if (prop === 'fill' && el.tagName.toLowerCase() !== 'svg') continue; + add('I2', el, `${v} as ${prop}`); break; + } + } + if (!text) continue; + const fg = parse(st.color); if (!fg) continue; + const back = backdrop(el); + if (back.image) notes.unknownBackdrop++; + else { + const size = parseFloat(st.fontSize), bold = +st.fontWeight >= 700; + const large = size >= 24 || (size >= 18.66 && bold); + const need = high ? (large ? 4.5 : 7) : (large ? 3 : 4.5); + const ink = blend([fg[0], fg[1], fg[2], fg[3] * Math.min(1, +st.opacity || 1)], back.color); + const r = contrast(ink, back.color); + if (r < need - 0.005) add('I1', el, `${r.toFixed(2)}:1 < ${need} ${st.color} on ${css(back.color)} "${text.slice(0, 24)}"`, r); + } + if (el.closest(LABEL_CONTEXT)) { + const cut = el.scrollWidth > el.clientWidth + 1 && (st.textOverflow === 'ellipsis' || st.overflow === 'hidden' || st.overflowX === 'hidden'); + const barLabel = !!el.closest('nav[role="navigation"]') && st.textOverflow === 'ellipsis' && el.scrollWidth > el.clientWidth + 1; + if (cut || barLabel) { + if (el.closest(ALLOWED_TRUNC)) notes.allowedTruncation++; + else add('I3', el, `"${text.slice(0, 24)}" shown in ${el.clientWidth}px of ${el.scrollWidth}px`, el.clientWidth); + } + } + } + + // I1b non-text contrast and I5 icon provenance + const PACK_CONTEXT = 'nav[role="navigation"], header[role="banner"], [role="group"], [data-testid="toolBar"], #layers [role="menu"], [data-testid="GrokDrawer"], [data-testid="DMDrawer"], [data-testid="app-bar-back"], [data-testid="app-bar-close"]'; + for (const svg of doc.querySelectorAll('svg')) { + if (!visible(svg) || svg.closest('[data-testid="icon-verified"]')) continue; + const s = cs(svg); + const painted = [...svg.querySelectorAll('path, circle, rect, polygon, ellipse, line')].filter(p => { const ps = cs(p); return +ps.opacity > 0.05 && (ps.fill !== 'none' || ps.stroke !== 'none'); }); + const masked = (e: Element) => { const es = cs(e); return (es.maskImage && es.maskImage !== 'none') || ((es as unknown as Record).webkitMaskImage ?? 'none') !== 'none' || es.backgroundImage !== 'none'; }; + if (opts.pack && svg.closest(PACK_CONTEXT) && painted.length && !masked(svg) && !(svg.parentElement && masked(svg.parentElement))) add('I5', svg, `X's own drawing in ${svg.closest(PACK_CONTEXT)!.tagName.toLowerCase()}`); + const ink = parse(masked(svg) ? s.backgroundColor : s.color); + if (!ink || ink[3] === 0 || (!painted.length && !masked(svg))) continue; + const back = backdrop(svg.parentElement ?? svg); + if (back.image) continue; + const r = contrast(blend(ink, back.color), back.color); + if (r < 3 - 0.005) add('I1b', svg, `${r.toFixed(2)}:1 < 3 icon ${css(ink)} on ${css(back.color)}`, r); + } + + // I6 empty frames, I7 nesting + const framed = (el: Element) => { + const s = cs(el), r = el.getBoundingClientRect(); + if (r.width < 60 || r.height <= 2 || s.position === 'fixed' && r.width >= vw - 2 && r.height >= vh - 2) return false; + if (s.backgroundImage !== 'none') return false; + const edge = ['Top', 'Right', 'Bottom', 'Left'].filter(k => parseFloat(s[`border${k}Width` as 'borderTopWidth']) > 0 && s[`border${k}Style` as 'borderTopStyle'] !== 'none' && (parse(s[`border${k}Color` as 'borderTopColor'])?.[3] ?? 0) > 0).length >= 3 || /inset/.test(s.boxShadow); + const fill = parse(s.backgroundColor); const parent = up(el); + const own = !!fill && fill[3] > 0.05 && !!parent && css(blend(fill, backdrop(parent).color)) !== css(backdrop(parent).color); + return edge || own; + }; + const MEDIA = 'img, svg, video, canvas, input, textarea, select, iframe, [role="img"], [role="textbox"], [role="progressbar"]'; + const frames: Element[] = []; + for (const el of all) { + if (!visible(el) || el.closest('#layers [data-testid="mask"]') || el.matches('[data-testid="mask"]')) continue; + if (!framed(el)) continue; + frames.push(el); + const content = (el.textContent ?? '').trim() || [...el.querySelectorAll(MEDIA)].some(m => visible(m)) || [...el.querySelectorAll('*')].some(c => cs(c).backgroundImage !== 'none' && visible(c)); + if (!content) add('I6', el, `empty frame ${rectOf(el)[2]}x${rectOf(el)[3]} ${cs(el).backgroundColor}`, rectOf(el)[3]); + } + for (const el of frames) { + const fill = cs(el).backgroundColor; + const column = el.querySelector('[data-testid="sidebarColumn"], [data-testid="primaryColumn"]'); + const inner = frames.find(f => f !== el && el.contains(f) && cs(f).backgroundColor === fill && parse(fill)![3] > 0 && /solid|inset/.test(cs(f).borderTopStyle + cs(f).boxShadow)); + if (column) add('I7', el, `frame holds the ${column.getAttribute('data-testid')}`); + else if (inner) add('I7', el, `frame holds a frame of the same fill (${sel(inner)})`); + } + + // I4 bar geometry and I10 targets + const nav = doc.querySelector('header[role="banner"] nav[role="navigation"]'); + if (nav && visible(nav)) { + const items = [...nav.children].filter(c => visible(c)) as HTMLElement[]; + const rects = items.map(i => i.getBoundingClientRect()); + const horizontal = rects.length > 2 && rects.every(r => Math.abs(r.top - rects[0].top) < 6); + notes.bar = horizontal ? 'horizontal' : 'rail'; + const labelled = items.some(i => [...i.querySelectorAll('span, div')].some(s => ownText(s) && visible(s))); + if (horizontal) { + notes.barMode = labelled ? 'labels' : 'icons'; + const widths = rects.map(r => r.width); + if (labelled && Math.max(...widths) - Math.min(...widths) > 2) add('I4', nav, `item widths ${Math.round(Math.min(...widths))}..${Math.round(Math.max(...widths))}px`, Math.max(...widths) - Math.min(...widths)); + const sorted = [...rects].sort((a, b) => a.left - b.left); + const unit = Math.max(...widths); + for (let i = 1; i < sorted.length; i++) { const gap = sorted[i].left - sorted[i - 1].right; if (gap > unit + 1) { add('I4', nav, `gap ${Math.round(gap)}px > one item ${Math.round(unit)}px`, gap); break; } } + } + for (const [i, item] of items.entries()) { + const r = rects[i]; const x = r.left + r.width / 2, y = r.top + r.height / 2; + if (x < 0 || y < 0 || x > vw || y > vh) { add('I4', item, 'item off screen'); continue; } + const hit = doc.elementFromPoint(x, y); + if (!hit || !(item === hit || item.contains(hit))) add('I4', item, `item not hit-testable (under ${hit ? sel(hit) : 'nothing'})`); + } + } + const minTarget = high ? 44 : 24; + const TARGET_CONTEXT = 'header[role="banner"] nav[role="navigation"] > *, header[role="banner"] [data-testid="SideNav_NewTweet_Button"], header[role="banner"] [data-testid="SideNav_AccountSwitcher_Button"], article [role="group"] button, article [role="group"] a, [data-testid="toolBar"] button, [data-testid="toolBar"] [role="button"]'; + for (const el of doc.querySelectorAll(TARGET_CONTEXT)) { + if (!visible(el)) continue; + const r = el.getBoundingClientRect(); + if (r.width < minTarget - 0.5 || r.height < minTarget - 0.5) add('I10', el, `${Math.round(r.width)}x${Math.round(r.height)} < ${minTarget}`, Math.min(r.width, r.height)); + } + + // I8 popover bounds + const root = cs(doc.documentElement); + const insetTop = parseFloat(root.getPropertyValue('--wb-bar-inset-top')) || 0, insetBottom = parseFloat(root.getPropertyValue('--wb-bar-inset-bottom')) || 0; + for (const el of doc.querySelectorAll('[data-testid="hoverCardParent"], [role="menu"], [role="listbox"], [data-testid="HoverCard"], [data-testid="Dropdown"], [data-testid="HoverLabel"]')) { + if (!visible(el) && !(el.getBoundingClientRect().width > 1 && !hidden(el))) continue; + if (el.parentElement?.closest('[data-testid="hoverCardParent"], [role="menu"], [data-testid="HoverCard"]')) continue; + const r = el.getBoundingClientRect(); + if (r.width < 2 || r.height < 2) continue; + const out = Math.max(-r.left, -(r.top - insetTop), r.right - vw, r.bottom - (vh - insetBottom)); + if (out > 1) add('I8', el, `${Math.round(out)}px outside the viewport`, out); + } + + return { counts, findings, notes, viewport: [vw, vh] }; +} From 158d8cdfa2843b8534ca79bb70a74cfd9f1d9023 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 05:00:43 +0700 Subject: [PATCH 06/12] Theme engine v3 step 5: SurfaceSet, the shell chrome reads it themeDerive.ts: canvas, content, card, chrome, raised, panel, strong, accentFill and tooltip, each with ink, inkSoft, link, line, hover, selected and focus solved on its fill (both gradient ends). Hover and selected step back toward the fill until every ink still reads on them; hover is neutral unless the theme sets one (no accent wash); a strong gradient's end moves until one ink reads on both; targets no ink can reach on a mid-tone fill are capped and noted. 5,000-palette property test. chromeTheme.ts: legible() is the solver; the bevel title bar, selection and face inks come from the strong and chrome surfaces. Chrome vars of the 13 built-ins: only the faint/warn/danger inks move (closer to the theme colour, still 4.5:1). Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeDerive.ts | 138 +++++++++++++++++++++ packages/runtime/src/themeInk.ts | 8 ++ packages/runtime/tests/themeDerive.test.ts | 57 +++++++++ packages/shell/src/frame/chromeTheme.ts | 30 +++-- 4 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 packages/runtime/src/themeDerive.ts create mode 100644 packages/runtime/tests/themeDerive.test.ts diff --git a/packages/runtime/src/themeDerive.ts b/packages/runtime/src/themeDerive.ts new file mode 100644 index 0000000..1d51cab --- /dev/null +++ b/packages/runtime/src/themeDerive.ts @@ -0,0 +1,138 @@ +// Theme engine v3, step 5 (.omc/plans/theme-engine-v3.md 3.2): every surface the site and the shell +// paint, derived from a theme's tokens, with every ink solved for the fill it sits on (themeInk.ts). +// Pure. Consumers ask "what is the ink on the chrome", never "which palette role reads here". +// +// The input is the v3 palette shape; v2 documents reach it through `surfacesInputFromV2` until step 13 +// makes v3 the stored format. + +import { contrastRatio, relativeLuminance } from './themeApi.ts'; +import { inkOn, inkOnEvery, mixOklab, TARGET } from './themeInk.ts'; + +export const SURFACE_NAMES = ['canvas', 'content', 'card', 'chrome', 'raised', 'panel', 'strong', 'accentFill', 'tooltip'] as const; +export type SurfaceName = typeof SURFACE_NAMES[number]; +export type EdgeStyle = 'flat' | 'hairline' | 'outline' | 'bevel' | 'gloss'; + +export type DerivePalette = { + scheme?: 'light' | 'dark'; + page?: string | null; bg: string; surface: string; elevated: string; face?: string | null; + border: string; text: string; muted: string; accent: string; + inverse?: string | null; inverse2?: string | null; onInverse?: string | null; + hover?: string | null; selected?: string | null; +}; +export type DeriveInput = { + palette: DerivePalette; + edge?: { style?: EdgeStyle; strong?: boolean }; + access?: 'standard' | 'high'; + post?: { style?: 'flat' | 'cards' | 'bubbles' | 'compact' | 'broadsheet' }; +}; + +export type Surface = { + /** The fill (`fill2`: the far end of a gradient fill; inks read on both). */ + fill: string; fill2?: string; + ink: string; inkSoft: string; link: string; line: string; + hover: string; selected: string; focus: string; +}; +export type SurfaceSet = Record & { scheme: 'light' | 'dark'; edge: EdgeStyle; notes: string[] }; + +const isDarkFill = (hex: string) => relativeLuminance(hex) < 0.18; + +/** The best ratio any ink can reach on every one of `fills` (black or white on the worst of them). */ +export const reachable = (fills: string[]) => Math.max(...['#000000', '#ffffff'].map(pole => Math.min(...fills.map(fill => contrastRatio(pole, fill))))); + +/** + * One surface. Inks are solved on the fill (both ends of a gradient); the hover and selected states are + * then pulled back toward the fill until every ink still reads on them, so a hovered or selected row + * never costs contrast. A target no ink can reach on this fill (7:1 on a mid tone) is capped at the + * best there is and noted. + */ +function surface(name: SurfaceName, fill: string, p: DerivePalette, input: DeriveInput, notes: string[], opts: { fill2?: string; inkPreferred?: string; hover?: string | null; selected?: string | null } = {}): Surface { + const fills = opts.fill2 ? [fill, opts.fill2] : [fill]; + const best = reachable(fills); + const cap = (target: number, what: string) => { + if (best >= target) return target; + notes.push(`${name}.${what}: ${target}:1 is out of reach on ${fills.join('/')}, ${best.toFixed(2)}:1 at most`); + return best; + }; + const textTarget = cap(input.access === 'high' ? TARGET.high : TARGET.text, 'ink'); + const softTarget = cap(TARGET.text, 'inkSoft'); + const solve = (preferred: string, target: number, what: string) => { + const result = inkOnEvery(fills, preferred, target); + if (result.adjusted) notes.push(`${name}.${what} ${preferred} -> ${result.ink} (${result.ratio.toFixed(2)}:1)`); + return result.ink; + }; + const ink = solve(opts.inkPreferred ?? p.text, textTarget, 'ink'); + const inkSoft = solve(p.muted, softTarget, 'inkSoft'); + const link = solve(p.accent, softTarget, 'link'); + const reads = (state: string) => contrastRatio(ink, state) >= textTarget - 1e-9 && contrastRatio(inkSoft, state) >= softTarget - 1e-9 && contrastRatio(link, state) >= softTarget - 1e-9; + /** A state `t` of the way from the fill to `toward`, shrunk until the inks read on it. */ + const state = (toward: string, t: number, what: string) => { + for (let k = t; k > 0.004; k /= 2) { const candidate = mixOklab(fill, toward, k); if (reads(candidate)) { if (k !== t) notes.push(`${name}.${what} step ${t} -> ${k.toFixed(3)}`); return candidate; } } + return fill; + }; + const dark = isDarkFill(fill); + // States: the theme's own hover/selected when they keep every ink readable, else a neutral step toward the ink (never the accent hue for hover). + const hover = opts.hover && reads(opts.hover) ? opts.hover : state(ink, dark ? 0.08 : 0.05, 'hover'); + const selected = opts.selected && reads(opts.selected) ? opts.selected : state(p.accent, 0.14, 'selected'); + const strongLine = input.edge?.strong || input.access === 'high'; + const lineTarget = Math.min(best, strongLine || input.edge?.style === 'outline' ? TARGET.nonText : 1.3); + return { + fill, ...(opts.fill2 ? { fill2: opts.fill2 } : {}), + ink, inkSoft, link, + line: strongLine ? ink : inkOnEvery(fills, p.border, lineTarget).ink, + hover, selected, focus: inkOnEvery(fills, p.accent, Math.min(best, TARGET.nonText)).ink, + }; +} + +/** Every surface of a theme. Notes list each ink the solver had to move. */ +export function deriveSurfaces(input: DeriveInput): SurfaceSet { + const p = input.palette; + const notes: string[] = []; + const scheme = p.scheme ?? (relativeLuminance(p.bg) < relativeLuminance(p.text) ? 'dark' : 'light'); + const edge = input.edge?.style ?? 'hairline'; + const face = p.face ?? (edge === 'bevel' ? mixOklab(p.surface, p.border, 0.2) : p.surface); + const cards = input.post?.style === 'cards' || input.post?.style === 'bubbles'; + // The strong fill (title bars, inverted buttons): its gradient end moves until one ink reads on both ends. + const inverse = p.inverse ?? p.text; + let inverse2 = p.inverse2 ?? inverse; + const strongPreferred = p.onInverse ?? p.bg; + if (inverse2 !== inverse) { + const both = inkOnEvery([inverse, inverse2], strongPreferred, TARGET.text); + if (both.ratio < TARGET.text) { + const ink = inkOn(inverse, strongPreferred, TARGET.text).ink; + const moved = inkOn(ink, inverse2, TARGET.text).ink; + notes.push(`strong.fill2 ${inverse2} -> ${moved} (one ink on both ends)`); + inverse2 = moved; + } + } + // The accent fill's label: white or the page colour, whichever reads better, then solved. + const accentLabel = contrastRatio('#ffffff', p.accent) >= contrastRatio(p.bg, p.accent) ? '#ffffff' : p.bg; + const tooltipFill = mixOklab(p.text, p.bg, 0.12); + const set = { + canvas: surface('canvas', p.page ?? p.bg, p, input, notes), + content: surface('content', p.bg, p, input, notes, { hover: p.hover, selected: p.selected }), + card: surface('card', cards ? p.surface : p.bg, p, input, notes, { hover: p.hover, selected: p.selected }), + chrome: surface('chrome', face, p, input, notes, { hover: p.hover, selected: p.selected }), + raised: surface('raised', p.elevated, p, input, notes), + panel: surface('panel', p.bg, p, input, notes, { hover: p.hover, selected: p.selected }), + strong: surface('strong', inverse, p, input, notes, { ...(inverse2 !== inverse ? { fill2: inverse2 } : {}), inkPreferred: strongPreferred }), + accentFill: surface('accentFill', p.accent, p, input, notes, { inkPreferred: accentLabel }), + tooltip: surface('tooltip', tooltipFill, p, input, notes, { inkPreferred: p.bg }), + } satisfies Record; + return { ...set, scheme, edge, notes }; +} + +/** The v2 palette and component hints as v3 derivation input (until the documents are v3, step 13). */ +export function surfacesInputFromV2(colors: { bg: string; surface: string; elevated: string; border: string; text: string; muted: string; accent: string; page?: string | null; inverse?: string | null; onInverse?: string | null }, hints: { bevel?: boolean; high?: boolean; cards?: boolean } = {}): DeriveInput { + return { + palette: { + page: colors.page ?? null, bg: colors.bg, surface: colors.surface, elevated: colors.elevated, border: colors.border, + text: colors.text, muted: colors.muted, accent: colors.accent, inverse: colors.inverse ?? null, onInverse: colors.onInverse ?? null, + // A bevelled v2 theme draws its chrome from `page` (Windows 98: the silver face) and its title bar from inverse to accent. + face: hints.bevel ? colors.page ?? colors.elevated : null, + inverse2: hints.bevel ? colors.accent : null, + }, + edge: { style: hints.bevel ? 'bevel' : 'hairline' }, + access: hints.high ? 'high' : 'standard', + post: { style: hints.cards ? 'cards' : 'flat' }, + }; +} diff --git a/packages/runtime/src/themeInk.ts b/packages/runtime/src/themeInk.ts index 6c2c8b3..092a3b8 100644 --- a/packages/runtime/src/themeInk.ts +++ b/packages/runtime/src/themeInk.ts @@ -79,6 +79,14 @@ export function composite(fill: string, over: string): string { return `#${top.map((v, i) => Math.round(v * alpha + bottom[i]! * (1 - alpha)).toString(16).padStart(2, '0')).join('')}`; } +/** `a` moved `t` of the way to `b` in OKLab (perceptually even; states and tints). */ +export function mixOklab(a: string, b: string, t: number): string { + const x = hexToOklch(a), y = hexToOklch(b); + const ax = x.c * Math.cos(x.h), bx = x.c * Math.sin(x.h), ay = y.c * Math.cos(y.h), by = y.c * Math.sin(y.h); + const A = ax + (ay - ax) * t, B = bx + (by - bx) * t; + return oklchToHex({ l: x.l + (y.l - x.l) * t, c: Math.hypot(A, B), h: Math.atan2(B, A) }); +} + // --------------------------------------------------------------------------------------------- // The solver diff --git a/packages/runtime/tests/themeDerive.test.ts b/packages/runtime/tests/themeDerive.test.ts new file mode 100644 index 0000000..794318c --- /dev/null +++ b/packages/runtime/tests/themeDerive.test.ts @@ -0,0 +1,57 @@ +// Theme engine v3, step 5: every surface and every role meets its target, for built-in-like and random palettes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { contrastRatio } from '../src/themeApi.ts'; +import { deriveSurfaces, reachable, surfacesInputFromV2, SURFACE_NAMES, type DeriveInput } from '../src/themeDerive.ts'; +import { hexToOklch } from '../src/themeInk.ts'; + +function rng(seed: number) { + return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} +const randomHex = (r: () => number) => `#${Array.from({ length: 3 }, () => Math.floor(r() * 256).toString(16).padStart(2, '0')).join('')}`; + +/** Every role of every surface against the fill(s) and states it sits on. */ +function assertTargets(input: DeriveInput, label: string) { + const set = deriveSurfaces(input); + for (const name of SURFACE_NAMES) { + const s = set[name]; + const grounds = [s.fill, ...(s.fill2 ? [s.fill2] : [])]; + // The target, or the best any ink can do on this fill (a mid tone has no 7:1 ink; the deriver notes it). + const best = reachable(grounds); + const text = Math.min(best, input.access === 'high' ? 7 : 4.5); + const soft = Math.min(best, 4.5); + for (const g of [...grounds, s.hover, s.selected]) { + assert.ok(contrastRatio(s.ink, g) >= text - 1e-9, `${label} ${name}.ink ${s.ink} on ${g}`); + assert.ok(contrastRatio(s.inkSoft, g) >= soft - 1e-9, `${label} ${name}.inkSoft on ${g}`); + assert.ok(contrastRatio(s.link, g) >= soft - 1e-9, `${label} ${name}.link on ${g}`); + } + for (const g of grounds) assert.ok(contrastRatio(s.focus, g) >= Math.min(best, 3) - 1e-9, `${label} ${name}.focus on ${g}`); + // A hover state is a visible step but never a different colour family. + const step = contrastRatio(s.hover, s.fill); + if (!input.palette.hover) assert.ok(step <= 1.6, `${label} ${name}.hover step ${step.toFixed(3)}`); + } + return set; +} + +test('Windows 98: silver chrome, navy-to-blue title bar with one readable ink, silver hover', () => { + const set = assertTargets(surfacesInputFromV2({ page: '#c0c0c0', bg: '#ffffff', surface: '#f4f4f4', elevated: '#dfdfdf', border: '#808080', text: '#000000', muted: '#3c3c3c', accent: '#0000a8', inverse: '#000080', onInverse: '#ffffff' }, { bevel: true }), 'w98'); + assert.equal(set.chrome.fill, '#c0c0c0'); + assert.equal(set.strong.fill, '#000080'); + assert.ok(contrastRatio(set.strong.ink, set.strong.fill2 ?? set.strong.fill) >= 4.5); + assert.equal(set.edge, 'bevel'); +}); + +test('the content hover is never an accent wash (the 1.0.2 lavender)', () => { + const set = deriveSurfaces(surfacesInputFromV2({ bg: '#ffffff', surface: '#f4f4f4', elevated: '#dfdfdf', border: '#808080', text: '#000000', muted: '#3c3c3c', accent: '#0000a8' })); + const hover = hexToOklch(set.content.hover); + assert.ok(hover.c < 0.01, `hover ${set.content.hover} has chroma ${hover.c.toFixed(3)}`); +}); + +test('5,000 random palettes: every surface and role meets its target (standard and high access)', () => { + const r = rng(5000); + for (let i = 0; i < 5000; i++) { + const bg = randomHex(r); + const palette = { bg, surface: randomHex(r), elevated: randomHex(r), border: randomHex(r), text: randomHex(r), muted: randomHex(r), accent: randomHex(r), page: randomHex(r), inverse: randomHex(r), inverse2: r() < 0.5 ? randomHex(r) : null }; + assertTargets({ palette, edge: { style: r() < 0.3 ? 'bevel' : 'hairline' }, access: i % 5 === 0 ? 'high' : 'standard' }, `#${i}`); + } +}); diff --git a/packages/shell/src/frame/chromeTheme.ts b/packages/shell/src/frame/chromeTheme.ts index aedad7b..d93b6b6 100644 --- a/packages/shell/src/frame/chromeTheme.ts +++ b/packages/shell/src/frame/chromeTheme.ts @@ -2,6 +2,10 @@ // the shell's `--wb-c-*` (tabs, address bar, titlebar, menus) and the panel's `--wb-p-*` tokens, // which shell.css (and the app's panel CSS) read with Night Ink as the fallback. Pure (tests, // shell, panel). The app turns its theme documents into a `ChromeTheme` (site.themes.chrome). +// Inks come from the engine's contrast solver and surfaces (theme engine v3, steps 4-5). + +import { deriveSurfaces, surfacesInputFromV2, type SurfaceSet } from '../../../runtime/src/themeDerive.ts'; +import { inkOn } from '../../../runtime/src/themeInk.ts'; /** The shell's own interface font (what an inheriting chrome role falls back to outside the page). */ export const SHELL_UI_FONT = "'Geist Variable', system-ui, sans-serif"; @@ -45,14 +49,14 @@ export function mix(a: string, b: string, t: number): string { const channels = (hex: string) => hexToRgb(hex).join(' '); -/** Moves `color` toward `pole` until it reads at `ratio` on `surface`. */ -function legible(color: string, surface: string, ratio: number): string { - if (contrast(color, surface) >= ratio) return color; - const pole = luminance(surface) > 0.18 ? '#000000' : '#ffffff'; - for (let t = 0.1; t <= 1; t += 0.1) { const next = mix(color, pole, t); if (contrast(next, surface) >= ratio) return next; } - return pole; +/** The theme's surfaces (theme engine v3, themeDerive.ts): the chrome takes its title bar, face and selection from them. */ +export function chromeSurfaces(theme: ChromeTheme): SurfaceSet | null { + return theme.colors ? deriveSurfaces(surfacesInputFromV2(theme.colors, { bevel: theme.edges === 'bevel' })) : null; } +/** The colour closest to `color` that reads at `ratio` on `surface` (the engine's solver, themeInk.ts). */ +const legible = (color: string, surface: string, ratio: number) => inkOn(surface, color, ratio).ink; + /** * CSS custom properties for the chrome in `theme`. A theme that keeps the site's colors changes * only the fonts (and keeps wayBetter's own corners and edges); `own` keeps wayBetter's own style @@ -107,19 +111,19 @@ export function chromeVars(theme: ChromeTheme, own = false): Record): Record { - const face = colors.page ?? colors.elevated; - const from = colors.inverse ?? colors.accent; - const titleText = colors.onInverse && contrast(colors.onInverse, from) >= 4.5 && contrast(colors.onInverse, colors.accent) >= 3 ? colors.onInverse - : contrast('#ffffff', from) >= contrast('#000000', from) ? '#ffffff' : '#000000'; + const set = deriveSurfaces(surfacesInputFromV2(colors, { bevel: true })); + const face = set.chrome.fill; + const strong = set.strong; return { '--wb-c-edges': 'bevel', '--wb-c-face': face, '--wb-c-face-text': legible(colors.text, face, 7), '--wb-c-bevel-hi': mix(face, '#ffffff', 1), '--wb-c-bevel-lt': mix(face, '#ffffff', 0.49), '--wb-c-bevel-sh': mix(face, '#000000', 0.335), '--wb-c-bevel-dk': mix(face, '#000000', 0.95), - '--wb-c-title-from': from, '--wb-c-title-to': colors.accent, '--wb-c-title-text': titleText, + // The active title bar is the strong surface: inverse to its gradient end, one ink readable on both. + '--wb-c-title-from': strong.fill, '--wb-c-title-to': strong.fill2 ?? strong.fill, '--wb-c-title-text': strong.ink, // Selected rows (menus, lists): the title bar's own color, as in a 98 menu. - '--wb-c-select': from, '--wb-c-select-text': titleText, + '--wb-c-select': strong.fill, '--wb-c-select-text': strong.ink, // Menus are the face: status inks are measured on it. - '--wb-c-muted': legible(colors.muted, face, 4.5), '--wb-c-faint': legible(colors.muted, face, 4.5), + '--wb-c-muted': set.chrome.inkSoft, '--wb-c-faint': set.chrome.inkSoft, '--wb-c-warn': legible('#806000', face, 4.5), '--wb-c-danger-ink': legible('#a00000', face, 4.5), }; } From 27952d60a8c201410cf668e59e5323d2a69ee441 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 05:46:52 +0700 Subject: [PATCH 07/12] Theme engine v3 step 8: the layer guard themeGuards.ts: watches the site's layer container; every new popover (and one that the site moves or that grows) is measured in an idle callback and shifted into the viewport minus the bar insets with a translate (data-wb-clamped, --wb-clamp-x/y); taller than the viewport it scrolls. Bounded: only nodes that hold a popover are measured, at most 4 per pass; a moved popover is seen through its own style attribute and a ResizeObserver. clampShift/viewportBox are pure and unit tested. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeGuards.ts | 140 +++++++++++++++++++++ packages/runtime/tests/themeGuards.test.ts | 27 ++++ 2 files changed, 167 insertions(+) create mode 100644 packages/runtime/src/themeGuards.ts create mode 100644 packages/runtime/tests/themeGuards.test.ts diff --git a/packages/runtime/src/themeGuards.ts b/packages/runtime/src/themeGuards.ts new file mode 100644 index 0000000..e85f8ce --- /dev/null +++ b/packages/runtime/src/themeGuards.ts @@ -0,0 +1,140 @@ +// Theme engine v3, step 8 (.omc/plans/theme-engine-v3.md 3.5, 4.2): the layer guard. A site positions +// its popovers (menus, hover cards, tooltips) from an anchor; a theme that moves the anchor (a bottom +// taskbar, a top bar) moves them past the window edge. Static CSS cannot see where a portal lands, so +// this guard measures each new layer root once and shifts it back inside the viewport minus the bar +// insets. Bounded: it watches only the layer container's children (no subtree attribute observation +// of the page), measures in an idle callback, at most a few layers per frame, never timeline cells. + +/** The viewport box popovers must stay in. */ +export type Box = { left: number; top: number; right: number; bottom: number }; +export type Rect = { left: number; top: number; width: number; height: number }; + +/** Attribute on a shifted layer (a static rule hides its caret, which points at the old anchor). */ +export const CLAMPED_ATTR = 'data-wb-clamped'; +export const CLAMP_X = '--wb-clamp-x'; +export const CLAMP_Y = '--wb-clamp-y'; + +/** + * Pure: the shift that brings `rect` inside `box`. A rect larger than the box keeps its start edge + * inside (left/top) and reports `overflowY`, so the caller can cap its height and let it scroll. + */ +export function clampShift(rect: Rect, box: Box): { dx: number; dy: number; overflowY: boolean } { + const shift = (start: number, size: number, min: number, max: number) => { + if (size >= max - min) return min - start; + if (start < min) return min - start; + if (start + size > max) return max - (start + size); + return 0; + }; + return { + dx: Math.round(shift(rect.left, rect.width, box.left, box.right)), + dy: Math.round(shift(rect.top, rect.height, box.top, box.bottom)), + overflowY: rect.height > box.bottom - box.top, + }; +} + +/** The box: the viewport less `margin` on every side and the bar insets top and bottom. */ +export function viewportBox(width: number, height: number, insets: { top: number; bottom: number }, margin = 8): Box { + return { left: margin, top: margin + insets.top, right: width - margin, bottom: height - margin - insets.bottom }; +} + +/** The static rules the guard relies on (part of the theme sheet). */ +export function layerGuardCss(): string { + return `[${CLAMPED_ATTR}]{translate:var(${CLAMP_X},0px) var(${CLAMP_Y},0px) !important}\n[${CLAMPED_ATTR}="scroll"]{max-height:var(--wb-clamp-h) !important;overflow-y:auto !important}`; +} + +export type LayerGuardOptions = { + /** The container the site renders its portals into (X: `#layers`). */ + root: string; + /** Inside a new layer, the element that is positioned and should be shifted (first match wins). */ + targets: string[]; + /** Bar insets in px (read from the theme's `--wb-bar-inset-top/bottom` by default). */ + insets?: () => { top: number; bottom: number }; +}; + +const px = (value: string) => { const n = parseFloat(value); return Number.isFinite(n) ? n : 0; }; + +/** Watches the layer root; returns a stop function. Off in safe mode (the host never installs it then). */ +export function installLayerGuard(win: Window, options: LayerGuardOptions): () => void { + const doc = win.document; + const insets = options.insets ?? (() => { + const style = win.getComputedStyle(doc.documentElement); + return { top: px(style.getPropertyValue('--wb-bar-inset-top')), bottom: px(style.getPropertyValue('--wb-bar-inset-bottom')) }; + }); + const selector = options.targets.join(','); + const pending = new Set(); + let scheduled = false; + const measure = () => { + scheduled = false; + let budget = 4; + for (const layer of [...pending]) { + if (budget-- <= 0) break; + pending.delete(layer); + if (!layer.isConnected) continue; + for (const target of layer.matches(selector) ? [layer] : [...layer.querySelectorAll(selector)].slice(0, 2)) clamp(target as HTMLElement); + } + if (pending.size) schedule(); + }; + // A measured popover is watched for the site moving it again (its inline position): only its own + // style attribute, and only position changes count (our own clamp variables change it too). + const placed = new WeakMap(); + const position = (el: HTMLElement) => `${el.style.left}|${el.style.top}|${el.style.right}|${el.style.bottom}|${el.style.transform}`; + const Observer = (win as Window & { MutationObserver: typeof MutationObserver }).MutationObserver; + const moved = new Observer((records: MutationRecord[]) => { + for (const record of records) { + const el = record.target as HTMLElement; + if (el.isConnected && placed.get(el) !== position(el)) { pending.add(el); schedule(); } + } + }); + // A popover that grows (a theme's font, content loading in) is measured again too. + const Resize = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + const sized = Resize ? new Resize(entries => { for (const entry of entries) { const el = entry.target as HTMLElement; if (el.isConnected) { pending.add(el); schedule(); } } }) : null; + const clamp = (el: HTMLElement) => { + if (!placed.has(el)) { moved.observe(el, { attributes: true, attributeFilter: ['style'] }); sized?.observe(el); } + placed.set(el, position(el)); + // Measure without our own shift. + el.style.removeProperty(CLAMP_X); el.style.removeProperty(CLAMP_Y); + const r = el.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return; + const box = viewportBox(win.innerWidth, win.innerHeight, insets()); + const { dx, dy, overflowY } = clampShift({ left: r.left, top: r.top, width: r.width, height: r.height }, box); + if (!dx && !dy && !overflowY) { el.removeAttribute(CLAMPED_ATTR); return; } + el.style.setProperty(CLAMP_X, `${dx}px`); el.style.setProperty(CLAMP_Y, `${dy}px`); + if (overflowY) el.style.setProperty('--wb-clamp-h', `${Math.max(80, box.bottom - box.top)}px`); + el.setAttribute(CLAMPED_ATTR, overflowY ? 'scroll' : ''); + }; + const schedule = () => { + if (scheduled) return; + scheduled = true; + const idle = (win as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback; + if (idle) idle.call(win, measure, { timeout: 120 }); else win.setTimeout(measure, 16); + }; + // Only a node that is or holds a popover is worth a measurement (typing in a dialog inserts nodes too). + const queue = (node: Node | null) => { + if (!node || node.nodeType !== 1) return; + const el = node as Element; + if (!el.matches(selector) && !el.querySelector(selector)) return; + pending.add(el); schedule(); + // The site positions a portal after it mounts (a layout effect, a measured anchor): measure again + // once it has settled. Two late passes, no observer on the page's style attributes. + for (const delay of [120, 450]) win.setTimeout(() => { if (el.isConnected) { pending.add(el); schedule(); } }, delay); + }; + let observer: MutationObserver | null = null; + let layers: Element | null = null; + const attach = () => { + const found = doc.querySelector(options.root); + if (found === layers) return; + observer?.disconnect(); + layers = found; + if (!found) return; + // Children of the layer root and their children: a portal mounts its positioned box a level or two down. + const next = new Observer((records: MutationRecord[]) => { for (const record of records) for (const node of record.addedNodes) queue(node.nodeType === 1 ? ((node as Element).closest(selector) ?? node) : null); }); + next.observe(found, { childList: true, subtree: true }); + observer = next; + for (const child of found.children) queue(child); + }; + attach(); + const retry = win.setInterval(attach, 2000); + const onResize = () => { if (layers) for (const child of layers.children) queue(child); }; + win.addEventListener('resize', onResize); + return () => { observer?.disconnect(); moved.disconnect(); sized?.disconnect(); win.clearInterval(retry); win.removeEventListener('resize', onResize); }; +} diff --git a/packages/runtime/tests/themeGuards.test.ts b/packages/runtime/tests/themeGuards.test.ts new file mode 100644 index 0000000..f5ca7c0 --- /dev/null +++ b/packages/runtime/tests/themeGuards.test.ts @@ -0,0 +1,27 @@ +// Theme engine v3, step 8: the layer guard's geometry (pure part). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { clampShift, viewportBox } from '../src/themeGuards.ts'; + +const box = viewportBox(2000, 1000, { top: 0, bottom: 56 }); + +test('the account menu that ran 52px past a 2000px window comes back inside, 8px from the edge', () => { + // Live 1.0.2: left 1737, width 300 in a 1985px viewport. + const shift = clampShift({ left: 1737, top: 822, width: 300, height: 108 }, viewportBox(1985, 1000, { top: 0, bottom: 56 })); + assert.equal(1737 + shift.dx + 300, 1985 - 8); + assert.equal(shift.dy, 0); +}); + +test('a hover card cut at the bottom moves up above the bar inset', () => { + const shift = clampShift({ left: 400, top: 900, width: 300, height: 200 }, box); + assert.equal(900 + shift.dy + 200, 1000 - 8 - 56); +}); + +test('inside stays put; taller than the box keeps its top edge and asks to scroll', () => { + assert.deepEqual(clampShift({ left: 100, top: 100, width: 200, height: 200 }, box), { dx: 0, dy: 0, overflowY: false }); + const tall = clampShift({ left: 100, top: 300, width: 200, height: 2000 }, box); + assert.equal(300 + tall.dy, 8); + assert.ok(tall.overflowY); + const left = clampShift({ left: -40, top: 100, width: 200, height: 100 }, box); + assert.equal(-40 + left.dx, 8); +}); From 67a8dec7e24a9c77f07dab3e07da7ed149fabadc Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 05:58:34 +0700 Subject: [PATCH 08/12] Theme engine v3 step 9: the bar's label mode is measured themeBar.ts: the sum of the whole labels (each in its own computed font, via canvas measureText, so a hidden label still counts), icons, paddings and gaps against the nav's room, with 16px hysteresis; writes html[data-wb-bar=labels|icons] on nav resize, font loads and window resize. No width breakpoint decides it any more. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeBar.ts | 82 +++++++++++++++++++++++++ packages/runtime/tests/themeBar.test.ts | 22 +++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/runtime/src/themeBar.ts create mode 100644 packages/runtime/tests/themeBar.test.ts diff --git a/packages/runtime/src/themeBar.ts b/packages/runtime/src/themeBar.ts new file mode 100644 index 0000000..e54c49a --- /dev/null +++ b/packages/runtime/src/themeBar.ts @@ -0,0 +1,82 @@ +// Theme engine v3, step 9 (.omc/plans/theme-engine-v3.md 4.1): one bar component. Whether a bar's +// items carry their labels is measured, never guessed from a width breakpoint: the sum of the whole +// labels (text measured in the label's own font), icons, paddings and gaps against the room the items +// have. The mode lands on `html[data-wb-bar="labels|icons"]`; the bar's CSS keys on it. A label is +// therefore whole or absent, never cut ("H…", 1.0.2). + +export const BAR_ATTR = 'data-wb-bar'; +export type BarMode = 'labels' | 'icons'; + +/** One item as the measurer sees it: its label text and font, and the fixed parts around the label. */ +export type BarItem = { label: string; font: string; fixed: number }; + +/** + * Pure: the mode for `items` in `available` px, with `gap` between items. Hysteresis: leaving icons + * mode needs 16px more room than staying in labels mode, so a bar never flaps at the threshold. + */ +export function barMode(items: Array<{ labelWidth: number; fixed: number }>, available: number, gap: number, current: BarMode | null, hysteresis = 16): BarMode { + if (!items.length || !items.some(item => item.labelWidth > 0)) return 'labels'; + const need = items.reduce((sum, item) => sum + item.labelWidth + item.fixed, 0) + gap * Math.max(0, items.length - 1); + const room = current === 'icons' ? available - hysteresis : available; + return need <= room ? 'labels' : 'icons'; +} + +export type BarMeasurerOptions = { + /** The bar's item container (X: the nav). */ + nav: string; + /** Items inside it, and inside an item its icon box and label box. */ + item: string; icon: string; label: string; + /** Only while this matches (a bar layout is active). */ + when?: string; +}; + +const px = (value: string) => { const n = parseFloat(value); return Number.isFinite(n) ? n : 0; }; + +/** Measures on nav resize and font loads; writes the mode on . Returns a stop function. */ +export function installBarMeasurer(win: Window, options: BarMeasurerOptions): () => void { + const doc = win.document; + const canvas = doc.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const textWidth = (text: string, font: string) => { if (!ctx) return text.length * 8; ctx.font = font; return Math.ceil(ctx.measureText(text).width); }; + let nav: Element | null = null; + let frame = 0; + const measure = () => { + frame = 0; + const html = doc.documentElement; + if (options.when && !doc.querySelector(options.when)) { html.removeAttribute(BAR_ATTR); return; } + if (!nav?.isConnected) return; + const items = [...nav.querySelectorAll(`:scope > ${options.item}`)].filter(el => (el as HTMLElement).offsetParent !== null || el.getClientRects().length); + const measured = items.map(item => { + const label = item.querySelector(options.label) as HTMLElement | null; + const icon = item.querySelector(options.icon) as HTMLElement | null; + const pill = (item.firstElementChild as HTMLElement | null) ?? (item as HTMLElement); + const ps = win.getComputedStyle(pill); + const fixed = px(ps.paddingLeft) + px(ps.paddingRight) + px(ps.borderLeftWidth) + px(ps.borderRightWidth) + (icon ? Math.max(icon.getBoundingClientRect().width, 20) : 0); + if (!label || !label.textContent?.trim()) return { labelWidth: 0, fixed }; + const ls = win.getComputedStyle(label); + // The label's own font, read even while the label is display:none (computed styles resolve it). + const font = `${ls.fontStyle} ${ls.fontWeight} ${ls.fontSize} ${ls.fontFamily}`; + return { labelWidth: textWidth(label.textContent.trim(), font) + px(ls.marginLeft) + px(ls.marginRight) + 2, fixed }; + }); + const ns = win.getComputedStyle(nav); + const available = (nav as HTMLElement).clientWidth - px(ns.paddingLeft) - px(ns.paddingRight); + const current = (html.getAttribute(BAR_ATTR) as BarMode | null); + const mode = barMode(measured, available, px(ns.columnGap) || 0, current); + if (current !== mode) html.setAttribute(BAR_ATTR, mode); + }; + const schedule = () => { if (!frame) frame = win.requestAnimationFrame(measure); }; + const Resize = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + const sized = Resize ? new Resize(schedule) : null; + const attach = () => { + const found = doc.querySelector(options.nav); + if (found !== nav) { if (nav) sized?.unobserve(nav); nav = found; if (nav) sized?.observe(nav); } + schedule(); + }; + attach(); + // The nav mounts late and is replaced on some navigations; items and fonts change rarely. + const timer = win.setInterval(attach, 1500); + const fonts = (doc as Document & { fonts?: FontFaceSet }).fonts; + fonts?.addEventListener?.('loadingdone', schedule); + win.addEventListener('resize', schedule); + return () => { win.clearInterval(timer); sized?.disconnect(); fonts?.removeEventListener?.('loadingdone', schedule); win.removeEventListener('resize', schedule); if (frame) win.cancelAnimationFrame(frame); doc.documentElement.removeAttribute(BAR_ATTR); }; +} diff --git a/packages/runtime/tests/themeBar.test.ts b/packages/runtime/tests/themeBar.test.ts new file mode 100644 index 0000000..37674b0 --- /dev/null +++ b/packages/runtime/tests/themeBar.test.ts @@ -0,0 +1,22 @@ +// Theme engine v3, step 9: the bar's label mode is measured, with hysteresis. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { barMode } from '../src/themeBar.ts'; + +const twelve = Array.from({ length: 12 }, (_, i) => ({ labelWidth: [36, 50, 84, 34, 30, 54, 48, 92, 54, 44, 64, 34][i]!, fixed: 16 + 24 })); +const need = twelve.reduce((s, it) => s + it.labelWidth + it.fixed, 0) + 2 * 11; + +test('whole labels when they fit, icons when they do not; never a cut label', () => { + assert.equal(barMode(twelve, need, 2, null), 'labels'); + assert.equal(barMode(twelve, need - 1, 2, null), 'icons'); +}); + +test('hysteresis: icons mode needs 16px more room to switch back, so the bar does not flap', () => { + assert.equal(barMode(twelve, need + 10, 2, 'icons'), 'icons'); + assert.equal(barMode(twelve, need + 16, 2, 'icons'), 'labels'); + assert.equal(barMode(twelve, need + 10, 2, 'labels'), 'labels'); +}); + +test('items without labels (X drew icons only) are always "labels" mode: nothing to hide', () => { + assert.equal(barMode(twelve.map(it => ({ ...it, labelWidth: 0 })), 100, 2, null), 'labels'); +}); From 90ae2047d8c6cbb62251500b88af7671b3391d7b Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 06:02:30 +0700 Subject: [PATCH 09/12] themeInvariants: controls are not empty frames (I6); a layer over the bar is not a bar hit-test failure (I4) Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeInvariants.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/themeInvariants.ts b/packages/runtime/src/themeInvariants.ts index cf5877e..085e070 100644 --- a/packages/runtime/src/themeInvariants.ts +++ b/packages/runtime/src/themeInvariants.ts @@ -168,7 +168,7 @@ export function runInvariants(doc: Document = document, opts: InvariantOptions = const frames: Element[] = []; for (const el of all) { if (!visible(el) || el.closest('#layers [data-testid="mask"]') || el.matches('[data-testid="mask"]')) continue; - if (!framed(el)) continue; + if (el.matches(MEDIA) || !framed(el)) continue; frames.push(el); const content = (el.textContent ?? '').trim() || [...el.querySelectorAll(MEDIA)].some(m => visible(m)) || [...el.querySelectorAll('*')].some(c => cs(c).backgroundImage !== 'none' && visible(c)); if (!content) add('I6', el, `empty frame ${rectOf(el)[2]}x${rectOf(el)[3]} ${cs(el).backgroundColor}`, rectOf(el)[3]); @@ -176,7 +176,8 @@ export function runInvariants(doc: Document = document, opts: InvariantOptions = for (const el of frames) { const fill = cs(el).backgroundColor; const column = el.querySelector('[data-testid="sidebarColumn"], [data-testid="primaryColumn"]'); - const inner = frames.find(f => f !== el && el.contains(f) && cs(f).backgroundColor === fill && parse(fill)![3] > 0 && /solid|inset/.test(cs(f).borderTopStyle + cs(f).boxShadow)); + // Controls (a bevelled button on a bevelled bar, a pill in a card) are not frames: only boxes nest wrongly. + const inner = frames.find(f => f !== el && el.contains(f) && !f.closest('a, button, [role="button"], [role="link"], [role="tab"], input, label') && cs(f).backgroundColor === fill && parse(fill)![3] > 0 && /solid|inset/.test(cs(f).borderTopStyle + cs(f).boxShadow)); if (column) add('I7', el, `frame holds the ${column.getAttribute('data-testid')}`); else if (inner) add('I7', el, `frame holds a frame of the same fill (${sel(inner)})`); } @@ -201,6 +202,8 @@ export function runInvariants(doc: Document = document, opts: InvariantOptions = const r = rects[i]; const x = r.left + r.width / 2, y = r.top + r.height / 2; if (x < 0 || y < 0 || x > vw || y > vh) { add('I4', item, 'item off screen'); continue; } const hit = doc.elementFromPoint(x, y); + // A layer over the bar (modal mask, menu, hover card) is I8's business, not the bar's. + if (hit?.closest('#layers')) continue; if (!hit || !(item === hit || item.contains(hit))) add('I4', item, `item not hit-testable (under ${hit ? sel(hit) : 'nothing'})`); } } From e3fe5820df22d94addb9840910c96be94549a620 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 06:11:38 +0700 Subject: [PATCH 10/12] host: no opener link handler in webviews (target=_blank clicks in site tabs were cancelled and never opened) Co-Authored-By: Claude Opus 5.5 --- crates/waybetter-host/src/app.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/waybetter-host/src/app.rs b/crates/waybetter-host/src/app.rs index 3a4249d..f91d0b6 100644 --- a/crates/waybetter-host/src/app.rs +++ b/crates/waybetter-host/src/app.rs @@ -1424,7 +1424,10 @@ impl App { let builder = if config.deep_link { builder.plugin(tauri_plugin_deep_link::init()) } else { builder }; let builder = builder .plugin(tauri_plugin_window_state::Builder::default().with_state_flags(window_state_flags()).build()) - .plugin(tauri_plugin_opener::init()); + // No injected link handler: in a site tab it cancels every target=_blank click and asks for + // `plugin:opener|open_url`, which tabs may not call, so nothing opened. Native code routes + // new-window requests itself (`on_new_window`); the shell opens links through its commands. + .plugin(tauri_plugin_opener::Builder::new().open_js_links_on_click(false).build()); // No capability grants the updater/process JS APIs; only Rust drives them. let builder = if config.updater { builder.plugin(tauri_plugin_updater::Builder::new().build()) } else { builder }; builder From 4eaaa31fb1bccec608e7133775f404de51308b9c Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 06:14:15 +0700 Subject: [PATCH 11/12] Theme engine v3: component inks solved on the grounds they sit on A part's text role is still swapped for a readable one on its own fill, and its ink is then solved with inkOn: on its fill, or with no fill of its own on every surface it can sit on, the page canvas included (Solarized Light's outline Post button in the rail: accent on the page read 4.45:1). Roles made for a parent's fill (onAccent, onInverse, bg) stand. Invariant suite: Solarized Light I1 107 -> 0. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeComponents.ts | 17 +++++++++++++++-- packages/runtime/tests/themeStructure.test.ts | 5 ++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/themeComponents.ts b/packages/runtime/src/themeComponents.ts index 340a518..750a51a 100644 --- a/packages/runtime/src/themeComponents.ts +++ b/packages/runtime/src/themeComponents.ts @@ -15,6 +15,7 @@ // The bevel borders (`bevel-out`, `bevel-in`) follow 98.css's raised and sunken edges (MIT, Jordan // Scales, https://github.com/jdan/98.css): four inset shadows, here computed from the theme's roles. +import { inkOnEvery } from './themeInk.ts'; import { contrastRatio, hexToRgb, mix, relativeLuminance, type FontRoleName } from './themeApi.ts'; // --------------------------------------------------------------------------------------------- @@ -390,15 +391,27 @@ export function compileComponents(components: ThemeComponents, parts: PartMap, c decl.forEach(([property, value], index) => put(part.fill ? fillRule : box, `fill${index ? `-${index}` : ''}`, property, value)); } let textRole = has('text') ? tokens.text! : undefined; + const need = def.contrast ?? 4.5; if (face && (def.text || textRole)) { - const need = def.contrast ?? 4.5; + // A role that cannot read on this fill gives way to the best role (white label on an accent fill)... if (!textRole || contrastRatio(ctx.colors[textRole], face) < need) { const readable = readableRole(face, ctx.colors, textRole); if (textRole && readable !== textRole) notes.push(`${id}: text "${textRole}" on its fill is under ${need}:1, using "${readable}"`); textRole = readable; } } - if (textRole) put(type, 'text', 'color', ctx.colors[textRole]); + if (textRole) { + // ...and the ink is then solved on the grounds it really sits on (theme engine v3 3.3): its fill, or with + // no fill of its own every surface it can sit on, the page canvas included (an outline button in the + // rail sits on the page: Solarized's accent read 4.45:1 there). + // A role made for another ground (onAccent, onInverse, bg: a label on a fill some parent paints, the + // Classic bar) is the theme's statement about that parent and stands. + const surfaceInk = textRole === 'text' || textRole === 'muted' || textRole === 'accent'; + const grounds = face ? [face] : surfaceInk ? [ctx.colors.bg, ctx.colors.surface, ctx.colors.elevated, ctx.colors.page] : []; + const solved = grounds.length ? inkOnEvery(grounds, ctx.colors[textRole], need) : { ink: ctx.colors[textRole], adjusted: false }; + if (solved.adjusted) notes.push(`${id}: text "${textRole}" moved to ${solved.ink} to read ${need}:1 on ${grounds.join('/')}`); + put(type, 'text', 'color', solved.ink); + } if (has('radius')) put(box, 'radius', 'border-radius', tokens.radius === 'pill' ? '9999px' : `${tokens.radius}px`); const style = has('borderStyle') ? tokens.borderStyle! : undefined; diff --git a/packages/runtime/tests/themeStructure.test.ts b/packages/runtime/tests/themeStructure.test.ts index 2f141f3..73f1515 100644 --- a/packages/runtime/tests/themeStructure.test.ts +++ b/packages/runtime/tests/themeStructure.test.ts @@ -92,7 +92,10 @@ test('text on a fill the theme chose keeps its contrast: the role is swapped for const out = css({ buttonPrimary: { fill: 'accent', text: 'accent' }, post: { fill: 'text' }, postText: { text: 'muted' }, avatar: { fill: 'surface' } }); assert.ok(out.values.includes(`--wb-part-buttonPrimary-text:${COLORS.onAccent}`)); assert.ok(out.notes.some(note => note.startsWith('buttonPrimary:'))); - assert.ok(out.values.includes(`--wb-part-postText-text:${COLORS.muted}`), 'no fill of its own: the theme\'s role stands (the palette guarantees it on the theme\'s surfaces)'); + // No fill of its own: the role's ink is solved on every surface it can sit on, the page canvas included + // (theme engine v3: this raw palette's muted reads 3.9:1 on its page; ensureContrast fixes it upstream too). + const postInk = out.values.find(v => v.startsWith('--wb-part-postText-text:'))!.split(':')[1]!; + for (const ground of [COLORS.bg, COLORS.surface, COLORS.elevated, COLORS.page]) assert.ok(contrastRatio(postInk, ground) >= 4.5, `${postInk} on ${ground}`); assert.ok(!out.text.includes('--wb-part-avatar-text'), 'a fill alone colors no text on a part that is not a text part'); assert.equal(readableRole('#000000', COLORS), 'onAccent'); for (const role of ['accent', 'text', 'surface', 'inverse'] as const) { From 9b42e84cdd71b8ce9378916f09df41a438aae4c5 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 2 Oct 2026 06:18:38 +0700 Subject: [PATCH 12/12] Theme engine v3 step 10: the frame marker installFrameMarker: framed parts inside the watched scopes get data-wb-void when nothing in them renders and collapse (frameMarkerCss); a ResizeObserver per part and a child-list/attribute observer on the scopes trigger the check, batched per frame. The per-part 1.5 s polling of the X app's void cards is replaced. Co-Authored-By: Claude Opus 5.5 --- packages/runtime/src/themeGuards.ts | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/runtime/src/themeGuards.ts b/packages/runtime/src/themeGuards.ts index e85f8ce..1d0e66e 100644 --- a/packages/runtime/src/themeGuards.ts +++ b/packages/runtime/src/themeGuards.ts @@ -138,3 +138,71 @@ export function installLayerGuard(win: Window, options: LayerGuardOptions): () = win.addEventListener('resize', onResize); return () => { observer?.disconnect(); moved.disconnect(); sized?.disconnect(); win.clearInterval(retry); win.removeEventListener('resize', onResize); }; } + +// --------------------------------------------------------------------------------------------- +// Frame marker (theme engine v3 4.3, step 10): a framed part (a card, a panel) whose content is all +// hidden (a feature switch, a plugin, the site itself) collapses instead of leaving an empty frame. +// Static CSS cannot see `display:none` inside, so the marker sets `data-wb-void` and a static rule +// hides the part. Watched by a ResizeObserver per framed part (a hidden module changes the part's size) +// and a child-list observer on the scopes (new parts), never by a timer. + +export const VOID_ATTR = 'data-wb-void'; +/** Elements that make a part non-empty when they render. */ +export const VISIBLE_INSIDE = 'a,button,h2,img,svg,input,video,[role="heading"],[data-testid="tweetText"]'; + +/** The static rule the marker relies on. */ +export const frameMarkerCss = () => `:root:root [${VOID_ATTR}]{display:none !important}`; + +export type FrameMarkerOptions = { + /** Containers to watch (X: the sidebar column). */ + scopes: string[]; + /** Framed parts inside them (one selector list). */ + parts: string; +}; + +/** Pure: whether `part` renders nothing (every candidate inside has no client rect). */ +export function isVoid(part: Element): boolean { + return ![...part.querySelectorAll(VISIBLE_INSIDE)].some(el => el.getClientRects().length > 0); +} + +export function installFrameMarker(win: Window, options: FrameMarkerOptions): () => void { + const doc = win.document; + const Resize = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + const Observer = (win as Window & { MutationObserver: typeof MutationObserver }).MutationObserver; + const watched = new WeakSet(); + let frame = 0; + const check = (part: Element) => { + // A void part is display:none itself: lift the mark to see whether something came back. + const was = part.hasAttribute(VOID_ATTR); + if (was) part.removeAttribute(VOID_ATTR); + if (isVoid(part)) part.setAttribute(VOID_ATTR, ''); + }; + const sweep = () => { + frame = 0; + for (const scope of options.scopes) { + const root = doc.querySelector(scope); + if (!root) continue; + for (const part of root.querySelectorAll(options.parts)) { + if (!watched.has(part)) { watched.add(part); sized?.observe(part); } + check(part); + } + } + }; + const schedule = () => { if (!frame) frame = win.requestAnimationFrame(sweep); }; + const sized = Resize ? new Resize(schedule) : null; + // Inner modules change while the part keeps its size only when hidden content was already 0 tall: + // child-list changes under the scopes cover those, and new parts. + const added = new Observer(schedule); + const roots = new WeakSet(); + const attach = () => { + for (const scope of options.scopes) { + const root = doc.querySelector(scope); + if (root && !roots.has(root)) { roots.add(root); added.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'hidden'] }); schedule(); } + } + }; + attach(); + // Scopes mount late and are replaced on navigation: looked up again every 2 s (one querySelector per + // scope; the per-part work is all observer-driven). + const retry = win.setInterval(attach, 2000); + return () => { sized?.disconnect(); added.disconnect(); win.clearInterval(retry); if (frame) win.cancelAnimationFrame(frame); for (const el of doc.querySelectorAll(`[${VOID_ATTR}]`)) el.removeAttribute(VOID_ATTR); }; +}