Compare commits

..

No commits in common. "9b42e84cdd71b8ce9378916f09df41a438aae4c5" and "74c14554c4db600125dafc92bc56b6c5e64de2a5" have entirely different histories.

21 changed files with 43 additions and 1071 deletions

View file

@ -1424,10 +1424,7 @@ 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())
// 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());
.plugin(tauri_plugin_opener::init());
// 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

@ -1,82 +0,0 @@
// 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,7 +15,6 @@
// 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';
// ---------------------------------------------------------------------------------------------
@ -391,27 +390,15 @@ 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)) {
// A role that cannot read on this fill gives way to the best role (white label on an accent fill)...
const need = def.contrast ?? 4.5;
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) {
// ...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 (textRole) put(type, 'text', 'color', ctx.colors[textRole]);
if (has('radius')) put(box, 'radius', 'border-radius', tokens.radius === 'pill' ? '9999px' : `${tokens.radius}px`);
const style = has('borderStyle') ? tokens.borderStyle! : undefined;

View file

@ -1,138 +0,0 @@
// 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

@ -1,208 +0,0 @@
// 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,7 +21,6 @@ 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

@ -1,124 +0,0 @@
// 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

@ -1,231 +0,0 @@
// 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,9 +16,8 @@
// 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, hexToRgb, mix, type FontRoleName } from './themeApi.ts';
import { FONT_ROLE_NAMES, MONO_FAMILIES, contrastRatio, hexToRgb, mix, relativeLuminance, 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';
@ -152,17 +151,19 @@ 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)})`;
/**
* 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.
*/
/** Moves `color` toward black or white until it reaches `ratio` against every surface. */
export function reach(color: string, surfaces: string[], ratio: number): string {
const kept = inkOnEvery(surfaces, color, ratio);
return kept.adjusted ? inkOnEvery(surfaces, color, ratio * 1.08).ink : kept.ink;
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;
}
/**
* 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

@ -1,22 +0,0 @@
// 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

@ -1,57 +0,0 @@
// 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

@ -1,27 +0,0 @@
// 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

@ -1,67 +0,0 @@
// 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,10 +92,7 @@ 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:')));
// 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.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)');
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,8 +832,6 @@ 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,10 +2,6 @@
// 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";
@ -49,14 +45,14 @@ export function mix(a: string, b: string, t: number): string {
const channels = (hex: string) => hexToRgb(hex).join(' ');
/** 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;
/** 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 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
@ -111,19 +107,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 set = deriveSurfaces(surfacesInputFromV2(colors, { bevel: true }));
const face = set.chrome.fill;
const strong = set.strong;
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';
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),
// 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,
'--wb-c-title-from': from, '--wb-c-title-to': colors.accent, '--wb-c-title-text': titleText,
// Selected rows (menus, lists): the title bar's own color, as in a 98 menu.
'--wb-c-select': strong.fill, '--wb-c-select-text': strong.ink,
'--wb-c-select': from, '--wb-c-select-text': titleText,
// Menus are the face: status inks are measured on it.
'--wb-c-muted': set.chrome.inkSoft, '--wb-c-faint': set.chrome.inkSoft,
'--wb-c-muted': legible(colors.muted, face, 4.5), '--wb-c-faint': legible(colors.muted, face, 4.5),
'--wb-c-warn': legible('#806000', face, 4.5), '--wb-c-danger-ink': legible('#a00000', face, 4.5),
};
}

View file

@ -39,8 +39,6 @@ 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) => {},
@ -279,7 +277,7 @@ let saveTimer: ReturnType<typeof setTimeout> | undefined;
export function saveLayoutSoon() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
if (!shell.api || shell.restoring || !shell.layoutReady) return;
if (!shell.api || shell.restoring) return;
try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(shell.api.toJSON())); } catch { /* Session restore is best effort. */ }
}, 400);
}

View file

@ -16,10 +16,6 @@
--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);
@ -62,13 +58,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: var(--wb-ok-ink); }
.wbi-source.is-store > svg { color: #3ecf8e; }
.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: var(--wb-ok-ink); 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: #3ecf8e; 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); }
@ -91,17 +87,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: 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-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-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: var(--wb-warn-ink); }
.wbi-note svg { flex: none; margin-top: 2px; color: #ffc55c; }
.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: var(--wb-warn-ink); }
.wbi-findings span { padding: 2px 8px; border-radius: 6px; background: rgb(255 176 32 / .1); color: #ffc55c; }
.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: var(--wb-warn-ink); font-size: 12.5px; }
.wbi-warnings { margin: 0; padding-inline-start: 18px; color: #ffc55c; 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; }
@ -130,8 +126,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: 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-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-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); } }
@ -171,7 +167,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-ink); }
.wbs-status.is-warn { color: var(--wb-warn); }
.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);
@ -200,11 +196,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-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-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-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-ink); }
.wbs-state.is-ok { color: var(--wb-ok); }
.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); }
@ -219,7 +215,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: var(--wb-danger-ink); }
.wb-btn.is-danger { background: color-mix(in srgb, #f4212e 16%, transparent); border-color: color-mix(in srgb, #f4212e 45%, transparent); color: #ff7b85; }
.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

@ -1,23 +0,0 @@
// 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,15 +211,6 @@
},
"lock": {
"$ref": "#/$defs/glyph"
},
"history": {
"$ref": "#/$defs/glyph"
},
"studio": {
"$ref": "#/$defs/glyph"
},
"articles": {
"$ref": "#/$defs/glyph"
}
},
"maxProperties": 64,

View file

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