A pure measurement over a rendered document (fixtures and live): text and icon contrast over the
painted backdrop, X colour leakage, truncated bar/nav/button labels, bar geometry and hit tests, X line
icons under a pack, empty and nested frames, popover bounds, target sizes. Grew out of theme-qa/probe.js.
themeInk.ts: OKLCH lightness search toward the pole with headroom, hue kept, chroma
cut only for the gamut; several fills (gradient ends, glass grounds); compositing.
reach() now runs on it (8% headroom when a colour has to move, for the site's tints
mixed from these surfaces). Property test: 5,000 random pairs reach 4.5:1 and 3:1;
the live 1.0.2 cases ("New" pill 1.19:1, HoverLabel 2.7:1, like pink 3.84:1) pass.
A glyph made only of axis-aligned rectangles (M, h, v, z) is pixel art: its SVG gets
shape-rendering="crispEdges", so a 16-cell glyph stays sharp at sizes that are not a whole multiple
of its grid (X's rail draws 26px icons, the action bar 18.75px).
Live 2026-10-01: X's search-field icon is a 28x16 box with 12px of padding; sized at 100% of that width a
pixel glyph lost its top and bottom and sat left of X's own icon. Glyphs are now sized with contain (a
scaled pack by the box's height) and placed in the content box, for masks and images alike.
With an ancestor in front (:root:root ::selection) every node the site inserts cost extra style
recalculation: Terminal measured 4 elements per insertion on the theme matrix (6 after a bar theme,
over the budget of 5); a bare ::selection measures 2 (3 after a bar theme). The rule did not predate
the design pass's changes; it was in Terminal's decor from the start.
- Rich sentences (a link or button inside text) can be replaced by data: the inventory gives a rich entry `slots`, a pack value places the site's own elements with {e0}, {e1}, ... (each once, the last may be left out; no markup, plural or other parameters). The engine returns a pieces + element order template; the page inventory carries `rich` slot counts.
- Packs may carry `when: { languages }` (applies only while the site shows one of them; `en` matches `en-GB`) and `rewrite: { words }` (whole-word rewording of the site's own text, messages keep their parameters' values; no sentences of the site in the pack). The hook tells the engine the site's language; a rewording pack makes the delivery carry the whole inventory.
- Apps can ship built-in packs (BuiltInPacks) delivered before installed ones; syncStringPacks hands the tabs a delivery at start only when it changed.
- Translate mode words are a function the app supplies (asked at each draw) and gain a `plural` word; host validates `when` and `rewrite` envelopes.
- Theme doctor: every finding says where (token path or CSS rule), what and how to fix, as catalog keys wb.doctor.*; the Theme Builder shows the full report; the import hint is translated. Doctor words are in the 17 supported catalogs.
- waybetter strings check describes conditions and rewrites.
A theme rule gated by html:has(...) / body:not(:has(...)) made the browser
restyle the whole page for every node the site inserted. Measured live on
x.com under any theme: about 440 elements restyled per insertion, against 1.5
without a theme; with the gates gone, 2.5.
Sites name page states as selectors (ThemeRuntimeSite.flags). The engine
keeps html[data-wb-<name>] in step, checked once per DOM hub frame and also
inside the site's open shadow roots. It passes the flags that hold to css(),
so CSS for one state can be left out of the other. Changing flags rebuild the
theme (they are part of its render key). Tests: pageFlags, flagAttribute.
Live on X's chat embed: --x-white is declared only inside the embed's root, so
the document's values left .text-white unresolved and the sent bubble's label
stayed white on a light accent (2:1 under Nord). Each root's theme now reads
the site's primitive variables from the root's own content (our sheet off
while reading), falling back to the document's.
X renders its chat inside the open shadow root of its embed host since
2026-10-01; the theme's <style> in <head> never reached it. For each root the
DOM hub names (domRoots), the engine now builds the theme from that root's own
rules (collectRootRules: adopted sheets and <style> elements, its own sheet
excluded), adds the static sheet, adopts it last among the root's sheets,
rebuilds it when the root's rules change and takes it off when the theme is
off or the root is gone. Test: collectRootRules.
X renders its chat inside an open shadow root since 2026-10-01 (the XChatEmbed
host [data-testid=xchatEmbedRoute]); the hub's document observer saw nothing
of it, selector observers found nothing, feature sheets in <head> did not
reach it and probes could not see it.
A site adapter may name shadowHosts. The hub's one MutationObserver then also
observes each such root (picked up on the next page mutation, or a few timed
retries while a host has no root yet; dropped with the observer), selector
observers search the roots too, domRoots(win) gives features the roots, a
feature's ctx.css sheet is also adopted by each root (CSP-proof, updated in
place, taken off on stop), and probes count matches inside the roots.
Tests: hub observes and searches a root that appears later with one
observer; registry adopts and removes the sheet and a probe inside a root is
never a miss.
themeDoctor(theme) names the lighter mechanism for what a theme's CSS does
(radius, bevel, hiding, literal colors, fonts), its performance costs (:has,
universal selectors, blur, size) and accessibility costs (outline removed,
unguarded motion, small post text, moved navigation), plus contrast fixes.
Theme import adds the count and the first hint to its message. Tested.
Appearance > Icons gets Import icon pack: a *.icons.json file is parsed by the
runtime sanitizer and shown on an install screen (name, author, licence,
source, credits, every glyph, size, data only) before it joins the persisted
theme choice (packs, at most 8, validated natively for shape and size) and
the Icons picker. Imported packs can be removed again.
- ThemeChoice gains an optional icons field (absent: follow the theme's pack; site: the site's
own icons; else a pack id), validated natively as a slug (waybetter-host theme.rs, with a
test) and kept by per-theme choices; variants is an override group natively too.
- Appearance: an Icons row (Follow the theme / the site's own icons / the site's packs) when the
site lists packs; English words in the catalog.
- The chrome's glyphs follow the owner's pick as well as the theme's suggestion.
menuMessages.en.json stays the English source of the menu's words; en.json now carries them too
(the i18n test takes them into the source union) and 48 catalogs translate all 51 menu words and
the seven new mode words (plural forms per language for the match count).
WebView2 runs document-start scripts before the document has an element, so
injectCSS threw ("reading 'appendChild'") for every CSS feature that was on at
start, e.g. hiding promoted posts, until the next flags delivery restarted it.
Such styles now wait (one MutationObserver for all of them), are attached as
soon as the document has a root, and get their CSP check then; a style cleared
while waiting is never attached.
A mode is a starting point, never a lock and never applied again on its own. modeStatus() (runtime)
compares the flags with the mode's preset: custom, what re-applying turns on and off, and which of
those the preset gained in an update since it was applied. mode_set keeps the preset of that moment
(modes.json "preset", flag names only, bounded; forgotten when the mode changes). Settings, the
first-run screen and the app menu's Mode row show Custom; Re-apply lists what it turns on and off
before it writes anything; a preset that grew is mentioned once on the mode row (Review), never applied.
Tests: exact match, changed by the owner, preset grew, choice made before the preset was kept.
The site adapter's staticCss(choice, mode) is written to its own <style> (wb-theme-static), rebuilt
only when the theme or the site's mode changes or on refresh(); the palette pass never reads it back
as the site's rules, and the first-frame snapshot carries it.
A theme may now say how each piece of a site is built, not only one radius and one density:
- components (themeComponents.ts): 36 generic parts with clamped tokens (radius, border incl.
98.css-style bevels, fill by color role / glass / gradient, padding, sizes, type, shadow, shape,
hover/active/focus), presets and parents; compiled over a site's part map as soft values
(--wb-part-*) and rigid rules; text on a chosen fill keeps its contrast.
- variants (themeVariants.ts): fixed words on six axes; the owner holds an axis or switches layout
changes off; the look of a variant is the site's skin.
- decoration (themeDecor.ts): dividers, chrome (title bar, masthead, prompt), a focus ring that is
always visible, selection, scrollbars, links, motion under reduced-motion.
- icon packs (themeIcons.ts): a standalone document of glyphs by semantic name (path data, colored
layers or a small PNG, never an SVG document), with license, source and credits; a theme only
names a pack; the owner's icon choice is separate; drawn as CSS masks of the current color over
the site's own svg boxes, behind forced-colors and mask-support guards, per group.
- a theme document gains the optional groups components, variants, decor, icons and
colors.actions; a theme without them is sanitized to exactly what it was.
- theme.schema.json and the new icons.schema.json are generated from the same tables
(scripts/theme-schema.mjs; a test fails when they are stale).
A site may color an icon or text with one of its border grays (X's composer toolbar: 1.7:1 on its
own black). Placed by axis position it landed between a theme's background and its secondary
text, under every contrast a theme guarantees. In text usage that stretch of the axis (above the
most raised surface, below secondary text) now maps to the theme's secondary text; surface-dark
foregrounds (labels on a filled surface), borders and backgrounds are unchanged.
The entry opens the notes in the page; its keywords carry the paused flags of that area. English
for the three new words; the other catalogs follow with the words step.
- create-waybetter: the generated app has a feature table (one area, one feature, three modes), a feature module, the registry in its runtime, the cards and the first-run screen in its panel
- waybetter i18n rename --map <file> [--copy]: keys move or are copied in every catalog with each language's own value
- feature context: win (no globals in a module), text() for a feature's words (t('...') is read as a whole catalog key by the i18n tools)
- probes: `when` (only counts while the part of the page it lives in is there)
- cards: a caret, an empty area says its features come later; the first-run screen takes the whole panel
- PanelSite.features: the app's feature table; AreaPlugins draws one card per area (switch, count, grouped features, Settings button that runs the area's command in the site tab) and the core flags as Fixes
- a feature still backed by an old plugin file is switched through plugin_set_enabled from its card and left out of the JavaScript plugins list
- Modes: Onboarding (three cards, recommended preselected; an existing setup may be kept) and ModeSettings with the keep-my-changes question; openOnboardingWhenDue for ShellSite.start
- flags are saved sparsely (only what differs from the table's defaults)
- PanelSite.appearance.extra; palette commands of an area are listed under its name
- the kit's words for feature settings and health notes; English in the framework catalog
- featureTable.ts: areas, per-feature flags, core flags, modes as presets; sparse storage, ownership (an area that is off turns off what it owns), mode switching that keeps or drops manual changes, table validation
- featureModules.ts: createFeatureRegistry (flag + surface gating, scoped context, palette commands under the area with a budget of 5, settings dialog, probes, hotfix selector overrides and kill flags)
- settingsForm.ts: a settings schema normalizes stored values and draws the controls from the UI kit
- domHub observeSelector; api.observe goes through the shared hub (a root of the caller's own keeps its observer)
- createFeatureFlags: a delivery is the whole state (a flag it does not name is at its default), resolve() for ownership, current()
wayBetter for X took My Space (profile customization) out; the framework only named it in
comments as an example of an app's own section and runtime member.
Moved from wayBetter for X: 27 messages in 51 languages, and windows/languages/<Language>.nsh for the 17 NSIS languages Tauri does not ship (scripts/installer-languages.mjs writes and checks them).
What every app's page runtime entry did by hand (moved from wayBetter for X's inject/entry.tsx):
- siteRuntime.ts: pageInvoke (the IPC bridge read at call time), createFeatureFlags (parts that
install on enable and dispose on disable, the flags native code delivers, a deck column's
overrides on top of them, recorded at once and applied when <html> exists) and
installSiteRuntime (the runtime object with the members native code calls, the plugin API views,
developer-mode errors, its window names, the queued native deliveries, the command report).
- pageStrings.ts: the page's language (English built in, deliveries checked as data, the
bundled-plugin namespace rule).
- panelButton.ts: the way into the panel from the page (the site's own navigation entry when the
adapter mounts one, else a floating button that hides under the site's overlays and sits above
its bottom bar).
- commandReport.ts: the palette command report and developer-mode start errors.
- tests/siteRuntime.test.ts.
- 668 wb.* messages for the panel (navigation, settings search, accounts, updates, Appearance, the
Theme Builder, fonts, plugins, custom CSS, Make, Power User, settings, backup and sync, developer
mode, the controls), moved with every translation from wayBetter for X's catalogs; `{site}`
stands where the English said "X" (49 keys; the app passes "X", so X reads the same). Keys that
named X got site-neutral names (settings.links.site*, settings.language.followSite*,
builder.leadingSite, builder.liveOnSite, fonts.asSite, make.siteDefaultLeading, features.default).
English source: shell/src/panel/panelMessages.ts.
- One translation could not take `{site}` (hu wb.settings.alerts.hint never named the site) and
falls back to English.
- locales/en.meta.json: the translator notes of the moved keys (40), among them the one fragment a
language may leave empty (wb.make.step2Before); the catalog test reads it.
- runtime/tests/i18n.test.ts: en.json is the kit, shell-model, store, shell-frame and panel source.
The site-independent theme engine moves from wayBetter for X into the framework:
- runtime/themeSpec.ts: theme document types, the schema's defaults (BASE_*), sanitizing,
import, overrides, ensureContrast, font roles/stacks, font choices, backgrounds.
- runtime/themeChoice.ts: ThemeChoice and createThemeCatalog(builtins) (normalize, base,
resolve, palette, all), choiceForId, serializeChoice.
- runtime/themeRuntime.ts: installThemeRuntime(win, site) (live CSS over the site's rules,
--wb-* vars, site color mode, first-frame snapshot), collectRules, resolvedFonts.
- runtime/fonts.ts: bundled fonts and owner-downloaded Google Fonts from bytes.
- themeApi exports hexToRgb, mix and MONO_FAMILIES (one copy: themeTokens and themeSpec use them).
- host theme.rs: ThemeChoice/ThemeScopes over a site's ThemeSite (envelope checks against
theme.schema.json, which theme a tab shows, page color, delivery script).
- 245 wb.* messages for the shell frame (tabs, address bar, new-tab page, palette, account
switcher, decks, workspaces, chips, media panel, pane themes, backup preview), moved with every
translation from wayBetter for X's catalogs; `{site}` stands where the English said "X" (10
keys; the app passes "X", so X reads the same). English source: shell/src/frame/frameMessages.ts.
- wb.install.source.store|link|file: where an install comes from, translated in every locale
(native code's label is English only).
- runtime/tests/i18n.test.ts: en.json is the kit, shell-model, store and shell-frame source.
Moved from wayBetter for X so every wayBetter app gets the Store UI; the app passes its native
bridge, theme adapter, translator and (optionally) its own controls and button classes.
- storeModel.ts: wire types of the native store commands (InstallPreview, StoreCatalogView,
LinkEvent, StoreItem with `apps`), permission badges in the site's words, categories, item state
(install/installed/update/changed locally/built in), search, free theme ids, versions, previews.
- installController.ts: the install screen's state machine (links incl. those queued before the
shell listened, dropped files, store items, cancel, confirm), generic over the app's theme format
(`ThemeAdapter`: parse, ids, facts, save) and its bridge (`StoreBackend`: invoke/listen).
- InstallDialog.tsx (`InstallHost`): the screen (Install enabled after 1 s, Enter never installs,
tabs hidden via setOverlay) and the window-wide drop target. StoreView.tsx: search, kinds,
categories, cards, permission badges, install/update/remove; default controls or the app's.
- store.css: both, themed with framework `--wb-*` tokens (documented; dark defaults).
- storeMessages.ts + every framework catalog: 120 `wb.store.*`, `wb.install.*`, `wb.permission.*`
messages moved from the app catalogs with all existing translations; the six that named X take
`{site}`; generic words the components need copied with their translations.
- npm package: exports for the new modules and store.css, optional react/react-dom peers; @types
for the typecheck. Tests: models, controller flows with a fake bridge, every looked-up key exists.
At document start (where the app installs the X runtime) there is no <html> element yet, so
the lang report never ran and "Follow X" never learned X's language (found live: siteLang
stayed null). It now reads lang again at readystatechange/DOMContentLoaded, and a report
that fails (no bridge yet) is retried with a bounded backoff. Tests.
Kit and shell-model words (18 keys) per locale, machine-drafted except English. Tests cover
locale normalization, fallback chains, direction, ICU-lite formatting and quoting, the runtime,
catalog rules (plural-only arguments, missing forms, allowEmpty), plugin strings, and that every
framework catalog passes the checks.
- runtime/i18n.ts: ICU-lite messages (placeholders as text, Intl.PluralRules plurals with exact
forms, selectordinal, select, number/date/time), fallback chains (pt-BR -> pt -> en, ar-x-fm ->
ar), X and legacy code aliases, RTL by locale, lazy catalogs, live onChange. Apostrophes are
literal except right before { } # or another apostrophe.
- runtime/i18nCheck.ts: rules for contributed catalogs (shape, keys, syntax, placeholder parity,
select options, markup/control characters, lengths). An argument English only uses to choose a
plural form may be dropped; missing plural forms are warnings; meta "allowEmpty" fragments.
- runtime/pluginI18n.ts: api.i18n bound per plugin (register/t/locale/dir/onChange/number/date/
relative/list); the UI kit's own words follow the app's language. pageLang.ts reports the
site's <html lang>. shell/messages.ts: shell-model words.
- waybetter-host i18n.rs: NativeStrings (validated plain text, caps, one-pass {name} fill, page
payload by prefix), system_locales via sys-locale; TabState.lang.
- JSON Schemas for catalogs and translator notes; npm exports waybetter/i18n, /i18n/check,
/i18n/plugin.
- styles.ts: adoptStyles(root, css) adopts a constructed stylesheet into a shadow root or document
(not governed by a page's CSP, parsed once, stays with the root when its host is moved);
<style> fallback where constructable sheets are missing or refused.
- UI kit: the kit's shadow root and each dialog's `css` use it (dialog CSS goes with the dialog).
- Plugin API: api.adoptCSS(root, css) and scope.adoptCSS (removed on dispose); injectCSS keeps its
<style> and, when a CSP leaves it without a sheet, applies the same rules as an adopted sheet.
- tests/styles.test.ts.
- host plugins.rs: PERMISSIONS, parse_permissions, ScriptPlugin.permissions/problems (None = legacy,
full API), validate_plugin (file name, 2 MiB, UTF-8 text without binary bytes, header with @name
and @version, valid @permissions, network:none refused when the code calls network APIs),
source_findings, PluginStore::install (atomic rename, never enables, keeps the enabled state on
update) / remove / is_enabled / file_hash. start/stop scripts run the module body, construction,
start() and stop() inside window.<global>.withPlugin(id, grants, fn) when the runtime has it.
- runtime permissions.ts: parsePermissions, scopePluginApi (undeclared gated members throw a
PluginPermissionError; scope().ui gated like api.ui), createPluginContext (the view of the plugin
whose code runs; legacy = full API). A declaration and guard rail, not a sandbox.
- The `commands` permission name comes from the parallel palette-commands work (its API lands there).
The kit draws in one shadow DOM host in the active theme's colors, corners and font and repaints
live; accessible dialogs (focus trap and return, Esc/Enter rules, alertdialog for alerts and
confirms), stacking with inert, toasts with actions, reduced motion, text-only content (ui.h),
and scope.ui closes a plugin's dialogs on dispose. api.toast routes to it.
Drop private from @waybetter/runtime and @waybetter/shell (root stays
private), add per-module exports with types/import conditions backed by
a tsc build (rewriteRelativeImportExtensions rewrites .ts specifiers to
.js in dist), files/sideEffects/engines/keywords/publishConfig, a
prepublishOnly that builds and tests, and a README + copied LICENSE per
package. Root gains build:packages and pack:check scripts. dist/ is
gitignored; the desktop app keeps importing raw src/*.ts by relative
path, unaffected.