diff --git a/CHANGELOG.md b/CHANGELOG.md index d06c0f8..c66e04b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 60c712a..ec416ee 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/scripts/theme-matrix.mjs b/apps/desktop/scripts/theme-matrix.mjs index ec28538..bfe4ddb 100644 --- a/apps/desktop/scripts/theme-matrix.mjs +++ b/apps/desktop/scripts/theme-matrix.mjs @@ -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) diff --git a/apps/desktop/scripts/theme-matrix/fixtures.mjs b/apps/desktop/scripts/theme-matrix/fixtures.mjs index 95ca8de..45a2ed6 100644 --- a/apps/desktop/scripts/theme-matrix/fixtures.mjs +++ b/apps/desktop/scripts/theme-matrix/fixtures.mjs @@ -172,14 +172,14 @@ function nav(m) { const glyph = ``; 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;">
${glyph}
${label}
`; + 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;">
${glyph}
${label}
`; }; return ``; diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index f577bd9..5d2897c 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -290,7 +290,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "better-twttr" -version = "1.0.0" +version = "1.0.1" dependencies = [ "argon2", "base64 0.22.1", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index aad7f6b..8e87092 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index bf37b06..3181289 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -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", diff --git a/apps/desktop/src/inject/theme.ts b/apps/desktop/src/inject/theme.ts index c4b878a..1fc6475 100644 --- a/apps/desktop/src/inject/theme.ts +++ b/apps/desktop/src/inject/theme.ts @@ -311,7 +311,9 @@ function layerCss(palette: Partial): string { * all (checked live, 2026-09-28: `document.body.children` on `x.com/` logged out is plain `
`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 & { page?: string }): string { const bg = palette.bg; const text = palette.text; @@ -353,8 +355,16 @@ function fullPalette(palette: Partial): 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, source: Source, palette: Partial & { page?: string }, vars: Record = {}): string { +export function buildThemeCss(rules: Iterable, source: Source, palette: Partial & { page?: string }, vars: Record = {}, 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, 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 { +export function buildFullCss(rules: RuleLike[], choice: ThemeChoice, mode: 'light' | 'dim' | 'dark', vars: Record = {}, 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 }), diff --git a/apps/desktop/src/inject/themeIcons.ts b/apps/desktop/src/inject/themeIcons.ts index 75fa132..3e85e8b 100644 --- a/apps/desktop/src/inject/themeIcons.ts +++ b/apps/desktop/src/inject/themeIcons.ts @@ -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' }, diff --git a/apps/desktop/src/inject/themeParts.ts b/apps/desktop/src/inject/themeParts.ts index 425688c..47fd575 100644 --- a/apps/desktop/src/inject/themeParts.ts +++ b/apps/desktop/src/inject/themeParts.ts @@ -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