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:
parent
12eb3d9bee
commit
7e2515aa42
14 changed files with 104 additions and 32 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -1,5 +1,15 @@
|
||||||
# Changelog
|
# 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
|
## 1.0.0
|
||||||
|
|
||||||
The first public release. wayBetter for X is the first app on the open
|
The first public release. wayBetter for X is the first app on the open
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@better-twttr/desktop",
|
"name": "@better-twttr/desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,20 @@ function inspect() {
|
||||||
sideScroll: scroller.scrollWidth - scroller.clientWidth,
|
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 {
|
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,
|
iconCount: icons.length, faintIcons: icons.filter(i => i.ratio < 3).length, worstIcon: icons[0] ?? null,
|
||||||
layers,
|
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,
|
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 browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined });
|
||||||
const results = [];
|
const results = [];
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const baselineWarnings = [];
|
const baselineWarnings = [];
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
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.
|
// Measure settled colors: theme motion only runs without a reduced-motion preference.
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
@ -201,6 +237,8 @@ try {
|
||||||
await page.evaluate(preset => window.__bt.apply({ preset }), theme);
|
await page.evaluate(preset => window.__bt.apply({ preset }), theme);
|
||||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||||
const check = await page.evaluate(inspect);
|
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;
|
if (theme === 'x') baseline = check;
|
||||||
const problems = [];
|
const problems = [];
|
||||||
for (const layer of check.layers) if (!layer.opaque) problems.push(`layer ${layer.name} is see-through (${layer.color})`);
|
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 (!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 (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.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) {
|
if (check.chat) {
|
||||||
const c = check.chat, x = baseline?.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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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 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 item = ([testid, href, label]) => {
|
||||||
const tag = href ? 'a' : 'button';
|
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;">
|
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"><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;">
|
<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>
|
<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>
|
<div class="css-175oi2r r-13qz1uu"><nav role="navigation" aria-label="Primary" class="css-175oi2r r-13qz1uu">${items.map(item).join('')}</nav></div>
|
||||||
<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" 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>
|
||||||
<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 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>`;
|
</div></div></div></header>`;
|
||||||
|
|
|
||||||
2
apps/desktop/src-tauri/Cargo.lock
generated
2
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -290,7 +290,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "better-twttr"
|
name = "better-twttr"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "better-twttr"
|
name = "better-twttr"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
description = "X, extended: themes, plugins and recoverable drafts"
|
description = "X, extended: themes, plugins and recoverable drafts"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "wayBetter for X",
|
"productName": "wayBetter for X",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"identifier": "app.bettertwttr.desktop",
|
"identifier": "app.bettertwttr.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
* 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.
|
* 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 {
|
function signedOutCss(palette: Partial<Palette> & { page?: string }): string {
|
||||||
const bg = palette.bg;
|
const bg = palette.bg;
|
||||||
const text = palette.text;
|
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;
|
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. */
|
/** 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.
|
// 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 resolve = (value: string) => resolveDeclared(value, vars);
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
@ -447,7 +457,9 @@ export function buildThemeCss(rules: Iterable<RuleLike>, source: Source, palette
|
||||||
}
|
}
|
||||||
if (full) out.push(chatSurfaceCss(full));
|
if (full) out.push(chatSurfaceCss(full));
|
||||||
out.push(...badges);
|
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');
|
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);
|
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. */
|
/** 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 theme = resolveTheme(choice);
|
||||||
const palette = paletteFor(choice);
|
const palette = paletteFor(choice);
|
||||||
const parts: string[] = [];
|
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)));
|
if (!isNeutral(theme)) parts.push(buildStructureCss(rules, theme, structureColors(theme, mode)));
|
||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
@ -528,6 +540,7 @@ export function installTheme(win: Window, options: ThemeRuntimeOptions = {}): Th
|
||||||
siteVars: [...X_PRIMITIVE_VARS, '--x-blue-500'],
|
siteVars: [...X_PRIMITIVE_VARS, '--x-blue-500'],
|
||||||
styleId: 'bt-theme',
|
styleId: 'bt-theme',
|
||||||
name: 'wayBetter for X',
|
name: 'wayBetter for X',
|
||||||
|
flags: THEME_FLAGS,
|
||||||
// Theme engine v2: components, variants, decoration and icons (themeStructure.ts).
|
// 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.
|
// 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 }),
|
staticCss: (choice, mode) => buildThemeStructureCss(resolveTheme(choice), resolvedTheme(choice, mode), { ...env, icons: choice.icons ?? env.icons, packs: choice.packs }),
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,11 @@ export const X_ICONS: IconTable = {
|
||||||
views: { ctx: `${ACT} a[href$="/analytics"] svg`, group: 'actions' },
|
views: { ctx: `${ACT} a[href$="/analytics"] svg`, group: 'actions' },
|
||||||
bookmark: { ctx: `${ACT} [data-testid="bookmark"] svg`, group: 'actions' },
|
bookmark: { ctx: `${ACT} [data-testid="bookmark"] svg`, group: 'actions' },
|
||||||
'bookmark.on': { ctx: `${ACT} [data-testid="removeBookmark"] svg`, group: 'actions' },
|
'bookmark.on': { ctx: `${ACT} [data-testid="removeBookmark"] svg`, group: 'actions' },
|
||||||
share: { ctx: `${ACT} > div:last-child [role="button"] svg`, group: 'actions' },
|
// X's share button: the action bar's menu button without a test id (repost has one). Not `> div:last-child`:
|
||||||
media: { ctx: `${TOOLS} [role="button"]:has(+ input[data-testid="fileInput"]) svg,${TOOLS} [data-testid="fileInput"] ~ [role="button"] svg`, group: 'composer' },
|
// 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' },
|
gif: { ctx: `${TOOLS} [data-testid="gifSearchButton"] svg`, group: 'composer' },
|
||||||
poll: { ctx: `${TOOLS} [data-testid="createPollButton"] svg`, group: 'composer' },
|
poll: { ctx: `${TOOLS} [data-testid="createPollButton"] svg`, group: 'composer' },
|
||||||
emoji: { ctx: `${TOOLS} [aria-label="Add emoji"] svg`, group: 'composer' },
|
emoji: { ctx: `${TOOLS} [aria-label="Add emoji"] svg`, group: 'composer' },
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,8 @@ import type { VariantSkins } from '../../../../waybetter/packages/runtime/src/th
|
||||||
const POST = 'article[data-testid="tweet"]';
|
const POST = 'article[data-testid="tweet"]';
|
||||||
const COLUMN = '[data-testid="primaryColumn"]';
|
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). */
|
/** 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). */
|
// Keyed on the engine's page flag (THEME_FLAGS in theme.ts), never `:has()` over the page: see SIGNED_OUT_ROOT.
|
||||||
const CHAT_APP = ':is([data-testid="dm-container"],[data-testid="xchatEmbedRoute"])';
|
const FRAMED = 'html:not([data-wb-chat]) [data-testid="primaryColumn"]';
|
||||||
const FRAMED = `[data-testid="primaryColumn"]:not(:has(${CHAT_APP}))`;
|
|
||||||
const RAIL = 'header[role="banner"]';
|
const RAIL = 'header[role="banner"]';
|
||||||
const NAV = `${RAIL} nav[role="navigation"]`;
|
const NAV = `${RAIL} nav[role="navigation"]`;
|
||||||
const ACTIONS = ['reply', 'retweet', 'unretweet', 'like', 'unlike', 'bookmark', 'removeBookmark'].map(id => `[data-testid="${id}"]`).join(',');
|
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])' },
|
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' },
|
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>).
|
// 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)' },
|
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' },
|
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"]))' },
|
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. */
|
/** 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 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. */
|
/** How X looks in each layout variant. */
|
||||||
export const X_SKINS: VariantSkins<XSkinContext> = {
|
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}{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{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}
|
${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}
|
${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} ${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_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}
|
${NOT_CHAT} ${RAIL} [data-testid="SideNav_AccountSwitcher_Button"]{margin:0 !important}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { contrastRatio, hexToRgb } from '../../../waybetter/packages/runtime/src/themeApi.ts';
|
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';
|
import { BUILTIN_THEMES } from '../src/inject/themeSpec.ts';
|
||||||
|
|
||||||
const rule = (selectorText: string, style: Record<string, string>) => ({ selectorText, style: { getPropertyValue: (name: string) => style[name] ?? '' } });
|
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 palette = paletteFor(choice(preset.id))!;
|
||||||
const css = buildThemeCss([], X_PALETTES.dark, palette);
|
const css = buildThemeCss([], X_PALETTES.dark, palette);
|
||||||
const lines = css.split('\n');
|
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(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`);
|
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:'))!;
|
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`);
|
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 palette = paletteFor(choice(id))!;
|
||||||
const page = BUILTIN_THEMES.find(item => item.id === id)!.colors!.page!;
|
const page = BUILTIN_THEMES.find(item => item.id === id)!.colors!.page!;
|
||||||
const css = buildThemeCss([], X_PALETTES.dark, { ...palette, 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">)', () => {
|
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 classic = paletteFor(choice('classic'))!;
|
||||||
const css = buildThemeCss([], X_PALETTES.dark, classic);
|
const css = buildThemeCss([], X_PALETTES.dark, classic);
|
||||||
assert.ok(css.includes('body:not(:has(main[role="main"]))'), 'the rule exists at all');
|
assert.ok(css.includes('html:not([data-wb-app]) body'), '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
|
// The guard is the engine's page flag for X's app shell (<main role="main">, on chat and settings too),
|
||||||
// exactly ":has(main[role=\"main\"])" so a route without [data-testid="primaryColumn"] still excludes.
|
// never a page-level :has() (every node X inserted re-matched the whole page, 1.0.0).
|
||||||
assert.ok(css.includes(':not(:has(main[role="main"]))'));
|
assert.equal(THEME_FLAGS.app, 'main[role="main"]');
|
||||||
|
assert.ok(!css.includes('body:not(:has('), 'no page-level :has() gate');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
2
bun.lock
2
bun.lock
|
|
@ -22,7 +22,7 @@
|
||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "@better-twttr/desktop",
|
"name": "@better-twttr/desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-twttr/contracts": "workspace:*",
|
"@better-twttr/contracts": "workspace:*",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "better-twttr",
|
"name": "better-twttr",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"i18n:extract": "node scripts/i18n-extract.mjs",
|
"i18n:extract": "node scripts/i18n-extract.mjs",
|
||||||
"privacy:scan": "node scripts/privacy-scan.mjs",
|
"privacy:scan": "node scripts/privacy-scan.mjs",
|
||||||
"check": "bun run typecheck && bun run test && bun run test:rust",
|
"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": {
|
"engines": {
|
||||||
"node": ">=24.0.0",
|
"node": ">=24.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit b3f160e4c159632e83f27e19939f35f04e6cab7d
|
Subproject commit b3f6960dc3c84671ae53697c9282cc5b08742282
|
||||||
Loading…
Add table
Add a link
Reference in a new issue