diff --git a/crates/waybetter-host/src/app.rs b/crates/waybetter-host/src/app.rs index f91d0b6..3a4249d 100644 --- a/crates/waybetter-host/src/app.rs +++ b/crates/waybetter-host/src/app.rs @@ -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 diff --git a/packages/runtime/src/themeBar.ts b/packages/runtime/src/themeBar.ts deleted file mode 100644 index e54c49a..0000000 --- a/packages/runtime/src/themeBar.ts +++ /dev/null @@ -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 . Returns a stop function. */ -export function installBarMeasurer(win: Window, options: BarMeasurerOptions): () => void { - const doc = win.document; - const canvas = doc.createElement('canvas'); - const ctx = canvas.getContext('2d'); - const textWidth = (text: string, font: string) => { if (!ctx) return text.length * 8; ctx.font = font; return Math.ceil(ctx.measureText(text).width); }; - let nav: Element | null = null; - let frame = 0; - const measure = () => { - frame = 0; - const html = doc.documentElement; - if (options.when && !doc.querySelector(options.when)) { html.removeAttribute(BAR_ATTR); return; } - if (!nav?.isConnected) return; - const items = [...nav.querySelectorAll(`:scope > ${options.item}`)].filter(el => (el as HTMLElement).offsetParent !== null || el.getClientRects().length); - const measured = items.map(item => { - const label = item.querySelector(options.label) as HTMLElement | null; - const icon = item.querySelector(options.icon) as HTMLElement | null; - const pill = (item.firstElementChild as HTMLElement | null) ?? (item as HTMLElement); - const ps = win.getComputedStyle(pill); - const fixed = px(ps.paddingLeft) + px(ps.paddingRight) + px(ps.borderLeftWidth) + px(ps.borderRightWidth) + (icon ? Math.max(icon.getBoundingClientRect().width, 20) : 0); - if (!label || !label.textContent?.trim()) return { labelWidth: 0, fixed }; - const ls = win.getComputedStyle(label); - // The label's own font, read even while the label is display:none (computed styles resolve it). - const font = `${ls.fontStyle} ${ls.fontWeight} ${ls.fontSize} ${ls.fontFamily}`; - return { labelWidth: textWidth(label.textContent.trim(), font) + px(ls.marginLeft) + px(ls.marginRight) + 2, fixed }; - }); - const ns = win.getComputedStyle(nav); - const available = (nav as HTMLElement).clientWidth - px(ns.paddingLeft) - px(ns.paddingRight); - const current = (html.getAttribute(BAR_ATTR) as BarMode | null); - const mode = barMode(measured, available, px(ns.columnGap) || 0, current); - if (current !== mode) html.setAttribute(BAR_ATTR, mode); - }; - const schedule = () => { if (!frame) frame = win.requestAnimationFrame(measure); }; - const Resize = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; - const sized = Resize ? new Resize(schedule) : null; - const attach = () => { - const found = doc.querySelector(options.nav); - if (found !== nav) { if (nav) sized?.unobserve(nav); nav = found; if (nav) sized?.observe(nav); } - schedule(); - }; - attach(); - // The nav mounts late and is replaced on some navigations; items and fonts change rarely. - const timer = win.setInterval(attach, 1500); - const fonts = (doc as Document & { fonts?: FontFaceSet }).fonts; - fonts?.addEventListener?.('loadingdone', schedule); - win.addEventListener('resize', schedule); - return () => { win.clearInterval(timer); sized?.disconnect(); fonts?.removeEventListener?.('loadingdone', schedule); win.removeEventListener('resize', schedule); if (frame) win.cancelAnimationFrame(frame); doc.documentElement.removeAttribute(BAR_ATTR); }; -} diff --git a/packages/runtime/src/themeComponents.ts b/packages/runtime/src/themeComponents.ts index 750a51a..340a518 100644 --- a/packages/runtime/src/themeComponents.ts +++ b/packages/runtime/src/themeComponents.ts @@ -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; diff --git a/packages/runtime/src/themeDerive.ts b/packages/runtime/src/themeDerive.ts deleted file mode 100644 index 1d51cab..0000000 --- a/packages/runtime/src/themeDerive.ts +++ /dev/null @@ -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 & { scheme: 'light' | 'dark'; edge: EdgeStyle; notes: string[] }; - -const isDarkFill = (hex: string) => relativeLuminance(hex) < 0.18; - -/** The best ratio any ink can reach on every one of `fills` (black or white on the worst of them). */ -export const reachable = (fills: string[]) => Math.max(...['#000000', '#ffffff'].map(pole => Math.min(...fills.map(fill => contrastRatio(pole, fill))))); - -/** - * One surface. Inks are solved on the fill (both ends of a gradient); the hover and selected states are - * then pulled back toward the fill until every ink still reads on them, so a hovered or selected row - * never costs contrast. A target no ink can reach on this fill (7:1 on a mid tone) is capped at the - * best there is and noted. - */ -function surface(name: SurfaceName, fill: string, p: DerivePalette, input: DeriveInput, notes: string[], opts: { fill2?: string; inkPreferred?: string; hover?: string | null; selected?: string | null } = {}): Surface { - const fills = opts.fill2 ? [fill, opts.fill2] : [fill]; - const best = reachable(fills); - const cap = (target: number, what: string) => { - if (best >= target) return target; - notes.push(`${name}.${what}: ${target}:1 is out of reach on ${fills.join('/')}, ${best.toFixed(2)}:1 at most`); - return best; - }; - const textTarget = cap(input.access === 'high' ? TARGET.high : TARGET.text, 'ink'); - const softTarget = cap(TARGET.text, 'inkSoft'); - const solve = (preferred: string, target: number, what: string) => { - const result = inkOnEvery(fills, preferred, target); - if (result.adjusted) notes.push(`${name}.${what} ${preferred} -> ${result.ink} (${result.ratio.toFixed(2)}:1)`); - return result.ink; - }; - const ink = solve(opts.inkPreferred ?? p.text, textTarget, 'ink'); - const inkSoft = solve(p.muted, softTarget, 'inkSoft'); - const link = solve(p.accent, softTarget, 'link'); - const reads = (state: string) => contrastRatio(ink, state) >= textTarget - 1e-9 && contrastRatio(inkSoft, state) >= softTarget - 1e-9 && contrastRatio(link, state) >= softTarget - 1e-9; - /** A state `t` of the way from the fill to `toward`, shrunk until the inks read on it. */ - const state = (toward: string, t: number, what: string) => { - for (let k = t; k > 0.004; k /= 2) { const candidate = mixOklab(fill, toward, k); if (reads(candidate)) { if (k !== t) notes.push(`${name}.${what} step ${t} -> ${k.toFixed(3)}`); return candidate; } } - return fill; - }; - const dark = isDarkFill(fill); - // States: the theme's own hover/selected when they keep every ink readable, else a neutral step toward the ink (never the accent hue for hover). - const hover = opts.hover && reads(opts.hover) ? opts.hover : state(ink, dark ? 0.08 : 0.05, 'hover'); - const selected = opts.selected && reads(opts.selected) ? opts.selected : state(p.accent, 0.14, 'selected'); - const strongLine = input.edge?.strong || input.access === 'high'; - const lineTarget = Math.min(best, strongLine || input.edge?.style === 'outline' ? TARGET.nonText : 1.3); - return { - fill, ...(opts.fill2 ? { fill2: opts.fill2 } : {}), - ink, inkSoft, link, - line: strongLine ? ink : inkOnEvery(fills, p.border, lineTarget).ink, - hover, selected, focus: inkOnEvery(fills, p.accent, Math.min(best, TARGET.nonText)).ink, - }; -} - -/** Every surface of a theme. Notes list each ink the solver had to move. */ -export function deriveSurfaces(input: DeriveInput): SurfaceSet { - const p = input.palette; - const notes: string[] = []; - const scheme = p.scheme ?? (relativeLuminance(p.bg) < relativeLuminance(p.text) ? 'dark' : 'light'); - const edge = input.edge?.style ?? 'hairline'; - const face = p.face ?? (edge === 'bevel' ? mixOklab(p.surface, p.border, 0.2) : p.surface); - const cards = input.post?.style === 'cards' || input.post?.style === 'bubbles'; - // The strong fill (title bars, inverted buttons): its gradient end moves until one ink reads on both ends. - const inverse = p.inverse ?? p.text; - let inverse2 = p.inverse2 ?? inverse; - const strongPreferred = p.onInverse ?? p.bg; - if (inverse2 !== inverse) { - const both = inkOnEvery([inverse, inverse2], strongPreferred, TARGET.text); - if (both.ratio < TARGET.text) { - const ink = inkOn(inverse, strongPreferred, TARGET.text).ink; - const moved = inkOn(ink, inverse2, TARGET.text).ink; - notes.push(`strong.fill2 ${inverse2} -> ${moved} (one ink on both ends)`); - inverse2 = moved; - } - } - // The accent fill's label: white or the page colour, whichever reads better, then solved. - const accentLabel = contrastRatio('#ffffff', p.accent) >= contrastRatio(p.bg, p.accent) ? '#ffffff' : p.bg; - const tooltipFill = mixOklab(p.text, p.bg, 0.12); - const set = { - canvas: surface('canvas', p.page ?? p.bg, p, input, notes), - content: surface('content', p.bg, p, input, notes, { hover: p.hover, selected: p.selected }), - card: surface('card', cards ? p.surface : p.bg, p, input, notes, { hover: p.hover, selected: p.selected }), - chrome: surface('chrome', face, p, input, notes, { hover: p.hover, selected: p.selected }), - raised: surface('raised', p.elevated, p, input, notes), - panel: surface('panel', p.bg, p, input, notes, { hover: p.hover, selected: p.selected }), - strong: surface('strong', inverse, p, input, notes, { ...(inverse2 !== inverse ? { fill2: inverse2 } : {}), inkPreferred: strongPreferred }), - accentFill: surface('accentFill', p.accent, p, input, notes, { inkPreferred: accentLabel }), - tooltip: surface('tooltip', tooltipFill, p, input, notes, { inkPreferred: p.bg }), - } satisfies Record; - return { ...set, scheme, edge, notes }; -} - -/** The v2 palette and component hints as v3 derivation input (until the documents are v3, step 13). */ -export function surfacesInputFromV2(colors: { bg: string; surface: string; elevated: string; border: string; text: string; muted: string; accent: string; page?: string | null; inverse?: string | null; onInverse?: string | null }, hints: { bevel?: boolean; high?: boolean; cards?: boolean } = {}): DeriveInput { - return { - palette: { - page: colors.page ?? null, bg: colors.bg, surface: colors.surface, elevated: colors.elevated, border: colors.border, - text: colors.text, muted: colors.muted, accent: colors.accent, inverse: colors.inverse ?? null, onInverse: colors.onInverse ?? null, - // A bevelled v2 theme draws its chrome from `page` (Windows 98: the silver face) and its title bar from inverse to accent. - face: hints.bevel ? colors.page ?? colors.elevated : null, - inverse2: hints.bevel ? colors.accent : null, - }, - edge: { style: hints.bevel ? 'bevel' : 'hairline' }, - access: hints.high ? 'high' : 'standard', - post: { style: hints.cards ? 'cards' : 'flat' }, - }; -} diff --git a/packages/runtime/src/themeGuards.ts b/packages/runtime/src/themeGuards.ts deleted file mode 100644 index 1d0e66e..0000000 --- a/packages/runtime/src/themeGuards.ts +++ /dev/null @@ -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(); - let scheduled = false; - const measure = () => { - scheduled = false; - let budget = 4; - for (const layer of [...pending]) { - if (budget-- <= 0) break; - pending.delete(layer); - if (!layer.isConnected) continue; - for (const target of layer.matches(selector) ? [layer] : [...layer.querySelectorAll(selector)].slice(0, 2)) clamp(target as HTMLElement); - } - if (pending.size) schedule(); - }; - // A measured popover is watched for the site moving it again (its inline position): only its own - // style attribute, and only position changes count (our own clamp variables change it too). - const placed = new WeakMap(); - const position = (el: HTMLElement) => `${el.style.left}|${el.style.top}|${el.style.right}|${el.style.bottom}|${el.style.transform}`; - const Observer = (win as Window & { MutationObserver: typeof MutationObserver }).MutationObserver; - const moved = new Observer((records: MutationRecord[]) => { - for (const record of records) { - const el = record.target as HTMLElement; - if (el.isConnected && placed.get(el) !== position(el)) { pending.add(el); schedule(); } - } - }); - // A popover that grows (a theme's font, content loading in) is measured again too. - const Resize = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; - const sized = Resize ? new Resize(entries => { for (const entry of entries) { const el = entry.target as HTMLElement; if (el.isConnected) { pending.add(el); schedule(); } } }) : null; - const clamp = (el: HTMLElement) => { - if (!placed.has(el)) { moved.observe(el, { attributes: true, attributeFilter: ['style'] }); sized?.observe(el); } - placed.set(el, position(el)); - // Measure without our own shift. - el.style.removeProperty(CLAMP_X); el.style.removeProperty(CLAMP_Y); - const r = el.getBoundingClientRect(); - if (r.width < 1 || r.height < 1) return; - const box = viewportBox(win.innerWidth, win.innerHeight, insets()); - const { dx, dy, overflowY } = clampShift({ left: r.left, top: r.top, width: r.width, height: r.height }, box); - if (!dx && !dy && !overflowY) { el.removeAttribute(CLAMPED_ATTR); return; } - el.style.setProperty(CLAMP_X, `${dx}px`); el.style.setProperty(CLAMP_Y, `${dy}px`); - if (overflowY) el.style.setProperty('--wb-clamp-h', `${Math.max(80, box.bottom - box.top)}px`); - el.setAttribute(CLAMPED_ATTR, overflowY ? 'scroll' : ''); - }; - const schedule = () => { - if (scheduled) return; - scheduled = true; - const idle = (win as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback; - if (idle) idle.call(win, measure, { timeout: 120 }); else win.setTimeout(measure, 16); - }; - // Only a node that is or holds a popover is worth a measurement (typing in a dialog inserts nodes too). - const queue = (node: Node | null) => { - if (!node || node.nodeType !== 1) return; - const el = node as Element; - if (!el.matches(selector) && !el.querySelector(selector)) return; - pending.add(el); schedule(); - // The site positions a portal after it mounts (a layout effect, a measured anchor): measure again - // once it has settled. Two late passes, no observer on the page's style attributes. - for (const delay of [120, 450]) win.setTimeout(() => { if (el.isConnected) { pending.add(el); schedule(); } }, delay); - }; - let observer: MutationObserver | null = null; - let layers: Element | null = null; - const attach = () => { - const found = doc.querySelector(options.root); - if (found === layers) return; - observer?.disconnect(); - layers = found; - if (!found) return; - // Children of the layer root and their children: a portal mounts its positioned box a level or two down. - const next = new Observer((records: MutationRecord[]) => { for (const record of records) for (const node of record.addedNodes) queue(node.nodeType === 1 ? ((node as Element).closest(selector) ?? node) : null); }); - next.observe(found, { childList: true, subtree: true }); - observer = next; - for (const child of found.children) queue(child); - }; - attach(); - const retry = win.setInterval(attach, 2000); - const onResize = () => { if (layers) for (const child of layers.children) queue(child); }; - win.addEventListener('resize', onResize); - return () => { observer?.disconnect(); moved.disconnect(); sized?.disconnect(); win.clearInterval(retry); win.removeEventListener('resize', onResize); }; -} - -// --------------------------------------------------------------------------------------------- -// Frame marker (theme engine v3 4.3, step 10): a framed part (a card, a panel) whose content is all -// hidden (a feature switch, a plugin, the site itself) collapses instead of leaving an empty frame. -// Static CSS cannot see `display:none` inside, so the marker sets `data-wb-void` and a static rule -// hides the part. Watched by a ResizeObserver per framed part (a hidden module changes the part's size) -// and a child-list observer on the scopes (new parts), never by a timer. - -export const VOID_ATTR = 'data-wb-void'; -/** Elements that make a part non-empty when they render. */ -export const VISIBLE_INSIDE = 'a,button,h2,img,svg,input,video,[role="heading"],[data-testid="tweetText"]'; - -/** The static rule the marker relies on. */ -export const frameMarkerCss = () => `:root:root [${VOID_ATTR}]{display:none !important}`; - -export type FrameMarkerOptions = { - /** Containers to watch (X: the sidebar column). */ - scopes: string[]; - /** Framed parts inside them (one selector list). */ - parts: string; -}; - -/** Pure: whether `part` renders nothing (every candidate inside has no client rect). */ -export function isVoid(part: Element): boolean { - return ![...part.querySelectorAll(VISIBLE_INSIDE)].some(el => el.getClientRects().length > 0); -} - -export function installFrameMarker(win: Window, options: FrameMarkerOptions): () => void { - const doc = win.document; - const Resize = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; - const Observer = (win as Window & { MutationObserver: typeof MutationObserver }).MutationObserver; - const watched = new WeakSet(); - let frame = 0; - const check = (part: Element) => { - // A void part is display:none itself: lift the mark to see whether something came back. - const was = part.hasAttribute(VOID_ATTR); - if (was) part.removeAttribute(VOID_ATTR); - if (isVoid(part)) part.setAttribute(VOID_ATTR, ''); - }; - const sweep = () => { - frame = 0; - for (const scope of options.scopes) { - const root = doc.querySelector(scope); - if (!root) continue; - for (const part of root.querySelectorAll(options.parts)) { - if (!watched.has(part)) { watched.add(part); sized?.observe(part); } - check(part); - } - } - }; - const schedule = () => { if (!frame) frame = win.requestAnimationFrame(sweep); }; - const sized = Resize ? new Resize(schedule) : null; - // Inner modules change while the part keeps its size only when hidden content was already 0 tall: - // child-list changes under the scopes cover those, and new parts. - const added = new Observer(schedule); - const roots = new WeakSet(); - const attach = () => { - for (const scope of options.scopes) { - const root = doc.querySelector(scope); - if (root && !roots.has(root)) { roots.add(root); added.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'hidden'] }); schedule(); } - } - }; - attach(); - // Scopes mount late and are replaced on navigation: looked up again every 2 s (one querySelector per - // scope; the per-part work is all observer-driven). - const retry = win.setInterval(attach, 2000); - return () => { sized?.disconnect(); added.disconnect(); win.clearInterval(retry); if (frame) win.cancelAnimationFrame(frame); for (const el of doc.querySelectorAll(`[${VOID_ATTR}]`)) el.removeAttribute(VOID_ATTR); }; -} diff --git a/packages/runtime/src/themeIcons.ts b/packages/runtime/src/themeIcons.ts index f73e8af..90f12c7 100644 --- a/packages/runtime/src/themeIcons.ts +++ b/packages/runtime/src/themeIcons.ts @@ -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; diff --git a/packages/runtime/src/themeInk.ts b/packages/runtime/src/themeInk.ts deleted file mode 100644 index 092a3b8..0000000 --- a/packages/runtime/src/themeInk.ts +++ /dev/null @@ -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); -} diff --git a/packages/runtime/src/themeInvariants.ts b/packages/runtime/src/themeInvariants.ts deleted file mode 100644 index 085e070..0000000 --- a/packages/runtime/src/themeInvariants.ts +++ /dev/null @@ -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; - findings: Finding[]; - /** Measured but not failures: text over images, X icons with no slot, X's allowed truncation. */ - notes: { unknownBackdrop: number; allowedTruncation: number; bar: 'none' | 'rail' | 'horizontal'; barMode?: 'labels' | 'icons' }; - viewport: [number, number]; -} - -/** X's own palette (Default and Lights out neutrals, X blue): the leak list until the engine exports X_COLOUR_KEYS. */ -export const DEFAULT_X_KEYS = ['rgb(29, 155, 240)', 'rgb(26, 140, 216)', 'rgb(15, 20, 25)', 'rgb(83, 100, 113)', 'rgb(113, 118, 123)', 'rgb(239, 243, 244)', - 'rgb(247, 249, 249)', 'rgb(231, 233, 234)', 'rgb(22, 24, 28)', 'rgb(32, 35, 39)', 'rgb(47, 51, 54)', 'rgb(20, 20, 20)', 'rgb(207, 217, 222)', 'rgb(51, 54, 57)']; - -type RGBA = [number, number, number, number]; -const parse = (c: string | null | undefined): RGBA | null => { const m = /rgba?\(([\d.]+),? ([\d.]+),? ([\d.]+)(?:,? ?\/? ?([\d.]+))?/.exec(c || ''); return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]] : null; }; -const lum = ([r, g, b]: RGBA) => { const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; -export const contrast = (a: RGBA, b: RGBA) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); }; -const blend = (top: RGBA, under: RGBA): RGBA => top[3] >= 1 ? top : [0, 1, 2].map(i => top[i] * top[3] + under[i] * (1 - top[3])).concat(1) as RGBA; -const css = (c: RGBA) => `rgb(${c.slice(0, 3).map(Math.round).join(', ')})`; - -export function runInvariants(doc: Document = document, opts: InvariantOptions = {}): InvariantReport { - const win = doc.defaultView as Window & typeof globalThis; - const vw = win.innerWidth, vh = win.innerHeight; - const keep = opts.keep ?? 12; - const high = opts.access === 'high'; - const counts = Object.fromEntries(INVARIANT_IDS.map(id => [id, 0])) as Record; - const findings: Finding[] = []; - const notes: InvariantReport['notes'] = { unknownBackdrop: 0, allowedTruncation: 0, bar: 'none' }; - const cs = (e: Element) => win.getComputedStyle(e); - const up = (e: Element): Element | null => e.parentElement ?? ((e.parentNode as ShadowRoot | null)?.host ?? null); - const rectOf = (e: Element): Rect => { const r = e.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)]; }; - const sel = (el: Element) => { - const p: string[] = []; - for (let e: Element | null = el; e && e !== doc.body && p.length < 4; e = up(e)) { - let s = e.tagName.toLowerCase(); const t = e.getAttribute('data-testid'); - if (t) { p.unshift(`${s}[data-testid="${t}"]`); break; } - if (e.id) { p.unshift(`#${e.id}`); break; } - const c = [...e.classList].filter(c => !/^(css|r)-/.test(c)).slice(0, 2); if (c.length) s += '.' + c.join('.'); - const role = e.getAttribute('role'); if (role) s += `[role="${role}"]`; - p.unshift(s); - } - return p.join(' > '); - }; - const add = (id: InvariantId, el: Element, detail: string, value?: number) => { - counts[id]++; - if (findings.filter(f => f.id === id).length < keep) findings.push({ id, sel: sel(el), rect: rectOf(el), detail, ...(value === undefined ? {} : { value: +value.toFixed(2) }) }); - }; - const hidden = (el: Element) => { for (let e: Element | null = el; e; e = up(e)) { const s = cs(e); if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity < 0.05) return true; } return false; }; - const onScreen = (el: Element) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1 && r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw; }; - const visible = (el: Element) => onScreen(el) && !hidden(el); - const pageColor = (): RGBA => { for (const e of [doc.body, doc.documentElement]) { const c = parse(cs(e).backgroundColor); if (c && c[3] > 0) return blend(c, [255, 255, 255, 1]); } return [255, 255, 255, 1]; }; - /** The painted backdrop under an element: the alpha stack up to the first opaque fill; `image` when an image or gradient paints first. */ - const backdrop = (el: Element): { color: RGBA; image: boolean } => { - const stack: RGBA[] = []; - for (let e: Element | null = el; e && e !== doc.documentElement; e = up(e)) { - const s = cs(e); - if (s.backgroundImage !== 'none' && !/^linear-gradient\(rgba?\([^)]*\), rgba?\([^)]*\)\)$/.test(s.backgroundImage)) return { color: pageColor(), image: true }; - const c = parse(s.backgroundColor); - if (c && c[3] > 0) { stack.push(c); if (c[3] >= 1) break; } - } - let base = pageColor(); - for (const c of stack.reverse()) base = blend(c, base); - return { color: base, image: false }; - }; - const ownText = (el: Element) => [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim(); - const all = [...doc.querySelectorAll('body *')]; - - // I1 text contrast, I2 leakage, I3 truncation - const xKeys = new Set(opts.xColourKeys ?? DEFAULT_X_KEYS); - const whitelisted = (el: Element) => !!el.closest('[data-testid="icon-verified"],[data-testid="unlike"],[data-testid="unretweet"],[data-testid="removeBookmark"],img,video,canvas'); - const LABEL_CONTEXT = 'nav[role="navigation"], header[role="banner"], [role="tablist"], [role="tab"], button, [role="button"], [role="menuitem"], .dv-tab'; - const ALLOWED_TRUNC = '[data-testid="User-Name"], [data-testid="trend"], .bt-exact-time, [data-testid="UserName"], [data-testid="UserCell"]'; - for (const el of all) { - if (el.closest('[aria-hidden="true"]') || el.closest('svg') && el.tagName.toLowerCase() !== 'svg') continue; - const text = ownText(el); - if (!visible(el)) continue; - const st = cs(el); - if (!opts.exemptLeak && !whitelisted(el)) { - for (const prop of ['color', 'backgroundColor', 'borderTopColor', 'borderBottomColor', 'fill', 'outlineColor'] as const) { - const v = st[prop] as string; if (!xKeys.has(v)) continue; - if (prop.startsWith('border') && parseFloat(prop === 'borderTopColor' ? st.borderTopWidth : st.borderBottomWidth) === 0) continue; - if (prop === 'outlineColor' && (st.outlineStyle === 'none' || parseFloat(st.outlineWidth) === 0)) continue; - if (prop === 'color' && !text) continue; - if (prop === 'fill' && el.tagName.toLowerCase() !== 'svg') continue; - add('I2', el, `${v} as ${prop}`); break; - } - } - if (!text) continue; - const fg = parse(st.color); if (!fg) continue; - const back = backdrop(el); - if (back.image) notes.unknownBackdrop++; - else { - const size = parseFloat(st.fontSize), bold = +st.fontWeight >= 700; - const large = size >= 24 || (size >= 18.66 && bold); - const need = high ? (large ? 4.5 : 7) : (large ? 3 : 4.5); - const ink = blend([fg[0], fg[1], fg[2], fg[3] * Math.min(1, +st.opacity || 1)], back.color); - const r = contrast(ink, back.color); - if (r < need - 0.005) add('I1', el, `${r.toFixed(2)}:1 < ${need} ${st.color} on ${css(back.color)} "${text.slice(0, 24)}"`, r); - } - if (el.closest(LABEL_CONTEXT)) { - const cut = el.scrollWidth > el.clientWidth + 1 && (st.textOverflow === 'ellipsis' || st.overflow === 'hidden' || st.overflowX === 'hidden'); - const barLabel = !!el.closest('nav[role="navigation"]') && st.textOverflow === 'ellipsis' && el.scrollWidth > el.clientWidth + 1; - if (cut || barLabel) { - if (el.closest(ALLOWED_TRUNC)) notes.allowedTruncation++; - else add('I3', el, `"${text.slice(0, 24)}" shown in ${el.clientWidth}px of ${el.scrollWidth}px`, el.clientWidth); - } - } - } - - // I1b non-text contrast and I5 icon provenance - const PACK_CONTEXT = 'nav[role="navigation"], header[role="banner"], [role="group"], [data-testid="toolBar"], #layers [role="menu"], [data-testid="GrokDrawer"], [data-testid="DMDrawer"], [data-testid="app-bar-back"], [data-testid="app-bar-close"]'; - for (const svg of doc.querySelectorAll('svg')) { - if (!visible(svg) || svg.closest('[data-testid="icon-verified"]')) continue; - const s = cs(svg); - const painted = [...svg.querySelectorAll('path, circle, rect, polygon, ellipse, line')].filter(p => { const ps = cs(p); return +ps.opacity > 0.05 && (ps.fill !== 'none' || ps.stroke !== 'none'); }); - const masked = (e: Element) => { const es = cs(e); return (es.maskImage && es.maskImage !== 'none') || ((es as unknown as Record).webkitMaskImage ?? 'none') !== 'none' || es.backgroundImage !== 'none'; }; - if (opts.pack && svg.closest(PACK_CONTEXT) && painted.length && !masked(svg) && !(svg.parentElement && masked(svg.parentElement))) add('I5', svg, `X's own drawing in ${svg.closest(PACK_CONTEXT)!.tagName.toLowerCase()}`); - const ink = parse(masked(svg) ? s.backgroundColor : s.color); - if (!ink || ink[3] === 0 || (!painted.length && !masked(svg))) continue; - const back = backdrop(svg.parentElement ?? svg); - if (back.image) continue; - const r = contrast(blend(ink, back.color), back.color); - if (r < 3 - 0.005) add('I1b', svg, `${r.toFixed(2)}:1 < 3 icon ${css(ink)} on ${css(back.color)}`, r); - } - - // I6 empty frames, I7 nesting - const framed = (el: Element) => { - const s = cs(el), r = el.getBoundingClientRect(); - if (r.width < 60 || r.height <= 2 || s.position === 'fixed' && r.width >= vw - 2 && r.height >= vh - 2) return false; - if (s.backgroundImage !== 'none') return false; - const edge = ['Top', 'Right', 'Bottom', 'Left'].filter(k => parseFloat(s[`border${k}Width` as 'borderTopWidth']) > 0 && s[`border${k}Style` as 'borderTopStyle'] !== 'none' && (parse(s[`border${k}Color` as 'borderTopColor'])?.[3] ?? 0) > 0).length >= 3 || /inset/.test(s.boxShadow); - const fill = parse(s.backgroundColor); const parent = up(el); - const own = !!fill && fill[3] > 0.05 && !!parent && css(blend(fill, backdrop(parent).color)) !== css(backdrop(parent).color); - return edge || own; - }; - const MEDIA = 'img, svg, video, canvas, input, textarea, select, iframe, [role="img"], [role="textbox"], [role="progressbar"]'; - const frames: Element[] = []; - for (const el of all) { - if (!visible(el) || el.closest('#layers [data-testid="mask"]') || el.matches('[data-testid="mask"]')) continue; - if (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] }; -} diff --git a/packages/runtime/src/themeSpec.ts b/packages/runtime/src/themeSpec.ts index 98e8b19..5e251f7 100644 --- a/packages/runtime/src/themeSpec.ts +++ b/packages/runtime/src/themeSpec.ts @@ -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 diff --git a/packages/runtime/tests/themeBar.test.ts b/packages/runtime/tests/themeBar.test.ts deleted file mode 100644 index 37674b0..0000000 --- a/packages/runtime/tests/themeBar.test.ts +++ /dev/null @@ -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'); -}); diff --git a/packages/runtime/tests/themeDerive.test.ts b/packages/runtime/tests/themeDerive.test.ts deleted file mode 100644 index 794318c..0000000 --- a/packages/runtime/tests/themeDerive.test.ts +++ /dev/null @@ -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}`); - } -}); diff --git a/packages/runtime/tests/themeGuards.test.ts b/packages/runtime/tests/themeGuards.test.ts deleted file mode 100644 index f5ca7c0..0000000 --- a/packages/runtime/tests/themeGuards.test.ts +++ /dev/null @@ -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); -}); diff --git a/packages/runtime/tests/themeInk.test.ts b/packages/runtime/tests/themeInk.test.ts deleted file mode 100644 index 4b8f4b9..0000000 --- a/packages/runtime/tests/themeInk.test.ts +++ /dev/null @@ -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'); -}); diff --git a/packages/runtime/tests/themeStructure.test.ts b/packages/runtime/tests/themeStructure.test.ts index 73f1515..2f141f3 100644 --- a/packages/runtime/tests/themeStructure.test.ts +++ b/packages/runtime/tests/themeStructure.test.ts @@ -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) { diff --git a/packages/shell/src/frame/Shell.tsx b/packages/shell/src/frame/Shell.tsx index c0ff9ed..36abe2a 100644 --- a/packages/shell/src/frame/Shell.tsx +++ b/packages/shell/src/frame/Shell.tsx @@ -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)); diff --git a/packages/shell/src/frame/chromeTheme.ts b/packages/shell/src/frame/chromeTheme.ts index d93b6b6..aedad7b 100644 --- a/packages/shell/src/frame/chromeTheme.ts +++ b/packages/shell/src/frame/chromeTheme.ts @@ -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): Record { - 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), }; } diff --git a/packages/shell/src/frame/state.ts b/packages/shell/src/frame/state.ts index 4dc5bf7..443586b 100644 --- a/packages/shell/src/frame/state.ts +++ b/packages/shell/src/frame/state.ts @@ -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 | 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); } diff --git a/packages/shell/src/store.css b/packages/shell/src/store.css index 55d3432..dabb097 100644 --- a/packages/shell/src/store.css +++ b/packages/shell/src/store.css @@ -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); } diff --git a/packages/shell/tests/themeTokens.test.ts b/packages/shell/tests/themeTokens.test.ts deleted file mode 100644 index f1ed69f..0000000 --- a/packages/shell/tests/themeTokens.test.ts +++ /dev/null @@ -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(/(? 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); - } -}); diff --git a/packages/waybetter/icons.schema.json b/packages/waybetter/icons.schema.json index 2c2ac7e..d10e6b5 100644 --- a/packages/waybetter/icons.schema.json +++ b/packages/waybetter/icons.schema.json @@ -211,15 +211,6 @@ }, "lock": { "$ref": "#/$defs/glyph" - }, - "history": { - "$ref": "#/$defs/glyph" - }, - "studio": { - "$ref": "#/$defs/glyph" - }, - "articles": { - "$ref": "#/$defs/glyph" } }, "maxProperties": 64, diff --git a/packages/waybetter/theme.schema.json b/packages/waybetter/theme.schema.json index 7d97d51..f790856 100644 --- a/packages/waybetter/theme.schema.json +++ b/packages/waybetter/theme.schema.json @@ -4126,15 +4126,6 @@ }, "lock": { "$ref": "#/$defs/glyph" - }, - "history": { - "$ref": "#/$defs/glyph" - }, - "studio": { - "$ref": "#/$defs/glyph" - }, - "articles": { - "$ref": "#/$defs/glyph" } }, "maxProperties": 8