Compare commits

..

12 commits

Author SHA1 Message Date
CRT
9b42e84cdd 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 <noreply@anthropic.com>
2026-10-02 06:18:38 +07:00
CRT
4eaaa31fb1 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 <noreply@anthropic.com>
2026-10-02 06:14:15 +07:00
CRT
e3fe5820df 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 <noreply@anthropic.com>
2026-10-02 06:11:38 +07:00
CRT
90ae2047d8 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 <noreply@anthropic.com>
2026-10-02 06:02:30 +07:00
CRT
67a8dec7e2 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 <noreply@anthropic.com>
2026-10-02 05:58:34 +07:00
CRT
27952d60a8 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 <noreply@anthropic.com>
2026-10-02 05:46:52 +07:00
CRT
158d8cdfa2 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 <noreply@anthropic.com>
2026-10-02 05:00:43 +07:00
CRT
013e1ff98f 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 <noreply@anthropic.com>
2026-10-02 05:00:02 +07:00
CRT
68217c818e 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 <noreply@anthropic.com>
2026-10-02 04:56:04 +07:00
CRT
4d39a28ac2 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 <noreply@anthropic.com>
2026-10-02 04:29:27 +07:00
CRT
ec68721cbe Icon names for X's newer rail items: history, studio, articles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 04:09:11 +07:00
CRT
04493fdb4b 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 <noreply@anthropic.com>
2026-10-02 04:03:23 +07:00
21 changed files with 1071 additions and 43 deletions

View file

@ -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

View file

@ -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 <html>. 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); };
}

View file

@ -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;

View file

@ -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<SurfaceName, Surface> & { 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<SurfaceName, Surface>;
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' },
};
}

View file

@ -0,0 +1,208 @@
// 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<Element>();
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<HTMLElement, string>();
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); };
}
// ---------------------------------------------------------------------------------------------
// 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<Element>();
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<Element>();
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); };
}

View file

@ -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;

View file

@ -0,0 +1,124 @@
// 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('')}`;
}
/** `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
/** 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);
}

View file

@ -0,0 +1,231 @@
// 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<InvariantId, number>;
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<InvariantId, number>;
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<string, string>).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 (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]);
}
for (const el of frames) {
const fill = cs(el).backgroundColor;
const column = el.querySelector('[data-testid="sidebarColumn"], [data-testid="primaryColumn"]');
// 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)})`);
}
// 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);
// 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'})`);
}
}
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] };
}

View file

@ -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

View file

@ -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');
});

View file

@ -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}`);
}
});

View file

@ -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);
});

View file

@ -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');
});

View file

@ -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) {

View file

@ -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));

View file

@ -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<string, stri
* accent, its label readable on both ends).
*/
function bevelVars(colors: NonNullable<ChromeTheme['colors']>): Record<string, string> {
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),
};
}

View file

@ -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<typeof setTimeout> | 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);
}

View file

@ -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); }

View file

@ -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(/(?<![-\w])color:\s*(#[0-9a-f]{3,8}|rgba?\([^)]*\))/gi)].map(m => 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);
}
});

View file

@ -211,6 +211,15 @@
},
"lock": {
"$ref": "#/$defs/glyph"
},
"history": {
"$ref": "#/$defs/glyph"
},
"studio": {
"$ref": "#/$defs/glyph"
},
"articles": {
"$ref": "#/$defs/glyph"
}
},
"maxProperties": 64,

View file

@ -4126,6 +4126,15 @@
},
"lock": {
"$ref": "#/$defs/glyph"
},
"history": {
"$ref": "#/$defs/glyph"
},
"studio": {
"$ref": "#/$defs/glyph"
},
"articles": {
"$ref": "#/$defs/glyph"
}
},
"maxProperties": 8