1.0.1: theme performance, full Windows 98/Classic bars, the app menu on each pane's wB, restored windows fit their monitor

This commit is contained in:
CRTkafa 2026-10-01 12:06:34 +07:00
parent 12eb3d9bee
commit 7e2515aa42
14 changed files with 104 additions and 32 deletions

View file

@ -1,5 +1,15 @@
# Changelog
## 1.0.1
### Fixed
- **Themes no longer slow X down.** A few theme rules made the browser restyle most of the page every time X added something (about 440 elements per change; now about 2). Scrolling with any theme on is smooth again. A test now fails any theme that does this.
- **Windows 98 and Classic bars show every item.** X made each menu item as wide as the whole menu, so a bottom or top bar showed only Home.
- **The app menu opens from each pane's wB button** at the top right (Alt+F, F10 and the account menu still open it). The titlebar logo is a plain logo again.
- **A restored window always fits its monitor**, including when a second monitor is smaller or gone.
- **macOS: Cmd+F always opens find** for the page in front, even when the app's own tab was active.
- The "help translate" link points at the public repository.
## 1.0.0
The first public release. wayBetter for X is the first app on the open

View file

@ -1,6 +1,6 @@
{
"name": "@better-twttr/desktop",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"license": "GPL-3.0-or-later",
"type": "module",

View file

@ -163,8 +163,20 @@ function inspect() {
sideScroll: scroller.scrollWidth - scroller.clientWidth,
};
}
// 5. The rail (and a theme's bar made of it): every visible item answers a click at its center.
const navMiss = [];
for (const item of document.querySelectorAll('header[role="banner"] nav[role="navigation"] > :is(a,button), header[role="banner"] [data-testid="SideNav_NewTweet_Button"]')) {
const r = item.getBoundingClientRect();
// Off-window items count: a bar that pushes items out of the window loses them.
if (r.width < 2 || r.height < 2 || dimmed(item)) continue;
const x = r.left + r.width / 2, y = r.top + r.height / 2;
const hit = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight ? document.elementFromPoint(x, y) : null;
// X's own layers (a modal's mask, a menu) cover the rail on purpose.
if (hit?.closest('#layers')) continue;
if (!hit || !item.contains(hit)) navMiss.push({ name: item.getAttribute('data-testid') || item.getAttribute('aria-label') || item.tagName, over: hit ? (hit.getAttribute('data-testid') || hit.tagName + (hit.id ? '#' + hit.id : '') + '.' + String(hit.className).slice(0, 40)) : 'outside the window' });
}
return {
chat, short: texts.filter(t => t.need && t.ratio < t.need).slice(0, 4),
navMiss, chat, short: texts.filter(t => t.need && t.ratio < t.need).slice(0, 4),
iconCount: icons.length, faintIcons: icons.filter(i => i.ratio < 3).length, worstIcon: icons[0] ?? null,
layers,
texts: texts.slice(0, 6), low: texts.filter(t => t.ratio < 4.5).length, lowLinks: texts.filter(t => t.tag === 'A' && t.ratio < 4.5).length, fail: texts.filter(t => t.ratio < 3).length, textCount: texts.length,
@ -173,12 +185,36 @@ function inspect() {
};
}
/** Elements the browser restyles per DOM insertion (and removal) in the page's main list: a theme rule with a
* page-wide :has() makes it the whole page (1.0.0: about 440 on x.com, against 1.5 without a theme). */
const RESTYLE_BUDGET = +(args.restyleBudget ?? 5);
async function restylePerInsertion(page, cdp) {
const events = [];
const collect = event => events.push(...event.value);
cdp.on('Tracing.dataCollected', collect);
await cdp.send('Tracing.start', { traceConfig: { includedCategories: ['devtools.timeline'] }, transferMode: 'ReportEvents' });
const ops = await page.evaluate(() => {
const host = document.querySelector('[data-testid="cellInnerDiv"]')?.parentElement ?? document.querySelector('[data-testid="primaryColumn"]') ?? document.querySelector('main') ?? document.body;
document.body.offsetHeight;
for (let i = 0; i < 20; i++) { const d = document.createElement('div'); host.appendChild(d); d.offsetHeight; d.remove(); document.body.offsetHeight; }
return 40;
});
const complete = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve));
await cdp.send('Tracing.end');
await complete;
cdp.off('Tracing.dataCollected', collect);
let elements = 0;
for (const event of events) if (event.name === 'UpdateLayoutTree' && event.args?.elementCount) elements += event.args.elementCount;
return +(elements / ops).toFixed(1);
}
const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined });
const results = [];
const failures = [];
const baselineWarnings = [];
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const cdp = await page.context().newCDPSession(page);
// Measure settled colors: theme motion only runs without a reduced-motion preference.
await page.emulateMedia({ reducedMotion: 'reduce' });
const errors = [];
@ -201,6 +237,8 @@ try {
await page.evaluate(preset => window.__bt.apply({ preset }), theme);
await page.evaluate(() => new Promise(requestAnimationFrame));
const check = await page.evaluate(inspect);
// Style cost of the page changing (once per cell, theme and mode).
if (width === widthsFor(cell)[0]) check.restyle = await restylePerInsertion(page, cdp);
if (theme === 'x') baseline = check;
const problems = [];
for (const layer of check.layers) if (!layer.opaque) problems.push(`layer ${layer.name} is see-through (${layer.color})`);
@ -215,6 +253,8 @@ try {
if (!check.contentVisible) problems.push('primary content not visible');
if (baseline && check.contentWidth < Math.min(baseline.contentWidth * 0.85, baseline.contentWidth - 90)) problems.push(`content squeezed to ${check.contentWidth}px (X: ${baseline.contentWidth}px)`);
if (check.overflow > 1) problems.push(`page overflows the pane by ${check.overflow}px`);
if (check.restyle !== undefined && check.restyle > RESTYLE_BUDGET) problems.push(`each DOM insertion restyles ${check.restyle} elements (budget ${RESTYLE_BUDGET}; X: ${baseline?.restyle ?? '?'}): a page-wide selector such as :has() over the page`);
if (check.navMiss.length) problems.push(`${check.navMiss.length} rail item(s) not clickable (${check.navMiss.slice(0, 3).map(m => m.name + ' under ' + m.over).join(', ')})`);
if (check.chat) {
const c = check.chat, x = baseline?.chat;
const same = (a, b) => (!a && !b) || (!!a && !!b && Math.abs(a.left - b.left) <= 4 && Math.abs(a.width - b.width) <= 4); // a theme's column frame may take its border (up to 2px a side)

View file

@ -172,14 +172,14 @@ function nav(m) {
const glyph = `<svg viewBox="0 0 24 24" aria-hidden="true" width="26" height="26" class="r-yyyyoo" style="color: ${m.text};"><g><path d="M12 3l9 8h-3v9H6v-9H3z"></path></g></svg>`;
const item = ([testid, href, label]) => {
const tag = href ? 'a' : 'button';
return `<${tag} ${href ? `href="${href}" role="link"` : 'type="button" role="button"'}${testid ? ` data-testid="${testid}"` : ''} aria-label="${label}" class="css-175oi2r" style="text-decoration: none; border: 0; background: transparent; padding: 4px 0;"><div class="css-175oi2r r-18u37iz r-sdzlij" style="align-items: center; gap: 18px; padding: 12px;"><div class="css-175oi2r">${glyph}</div><div class="css-175oi2r fx-label-box"><span class="css-1jxf684 r-chirp r-adyw6z fx-label" style="color: ${m.text};">${label}</span></div></div></${tag}>`;
return `<${tag} ${href ? `href="${href}" role="link"` : 'type="button" role="button"'}${testid ? ` data-testid="${testid}"` : ''} aria-label="${label}" class="css-175oi2r r-16y2uox r-13qz1uu" style="text-decoration: none; border: 0; background: transparent; padding: 4px 0;"><div class="css-175oi2r r-18u37iz r-sdzlij" style="align-items: center; gap: 18px; padding: 12px;"><div class="css-175oi2r">${glyph}</div><div class="css-175oi2r fx-label-box"><span class="css-1jxf684 r-chirp r-adyw6z fx-label" style="color: ${m.text};">${label}</span></div></div></${tag}>`;
};
return `<header role="banner" class="css-175oi2r" style="align-items: flex-end; flex-grow: 1;">
<div class="css-175oi2r"><div class="css-175oi2r fx-nav" style="position: sticky; top: 0; height: 100vh; padding: 0 8px; box-sizing: border-box;"><div class="css-175oi2r" style="height: 100%; justify-content: space-between;">
<div class="css-175oi2r" style="gap: 4px; padding-top: 8px;">
<h1 role="heading" class="css-175oi2r" style="margin: 0;"><a href="/home" aria-label="X" role="link" class="css-175oi2r" style="padding: 12px;"><div class="fx-icon" style="color: ${m.text};"></div></a></h1>
<nav role="navigation" aria-label="Primary" class="css-175oi2r">${items.map(item).join('')}</nav>
<a href="#" role="link" data-testid="SideNav_NewTweet_Button" class="css-175oi2r r-sdzlij fx-post" style="background-color: ${m.fill}; border-color: rgba(0, 0, 0, 0); margin-top: 12px; min-height: 52px; align-items: center; justify-content: center; text-decoration: none;"><div class="css-175oi2r" style="color: ${m.onFill};"><span class="css-1jxf684 r-chirp r-b88u0q r-a023e6">Post</span></div></a>
<div class="css-175oi2r r-13qz1uu"><nav role="navigation" aria-label="Primary" class="css-175oi2r r-13qz1uu">${items.map(item).join('')}</nav></div>
<div class="css-175oi2r r-13qz1uu" style="align-items: center;"><a href="#" role="link" data-testid="SideNav_NewTweet_Button" class="css-175oi2r r-sdzlij fx-post" style="background-color: ${m.fill}; border-color: rgba(0, 0, 0, 0); margin-top: 12px; min-height: 52px; align-items: center; justify-content: center; text-decoration: none;"><div class="css-175oi2r" style="color: ${m.onFill};"><span class="css-1jxf684 r-chirp r-b88u0q r-a023e6">Post</span></div></a></div>
</div>
<div class="css-175oi2r" style="padding: 12px 0;"><button type="button" data-testid="SideNav_AccountSwitcher_Button" aria-label="Account menu" class="css-175oi2r" style="border: 0; background: transparent;"><div class="fx-avatar"></div></button></div>
</div></div></div></header>`;

View file

@ -290,7 +290,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "better-twttr"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"argon2",
"base64 0.22.1",

View file

@ -1,6 +1,6 @@
[package]
name = "better-twttr"
version = "1.0.0"
version = "1.0.1"
description = "X, extended: themes, plugins and recoverable drafts"
license = "GPL-3.0-or-later"
edition = "2021"

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "wayBetter for X",
"version": "1.0.0",
"version": "1.0.1",
"identifier": "app.bettertwttr.desktop",
"build": {
"beforeDevCommand": "bun run dev",

View file

@ -311,7 +311,9 @@ function layerCss(palette: Partial<Palette>): string {
* all (checked live, 2026-09-28: `document.body.children` on `x.com/` logged out is plain `<div>`s),
* so that tag's absence is how a signed-out page is told apart from X's real app shell.
*/
const SIGNED_OUT_ROOT = 'body:not(:has(main[role="main"]))';
// Signed out: no X app shell. A page flag the engine keeps (THEME_FLAGS), never `body:not(:has(main))`:
// a page-level :has() made the browser re-match every element on each node X inserted (1.0.0, all themes).
const SIGNED_OUT_ROOT = 'html:not([data-wb-app]) body';
function signedOutCss(palette: Partial<Palette> & { page?: string }): string {
const bg = palette.bg;
const text = palette.text;
@ -353,8 +355,16 @@ function fullPalette(palette: Partial<Palette>): Palette | null {
return bg && surface && elevated && border && text && muted && accent ? { ...palette, bg, surface, elevated, border, text, muted, accent } : null;
}
/** Page states the theme's CSS keys on (`html[data-wb-app]`, `html[data-wb-chat]`), kept by the engine. */
export const THEME_FLAGS = {
/** X's app shell (signed in). */
app: 'main[role="main"]',
/** X's chat app, in the page or as its embed host. */
chat: '[data-testid="dm-container"],[data-testid="xchatEmbedRoute"]',
} as const;
/** Pure: override rules for X's palette-colored rules plus the variable and page-level overrides. */
export function buildThemeCss(rules: Iterable<RuleLike>, source: Source, palette: Partial<Palette> & { page?: string }, vars: Record<string, string> = {}): string {
export function buildThemeCss(rules: Iterable<RuleLike>, source: Source, palette: Partial<Palette> & { page?: string }, vars: Record<string, string> = {}, options: { signedIn?: boolean } = {}): string {
// X's newer rules use `var(--x-white)` and Tailwind's `color-mix()` opacity form: read them as the color they paint.
const resolve = (value: string) => resolveDeclared(value, vars);
const out: string[] = [];
@ -447,7 +457,9 @@ export function buildThemeCss(rules: Iterable<RuleLike>, source: Source, palette
}
if (full) out.push(chatSurfaceCss(full));
out.push(...badges);
if (palette.bg && palette.text) out.push(signedOutCss(palette));
// Signed-out pages only: their rules carry :has() (X's sign-in buttons), and any :has() over the page
// costs every insertion a full restyle, so a signed-in page (engine flag `app`) never gets them.
if (palette.bg && palette.text && !options.signedIn) out.push(signedOutCss(palette));
return out.join('\n');
}
@ -498,11 +510,11 @@ export function resolvedTheme(choice: ThemeChoice, mode: 'light' | 'dim' | 'dark
export const pageFonts = (theme: ThemeSpec): ResolvedFonts => resolvedFonts(theme.font, X_FONT_STACK);
/** Everything a theme adds to X: palette remapping first, then type/shape/layout/canvas/motion. */
export function buildFullCss(rules: RuleLike[], choice: ThemeChoice, mode: 'light' | 'dim' | 'dark', vars: Record<string, string> = {}): string {
export function buildFullCss(rules: RuleLike[], choice: ThemeChoice, mode: 'light' | 'dim' | 'dark', vars: Record<string, string> = {}, flags: readonly string[] = []): string {
const theme = resolveTheme(choice);
const palette = paletteFor(choice);
const parts: string[] = [];
if (palette) parts.push(buildThemeCss(rules, X_PALETTES[mode], { ...palette, page: theme.colors?.page }, vars));
if (palette) parts.push(buildThemeCss(rules, X_PALETTES[mode], { ...palette, page: theme.colors?.page }, vars, { signedIn: flags.includes('app') }));
if (!isNeutral(theme)) parts.push(buildStructureCss(rules, theme, structureColors(theme, mode)));
return parts.join('\n');
}
@ -528,6 +540,7 @@ export function installTheme(win: Window, options: ThemeRuntimeOptions = {}): Th
siteVars: [...X_PRIMITIVE_VARS, '--x-blue-500'],
styleId: 'bt-theme',
name: 'wayBetter for X',
flags: THEME_FLAGS,
// Theme engine v2: components, variants, decoration and icons (themeStructure.ts).
// The owner's icon pick travels in the theme choice (persisted natively); `setIcons` only previews one.
staticCss: (choice, mode) => buildThemeStructureCss(resolveTheme(choice), resolvedTheme(choice, mode), { ...env, icons: choice.icons ?? env.icons, packs: choice.packs }),

View file

@ -31,8 +31,11 @@ export const X_ICONS: IconTable = {
views: { ctx: `${ACT} a[href$="/analytics"] svg`, group: 'actions' },
bookmark: { ctx: `${ACT} [data-testid="bookmark"] svg`, group: 'actions' },
'bookmark.on': { ctx: `${ACT} [data-testid="removeBookmark"] svg`, group: 'actions' },
share: { ctx: `${ACT} > div:last-child [role="button"] svg`, group: 'actions' },
media: { ctx: `${TOOLS} [role="button"]:has(+ input[data-testid="fileInput"]) svg,${TOOLS} [data-testid="fileInput"] ~ [role="button"] svg`, group: 'composer' },
// X's share button: the action bar's menu button without a test id (repost has one). Not `> div:last-child`:
// a structural pseudo-class there made every node appended to a timeline restyle the last post.
share: { ctx: `${ACT} [role="button"][aria-haspopup="menu"]:not([data-testid]) svg`, group: 'actions' },
// No `[role="button"]:has(+ input)`: that :has() restyled the whole page on every insertion (1.0.0).
media: { ctx: `${TOOLS} [data-testid="fileInput"] ~ [role="button"] svg`, group: 'composer' },
gif: { ctx: `${TOOLS} [data-testid="gifSearchButton"] svg`, group: 'composer' },
poll: { ctx: `${TOOLS} [data-testid="createPollButton"] svg`, group: 'composer' },
emoji: { ctx: `${TOOLS} [aria-label="Add emoji"] svg`, group: 'composer' },

View file

@ -11,9 +11,8 @@ import type { VariantSkins } from '../../../../waybetter/packages/runtime/src/th
const POST = 'article[data-testid="tweet"]';
const COLUMN = '[data-testid="primaryColumn"]';
/** The main column on every route but chat (a drawn bar above X's chat app would push its composer out of the window). */
/** X's chat app: in the page, or the host of its embed (an open shadow root the page's :has() cannot look into, 2026-10-01). */
const CHAT_APP = ':is([data-testid="dm-container"],[data-testid="xchatEmbedRoute"])';
const FRAMED = `[data-testid="primaryColumn"]:not(:has(${CHAT_APP}))`;
// Keyed on the engine's page flag (THEME_FLAGS in theme.ts), never `:has()` over the page: see SIGNED_OUT_ROOT.
const FRAMED = 'html:not([data-wb-chat]) [data-testid="primaryColumn"]';
const RAIL = 'header[role="banner"]';
const NAV = `${RAIL} nav[role="navigation"]`;
const ACTIONS = ['reply', 'retweet', 'unretweet', 'like', 'unlike', 'bookmark', 'removeBookmark'].map(id => `[data-testid="${id}"]`).join(',');
@ -38,7 +37,9 @@ export const X_PARTS: PartMap = {
buttonPrimary: { sel: '[data-testid="tweetButtonInline"],[data-testid="tweetButton"],[data-testid="SideNav_NewTweet_Button"]', text: ':is([data-testid="tweetButtonInline"],[data-testid="tweetButton"],[data-testid="SideNav_NewTweet_Button"]) :is(span,div[dir])' },
buttonSecondary: { sel: '[data-testid$="-follow"],[data-testid$="-unfollow"]', text: ':is([data-testid$="-follow"],[data-testid$="-unfollow"]) span' },
// Live 2026-10-01: the painted box is three levels above the input's label (a div, no <label>).
search: { sel: 'form[role="search"] div:has(> div > div > [data-testid="SearchBox_Search_Input_label"])', text: 'form[role="search"] input' },
// X's search pill: the form's grandchild (the label sits three levels below it). No :has(): a page-wide
// :has() made every node X inserted restyle the whole page (1.0.0).
search: { sel: 'form[role="search"] > div > div', text: 'form[role="search"] input' },
composer: { sel: '[data-testid="tweetTextarea_0"]', text: '[data-testid="tweetTextarea_0"] :is(span,div)' },
composerTool: { sel: '[data-testid="toolBar"] nav [role="button"]', text: '[data-testid="toolBar"] nav [role="button"] svg' },
sidebarCard: { sel: '[data-testid="sidebarColumn"] div:has(> :is(aside[role="complementary"],section[role="region"]))' },
@ -79,7 +80,7 @@ export type XSkinContext = {
/** Only above X's narrow breakpoint, never on chat's own row, never while the pane's rail is parked. */
const WIDE = '@media (min-width:700px)';
const NOT_CHAT = `html:not(:has(${CHAT_APP}))`;
const NOT_CHAT = 'html:not([data-wb-chat])';
/** How X looks in each layout variant. */
export const X_SKINS: VariantSkins<XSkinContext> = {
@ -139,7 +140,11 @@ ${NOT_CHAT} ${RAIL} > div{width:100% !important;height:56px !important}
${NOT_CHAT} ${inner}{position:static !important;width:100% !important;height:56px !important;max-width:1280px;margin:0 auto !important}
${NOT_CHAT} ${inner} > div{flex-direction:row !important;align-items:center !important;justify-content:space-between !important;height:56px !important;width:100% !important;padding:0 16px !important;overflow:visible !important}
${NOT_CHAT} ${inner} > div > div{flex-direction:row !important;align-items:center !important;height:56px !important;gap:4px}
/* The same for the groups inside the row (the nav and the Post button sit in full-width wrappers). */
${NOT_CHAT} ${inner} > div > div > div{width:auto !important;flex:0 1 auto !important;min-width:0 !important}
${NOT_CHAT} ${NAV}{flex-direction:row !important;align-items:center !important;flex-wrap:nowrap !important;gap:2px}
/* X sizes each rail item to the rail (width:100%, flex-grow:1): in a row that is one window per item. */
${NOT_CHAT} ${NAV} > :is(a,button){width:auto !important;flex:0 1 auto !important;min-width:0 !important}
${NOT_CHAT} ${NAV} > :is(a,button) > div{padding:8px 10px !important}
${NOT_CHAT} ${RAIL} [data-testid="SideNav_NewTweet_Button"]{margin:0 0 0 8px !important;width:auto !important;min-height:40px !important;padding:0 16px !important}
${NOT_CHAT} ${RAIL} [data-testid="SideNav_AccountSwitcher_Button"]{margin:0 !important}

View file

@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { contrastRatio, hexToRgb } from '../../../waybetter/packages/runtime/src/themeApi.ts';
import { PRESETS, X_PALETTES, buildThemeCss, detectXPalette, hexToHslTriple, mapColor, mapInlineColor, normalizeTheme, paletteFor, type ThemeChoice } from '../src/inject/theme.ts';
import { PRESETS, THEME_FLAGS, X_PALETTES, buildThemeCss, detectXPalette, hexToHslTriple, mapColor, mapInlineColor, normalizeTheme, paletteFor, type ThemeChoice } from '../src/inject/theme.ts';
import { BUILTIN_THEMES } from '../src/inject/themeSpec.ts';
const rule = (selectorText: string, style: Record<string, string>) => ({ selectorText, style: { getPropertyValue: (name: string) => style[name] ?? '' } });
@ -234,9 +234,9 @@ test("X's signed-out flow (login/signup, no <main>) gets one legible surface ins
const palette = paletteFor(choice(preset.id))!;
const css = buildThemeCss([], X_PALETTES.dark, palette);
const lines = css.split('\n');
const root = lines.find(l => l.startsWith('body:not(:has(main[role="main"]))){') || l.startsWith('body:not(:has(main[role="main"]))'));
const root = lines.find(l => l.startsWith('html:not([data-wb-app]) body){') || l.startsWith('html:not([data-wb-app]) body'));
assert.ok(root, `${preset.id}: no signed-out root rule`);
assert.ok(lines.some(l => l.includes('background-color:' + palette.bg + ' !important') && l.startsWith('body:not(:has(main[role="main"]))')), `${preset.id}: canvas`);
assert.ok(lines.some(l => l.includes('background-color:' + palette.bg + ' !important') && l.startsWith('html:not([data-wb-app]) body')), `${preset.id}: canvas`);
assert.ok(lines.some(l => l.includes('[role="dialog"]') && l.includes(`background-color:${palette.surface} !important`)), `${preset.id}: dialog surface`);
const textRule = lines.find(l => l.includes(':is(h1,h2,h3,h4,p,span,label,li,div){color:'))!;
assert.ok(textRule?.includes(`color:${palette.text} !important`), `${preset.id}: forced text`);
@ -258,15 +258,16 @@ test('signed-out links read against the tinted page canvas as well as the body b
const palette = paletteFor(choice(id))!;
const page = BUILTIN_THEMES.find(item => item.id === id)!.colors!.page!;
const css = buildThemeCss([], X_PALETTES.dark, { ...palette, page });
assert.ok(css.includes(`body:not(:has(main[role="main"])) a{color:${palette.text} !important}`), `${id}: link uses text color on tinted page`);
assert.ok(css.includes(`html:not([data-wb-app]) body a{color:${palette.text} !important}`), `${id}: link uses text color on tinted page`);
}
});
test('signed-out root does not fire on a real logged-in shell (chat/settings have no primaryColumn, but do have <main role="main">)', () => {
const classic = paletteFor(choice('classic'))!;
const css = buildThemeCss([], X_PALETTES.dark, classic);
assert.ok(css.includes('body:not(:has(main[role="main"]))'), 'the rule exists at all');
// The selector itself, not the render, is what keeps it off X's own app shell; assert the guard is
// exactly ":has(main[role=\"main\"])" so a route without [data-testid="primaryColumn"] still excludes.
assert.ok(css.includes(':not(:has(main[role="main"]))'));
assert.ok(css.includes('html:not([data-wb-app]) body'), 'the rule exists at all');
// The guard is the engine's page flag for X's app shell (<main role="main">, on chat and settings too),
// never a page-level :has() (every node X inserted re-matched the whole page, 1.0.0).
assert.equal(THEME_FLAGS.app, 'main[role="main"]');
assert.ok(!css.includes('body:not(:has('), 'no page-level :has() gate');
});

View file

@ -22,7 +22,7 @@
},
"apps/desktop": {
"name": "@better-twttr/desktop",
"version": "1.0.0",
"version": "1.0.1",
"dependencies": {
"@better-twttr/contracts": "workspace:*",
"@fontsource-variable/geist": "^5.3.0",

View file

@ -1,6 +1,6 @@
{
"name": "better-twttr",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"license": "GPL-3.0-or-later",
"type": "module",
@ -28,7 +28,7 @@
"i18n:extract": "node scripts/i18n-extract.mjs",
"privacy:scan": "node scripts/privacy-scan.mjs",
"check": "bun run typecheck && bun run test && bun run test:rust",
"build:server": "bun run --cwd packages/contracts build && bun run --cwd apps/api build && bun run --cwd apps/web build"
"build:server": "git submodule update --init waybetter && bun run --cwd packages/contracts build && bun run --cwd apps/api build && bun run --cwd apps/web build"
},
"engines": {
"node": ">=24.0.0",

@ -1 +1 @@
Subproject commit b3f160e4c159632e83f27e19939f35f04e6cab7d
Subproject commit b3f6960dc3c84671ae53697c9282cc5b08742282