wayBetter for X
This commit is contained in:
commit
df5dff619e
720 changed files with 147465 additions and 0 deletions
149
apps/desktop/src/inject/themeParts.ts
Normal file
149
apps/desktop/src/inject/themeParts.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// X's parts for theme engine v2: which of X's elements each generic component is (framework
|
||||
// themeComponents.ts), where decoration goes, and how X draws each layout variant. Every selector
|
||||
// is anchored on a test id, a role or an attribute; a generic tag only ends a selector as a text leaf
|
||||
// under such an anchor. `seen` notes the day a selector was read on live x.com (Lights out, the two
|
||||
// test accounts); the fixtures in scripts/theme-matrix carry the same markup.
|
||||
|
||||
import type { PartMap, RoleColors } from '../../../../waybetter/packages/runtime/src/themeComponents.ts';
|
||||
import { cssString, type DecorTargets } from '../../../../waybetter/packages/runtime/src/themeDecor.ts';
|
||||
import type { VariantSkins } from '../../../../waybetter/packages/runtime/src/themeVariants.ts';
|
||||
|
||||
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}))`;
|
||||
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(',');
|
||||
const AVATAR = '[data-testid^="UserAvatar-Container-"]';
|
||||
|
||||
/** Read live 2026-09-30 unless marked otherwise. */
|
||||
export const X_PARTS: PartMap = {
|
||||
nav: { sel: NAV },
|
||||
navItem: { sel: `${NAV} > :is(a,button) > div`, text: `${NAV} > :is(a,button) > div :is(span,svg)` },
|
||||
topBar: { sel: `${COLUMN} > div > div:first-child` },
|
||||
tabs: { sel: `${COLUMN} [role="tablist"]` },
|
||||
tab: { sel: `${COLUMN} [role="tablist"] [role="tab"]`, text: `${COLUMN} [role="tablist"] [role="tab"] span` },
|
||||
post: { sel: POST },
|
||||
avatar: { sel: `${POST} ${AVATAR},${POST} ${AVATAR} :is(div,a,img)`, skip: ['size'] },
|
||||
displayName: { sel: `${POST} [data-testid="User-Name"] > div:first-child`, text: `${POST} [data-testid="User-Name"] > div:first-child :is(span,a)` },
|
||||
handle: { sel: `${POST} [data-testid="User-Name"] > div:nth-child(2)`, text: `${POST} [data-testid="User-Name"] > div:nth-child(2) :is(span,a)` },
|
||||
postText: { sel: `${POST} [data-testid="tweetText"]`, text: `${POST} [data-testid="tweetText"],${POST} [data-testid="tweetText"] :is(span,a)` },
|
||||
meta: { sel: `${POST} a[href*="/status/"] > time` },
|
||||
actionBar: { sel: `${POST} [role="group"]` },
|
||||
action: { sel: `${POST} [role="group"] :is(${ACTIONS})` },
|
||||
count: { sel: `${POST} [role="group"] [data-testid="app-text-transition-container"]`, text: `${POST} [role="group"] [data-testid="app-text-transition-container"] span` },
|
||||
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' },
|
||||
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"]))' },
|
||||
menu: { sel: '#layers [role="menu"]' },
|
||||
hoverCard: { sel: '#layers [data-testid="HoverCard"]' },
|
||||
};
|
||||
|
||||
/** Where X's decoration goes. */
|
||||
export function xDecorTargets(): DecorTargets<XSkinContext> {
|
||||
return {
|
||||
divider: `${COLUMN} [data-testid="cellInnerDiv"] > div`,
|
||||
link: `${POST} [data-testid="tweetText"] a`,
|
||||
focusable: 'a,button,[role="button"],[role="tab"],[role="link"],input,textarea,[tabindex="0"]',
|
||||
moving: `${POST},${NAV} > :is(a,button) > div,[role="tab"],[data-testid$="-follow"],[data-testid="tweetButtonInline"]`,
|
||||
pressable: `${NAV} > :is(a,button),[data-testid="tweetButtonInline"],[data-testid="tweetButton"],[data-testid$="-follow"],${POST} [role="group"] [role="button"]`,
|
||||
chrome: {
|
||||
// A window title bar on the main column, and on each sidebar card with X's own label for it.
|
||||
titlebar: (title, { colors }) => [
|
||||
`${FRAMED}::before{content:${cssString(title || 'Home')};display:block;order:-1;box-sizing:border-box;height:24px;padding:4px 8px;font:700 12px/16px "Pixelated MS Sans Serif","MS Sans Serif","Tahoma",sans-serif;letter-spacing:.2px;color:#ffffff;background:linear-gradient(90deg, ${colors.inverse}, ${colors.accent});white-space:nowrap;overflow:hidden}`,
|
||||
`[data-testid="sidebarColumn"] :is(aside[role="complementary"],section[role="region"])[aria-label]::before{content:attr(aria-label);display:block;margin:-12px -16px 10px;padding:3px 8px;font:700 12px/16px "Pixelated MS Sans Serif","MS Sans Serif","Tahoma",sans-serif;color:#ffffff;background:linear-gradient(90deg, ${colors.inverse}, ${colors.accent})}`,
|
||||
].join('\n'),
|
||||
// A newspaper masthead: the theme's title between two rules at the top of the column.
|
||||
masthead: (title, { colors, fonts }) => `${FRAMED}::before{content:${cssString(title || 'The Timeline')};display:block;order:-1;padding:18px 16px 12px;text-align:center;font:700 34px/1.1 ${fonts.heading};letter-spacing:-.01em;color:${colors.text};border-bottom:4px double ${colors.text};margin:0 16px}`,
|
||||
// A terminal prompt line above the column.
|
||||
prompt: (title, { colors, fonts }) => `${FRAMED}::before{content:${cssString(`${title || 'guest@x'}:~$ tail -f timeline`)};display:block;order:-1;padding:10px 16px;font:14px/20px ${fonts.mono};color:${colors.accent};border-bottom:1px dashed ${colors.border}}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type XSkinContext = {
|
||||
colors: RoleColors;
|
||||
fonts: { heading: string; mono: string; post: string; interface: string };
|
||||
/** The words of worded actions (`variants.actions = "labels"`). */
|
||||
labels: { reply: string; repost: string; like: string; views: string; bookmark: string; share: string };
|
||||
/** Brackets around a worded action (Terminal: `[reply 12]`). */
|
||||
brackets: boolean;
|
||||
};
|
||||
|
||||
/** 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}))`;
|
||||
|
||||
/** How X looks in each layout variant. */
|
||||
export const X_SKINS: VariantSkins<XSkinContext> = {
|
||||
nav: {
|
||||
// The rail becomes a bar across the top: logo, items and the Post button in a row.
|
||||
top: ({ colors }) => bar('top', colors),
|
||||
bottom: ({ colors }) => bar('bottom', colors),
|
||||
icons: () => `${WIDE}{${NOT_CHAT} ${NAV} > :is(a,button) span{display:none !important}}`,
|
||||
},
|
||||
post: {
|
||||
compact: () => `${POST}{padding-top:6px !important;padding-bottom:6px !important}\n${POST} [data-testid="tweetText"]{margin-top:0 !important}`,
|
||||
cards: ({ colors }) => `${COLUMN} [data-testid="cellInnerDiv"] ${POST}{margin:6px 12px;border:1px solid ${colors.border} !important;border-radius:14px;background-color:${colors.surface} !important}\n${COLUMN} [data-testid="cellInnerDiv"] > div{border-bottom-width:0 !important}`,
|
||||
bubbles: ({ colors }) => `${COLUMN} [data-testid="cellInnerDiv"] ${POST} [data-testid="tweetText"]{background-color:${colors.surface};padding:10px 14px;border-radius:18px;margin-top:4px}\n${COLUMN} [data-testid="cellInnerDiv"] > div{border-bottom-width:0 !important}`,
|
||||
// One column set like a newspaper: small-caps bylines, a serif body, hairlines, and a drop cap on a post's own page.
|
||||
broadsheet: ({ colors, fonts }) => [
|
||||
`${POST} [data-testid="User-Name"] > div:first-child :is(span,a){font-variant-caps:all-small-caps !important;letter-spacing:.06em !important}`,
|
||||
`${POST} [data-testid="tweetText"],${POST} [data-testid="tweetText"] span{font-family:${fonts.post} !important}`,
|
||||
`${COLUMN} [data-testid="cellInnerDiv"] > div{border-bottom:1px solid ${colors.text} !important}`,
|
||||
`html[data-bt-route="status"] ${POST}[tabindex="-1"] [data-testid="tweetText"] > span:first-child::first-letter{float:left;font-size:3.2em;line-height:.9;padding:4px 6px 0 0;font-family:${fonts.post}}`,
|
||||
].join('\n'),
|
||||
},
|
||||
actions: {
|
||||
// Words instead of pictures: the icon box is hidden, the word stands next to X's own count.
|
||||
labels: ({ labels, brackets, colors }) => {
|
||||
const word = (testids: string[], text: string) => {
|
||||
const buttons = testids.map(id => `${POST} [role="group"] [data-testid="${id}"]`).join(',');
|
||||
return [
|
||||
`:is(${buttons}) svg{display:none !important}`,
|
||||
`:is(${buttons}) > div > div:first-child::before{content:${cssString(`${brackets ? '[' : ''}${text}`)};font-size:13px;font-weight:600;white-space:nowrap}`,
|
||||
...(brackets ? [`:is(${buttons}) > div::after{content:"]";font-size:13px;font-weight:600;margin-left:1px}`] : []),
|
||||
].join('\n');
|
||||
};
|
||||
return [
|
||||
word(['reply'], labels.reply), word(['retweet', 'unretweet'], labels.repost), word(['like', 'unlike'], labels.like),
|
||||
word(['bookmark', 'removeBookmark'], labels.bookmark),
|
||||
`${POST} [role="group"] :is([data-testid="unlike"],[data-testid="unretweet"],[data-testid="removeBookmark"]) > div{color:${colors.accent} !important}`,
|
||||
].join('\n');
|
||||
},
|
||||
// Counts show on hover only.
|
||||
quiet: () => `${POST}:not(:hover) [role="group"] [data-testid="app-text-transition-container"]{opacity:0 !important}`,
|
||||
},
|
||||
header: {
|
||||
solid: ({ colors }) => `${COLUMN} > div > div:first-child{background-color:${colors.bg} !important;backdrop-filter:none !important}`,
|
||||
bar: ({ colors }) => `${COLUMN} > div > div:first-child{box-shadow:0 1px 0 ${colors.border},0 2px 8px rgba(0, 0, 0, 0.12) !important}`,
|
||||
},
|
||||
sidebar: {
|
||||
hidden: () => `${WIDE}{${NOT_CHAT} [data-testid="sidebarColumn"]{display:none !important}}`,
|
||||
},
|
||||
};
|
||||
|
||||
/** The rail as a bar across the top or the bottom of the window. */
|
||||
function bar(edge: 'top' | 'bottom', colors: RoleColors): string {
|
||||
const inner = `${RAIL} > div > div`;
|
||||
return `${WIDE}{
|
||||
${NOT_CHAT} ${RAIL}{position:fixed !important;${edge}:0;left:0;right:0;z-index:30;height:56px;width:auto !important;flex-grow:0 !important;align-items:stretch !important;background-color:${colors.surface};border-${edge === 'top' ? 'bottom' : 'top'}:1px solid ${colors.border};box-shadow:0 ${edge === 'top' ? '1px' : '-1px'} 6px rgba(0, 0, 0, 0.12)}
|
||||
${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}
|
||||
${NOT_CHAT} ${NAV}{flex-direction:row !important;align-items:center !important;flex-wrap:nowrap !important;gap:2px}
|
||||
${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}
|
||||
${NOT_CHAT} main[role="main"]{padding-${edge}:56px !important;align-items:center !important}
|
||||
${NOT_CHAT} ${COLUMN} > div > div:first-child{${edge === 'top' ? 'top:56px !important' : ''}}
|
||||
}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue