@volter/twin-x 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -51,6 +51,9 @@ export {
51
51
  xMirrorStyles,
52
52
  } from './x-mirror-ui.ts';
53
53
  export type { TextSegment, XRow } from './x-mirror-ui.ts';
54
+ export { serveXWeb, X_PBS_PATHS, X_VIDEO_PATHS, X_WEB_HOSTS, X_WEB_PATHS } from './x-web.ts';
55
+ export { xWebPost, xWebProfile } from './x-twin.ts';
56
+ export type { XWebPost, XWebProfile } from './x-twin.ts';
54
57
  export { missingScopes, parseScopeList, REQUIRED_SCOPES, X_SCOPES } from './x-scopes.ts';
55
58
  // The client-side rate budget — the fail-closed backstop `liveXExecute` routes every live
56
59
  // request through. The mechanism is the kernel's (`@volter/world-core` -> rateBudget.ts); these are
@@ -71,6 +74,7 @@ export type { XBudgetErrorKind, XBudgetOptions, XBudgetReservation, XBudgetSnaps
71
74
 
72
75
  import type { TwinPack } from '@volter/world-core';
73
76
  import { X_RATE_BUDGET as RATE_BUDGET } from './x-budget.ts';
77
+ import { X_PBS_PATHS, X_VIDEO_PATHS, X_WEB_HOSTS, X_WEB_PATHS } from './x-web.ts';
74
78
 
75
79
  /**
76
80
  * The api.x.com / api.twitter.com paths THIS pack serves, as a RegExp SOURCE for the descriptor's
@@ -126,9 +130,21 @@ export const pack: TwinPack = {
126
130
  // api.x.com — DMs, spaces, lists, media metadata — stays unclaimed, so an unmodelled X call
127
131
  // refuses loudly rather than landing in a twin that cannot serve it. api.twitter.com is claimed
128
132
  // alongside api.x.com because that is the host X's own legacy clients still target.
133
+ //
134
+ // x.com / twitter.com carry X's PUBLIC PAGES — a post (`/<handle>/status/<id>`) and a profile
135
+ // (`/<handle>`) — which an app links a person to once it has posted (x-web.ts). The www. and
136
+ // mobile. names and twitter.com answer those paths with X's own moves toward x.com (X_WEB_HOSTS).
137
+ // x.com is also where `xidentity` serves the OAuth
138
+ // consent screen (`^/i/oauth2/authorize/?$|^/_twin/`); X_WEB_PATHS never admits `i` or `_twin` as
139
+ // a handle, so the two claims stay disjoint (scripts/vendor-hosts.test.ts pins it). pbs.twimg.com
140
+ // and video.twimg.com are where those pages (and every API media `url`) load images and video
141
+ // from, claimed for exactly the paths x-server.ts serves from the blob store.
129
142
  hosts: [
130
143
  { host: 'api.x.com', pathPattern: X_API_PATHS },
131
144
  { host: 'api.twitter.com', pathPattern: X_API_PATHS },
145
+ ...X_WEB_HOSTS.map((host) => ({ host, pathPattern: X_WEB_PATHS })),
146
+ { host: 'pbs.twimg.com', pathPattern: X_PBS_PATHS },
147
+ { host: 'video.twimg.com', pathPattern: X_VIDEO_PATHS },
132
148
  ],
133
149
  browserRouting: { apiPathPrefix: '/2/', loaderHost: 'https://api.x.com' },
134
150
  };
@@ -19,7 +19,7 @@ import { applyTwinWrite } from '@volter/world-core';
19
19
  import { createElement } from 'react';
20
20
  import { renderToStaticMarkup } from 'react-dom/server';
21
21
  import { PostRow, ProfileHeader } from '../client/x-mirror.tsx';
22
- import { createXMirrorServer, joinedLabel, POST_READ_QUERY, PROFILE_READ_QUERY, tweetsById, usersById, type XRow } from './x-mirror-ui.ts';
22
+ import { createXMirrorServer, fullTimestamp, joinedLabel, POST_READ_QUERY, PROFILE_READ_QUERY, tweetsById, usersById, type XRow } from './x-mirror-ui.ts';
23
23
 
24
24
  type Body = Record<string, any>;
25
25
  type Step = { m: string; p: string; b?: unknown; headers?: Record<string, string>; readOnly?: boolean };
@@ -1322,6 +1322,102 @@ export const X_CAPABILITIES: CapabilitySpec[] = [
1322
1322
  rmSync(root, { recursive: true, force: true });
1323
1323
  }
1324
1324
  }),
1325
+ // ══ x.com's PUBLIC PAGES (x-web.ts) — what a link an app hands the person opens: the post page and
1326
+ // the profile page on x.com itself, served from the store to a request the World routed from x.com ══
1327
+ done('x.web.post_page', 'web', "x.com/<handle>/status/<id> (the host the World routed it from named in Host) answers the post's page from the store: the author's name and @handle, the text, the time, and each photo from pbs.twimg.com's path; a deleted post and an id no post has are 404 (x.com's own status for them, observed 2026-09-28) with X's page-not-found words", 'ui', 'core', () =>
1328
+ withWorld(async (h, root) => {
1329
+ const fetchTwin = createXTwinFetch({ root });
1330
+ await mediaToken(h);
1331
+ const media = ((await h({ m: 'POST', p: '/2/media/upload', b: { media: b64(tinyPng()), media_category: 'tweet_image' }, headers: MEDIA_AUTH })).body as Body).data;
1332
+ const posted = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'the release is out <b>now</b>', media: { media_ids: [String(media?.id)] } }, headers: MEDIA_AUTH })).body as Body).data;
1333
+ const gone = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'about to go' } })).body as Body).data.id as string;
1334
+ await h({ m: 'DELETE', p: `/2/tweets/${gone}` });
1335
+ const read = (await h({ m: 'GET', p: `/2/tweets/${posted.id}?tweet.fields=created_at&expansions=attachments.media_keys&media.fields=url` })).body as Body;
1336
+ const page = (path: string, host = 'x.com') => fetchTwin(new Request(`http://127.0.0.1:9${path}`, { headers: { host } }));
1337
+ const found = await page(`/orgvoice/status/${posted.id}`);
1338
+ const html = await found.text();
1339
+ const photo = /\/media\/([0-9a-f]{64})\.png$/.exec(String(read.includes?.media?.[0]?.url))?.[1];
1340
+ const deleted = await page(`/orgvoice/status/${gone}`);
1341
+ const missing = await page('/orgvoice/status/404404404404404404');
1342
+ const missingHtml = await missing.text();
1343
+ return found.status === 200 && (found.headers.get('content-type') ?? '').startsWith('text/html')
1344
+ && html.includes('Org Voice') && html.includes('@orgvoice') && html.includes('the release is out &lt;b&gt;now&lt;/b&gt;') && !html.includes('<b>now</b>')
1345
+ && photo !== undefined && html.includes(`src="https://pbs.twimg.com/media/${photo}.png"`)
1346
+ && html.includes(`datetime="${read.data.created_at}"`) && html.includes(fullTimestamp(read.data.created_at, 'UTC'))
1347
+ && deleted.status === 404 && !(await deleted.text()).includes('about to go')
1348
+ && missing.status === 404 && missingHtml.includes('this page doesn’t exist');
1349
+ })),
1350
+ done('x.web.profile_page', 'web', "x.com/<handle> answers the account's profile from the store — name, @handle, bio, joined date — over its own posts newest first, each linking to its post page; replies stay off it, the handle is case-insensitive, and a handle no account has is 404", 'ui', 'core', () =>
1351
+ withWorld(async (h, root) => {
1352
+ const fetchTwin = createXTwinFetch({ root });
1353
+ await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3000', username: 'profiled', name: 'Profiled Org', description: 'we build things' }, headers: {} });
1354
+ await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'profiled-token', account_id: '3000', scopes: FULL_SCOPES }, headers: {} });
1355
+ const as = { authorization: 'Bearer profiled-token' };
1356
+ const older = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'the older post' }, headers: as })).body as Body).data.id as string;
1357
+ const newer = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'the newer post' }, headers: as })).body as Body).data.id as string;
1358
+ const mention = await seedMention(h, 'hey @profiled');
1359
+ const reply = await h({ m: 'POST', p: '/2/tweets', b: { text: 'a reply, not a post', reply: { in_reply_to_tweet_id: mention } }, headers: as });
1360
+ if (reply.status !== 201) return false;
1361
+ const page = (path: string) => fetchTwin(new Request(`http://127.0.0.1:9${path}`, { headers: { host: 'x.com' } }));
1362
+ const found = await page('/Profiled');
1363
+ const html = await found.text();
1364
+ const missing = await page('/nobodyhere');
1365
+ const iNewer = html.indexOf(`href="/profiled/status/${newer}"`);
1366
+ const iOlder = html.indexOf(`href="/profiled/status/${older}"`);
1367
+ return found.status === 200 && html.includes('Profiled Org') && html.includes('@profiled') && html.includes('we build things') && html.includes('Joined ')
1368
+ && iNewer !== -1 && iOlder !== -1 && iNewer < iOlder && html.includes('the newer post') && !html.includes('a reply, not a post')
1369
+ && missing.status === 404 && (await missing.text()).includes('This account doesn’t exist');
1370
+ })),
1371
+ done('x.web.other_hosts_move_to_x_com', 'web', "X's other web names answer a page path with X's own moves (observed 2026-09-28): twitter.com 301 to x.com, www.x.com and www.twitter.com 301 to twitter.com, mobile.twitter.com 302 to twitter.com, mobile.x.com 302 to x.com; the API host serves no page at those paths", 'ui', 'common', () =>
1372
+ withWorld(async (h, root) => {
1373
+ const fetchTwin = createXTwinFetch({ root });
1374
+ const id = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'linked from an app' } })).body as Body).data.id as string;
1375
+ const path = `/orgvoice/status/${id}`;
1376
+ const at = (host: string) => fetchTwin(new Request(`http://127.0.0.1:9${path}`, { headers: { host } }));
1377
+ const expected: Array<[string, number, string]> = [
1378
+ ['twitter.com', 301, 'https://x.com'], ['www.x.com', 301, 'https://twitter.com'], ['www.twitter.com', 301, 'https://twitter.com'],
1379
+ ['mobile.twitter.com', 302, 'https://twitter.com'], ['mobile.x.com', 302, 'https://x.com'],
1380
+ ];
1381
+ for (const [host, status, to] of expected) {
1382
+ const r = await at(host);
1383
+ if (r.status !== status || r.headers.get('location') !== `${to}${path}`) return false;
1384
+ }
1385
+ const api = await at('api.x.com');
1386
+ return api.status === 404 && !(await api.text()).includes('linked from an app');
1387
+ })),
1388
+ done('x.web.canonical_post_url', 'web', "A post's page under another handle, or its author's handle in another case, is X's 307 to the author's own /<handle>/status/<id>; a trailing slash is a 307 to the path without it; the canonical URL is the 200 page", 'ui', 'core', () =>
1389
+ withWorld(async (h, root) => {
1390
+ const fetchTwin = createXTwinFetch({ root });
1391
+ const id = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'where it lives' } })).body as Body).data.id as string;
1392
+ const at = (path: string) => fetchTwin(new Request(`http://127.0.0.1:9${path}`, { headers: { host: 'x.com' } }));
1393
+ const canonical = `https://x.com/orgvoice/status/${id}`;
1394
+ const wrong = await at(`/member/status/${id}`);
1395
+ const cased = await at(`/OrgVoice/status/${id}`);
1396
+ const slash = await at(`/orgvoice/status/${id}/`);
1397
+ const right = await at(`/orgvoice/status/${id}`);
1398
+ return wrong.status === 307 && wrong.headers.get('location') === canonical && cased.status === 307 && cased.headers.get('location') === canonical
1399
+ && slash.status === 307 && slash.headers.get('location') === canonical && right.status === 200 && (await right.text()).includes('where it lives');
1400
+ })),
1401
+ done('x.web.default_avatar', 'web', "An account without a photo is drawn with X's default avatar on its x.com pages (the grey silhouette at the default profile_image_url the API answers for it, redrawn inline: the twin claims no abs.twimg.com), on its profile and beside its posts; an account with a photo is not given the default", 'ui', 'common', () =>
1402
+ withWorld(async (h, root) => {
1403
+ const fetchTwin = createXTwinFetch({ root });
1404
+ await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3200', username: 'plainface', name: 'Plain Face' }, headers: {} });
1405
+ await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3201', username: 'photoface', name: 'Photo Face', profile_image_url: 'https://pbs.twimg.com/profile_images/2/photoface_normal.jpg' }, headers: {} });
1406
+ await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'plainface-token', account_id: '3200', scopes: FULL_SCOPES }, headers: {} });
1407
+ const posted = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'no photo yet' }, headers: { authorization: 'Bearer plainface-token' } })).body as Body).data.id as string;
1408
+ const api = ((await h({ m: 'GET', p: '/2/users/3200?user.fields=profile_image_url' })).body as Body).data?.profile_image_url;
1409
+ const page = async (path: string) => (await fetchTwin(new Request(`http://127.0.0.1:9${path}`, { headers: { host: 'x.com' } }))).text();
1410
+ const drawn = (html: string) => /class="avatar avatar-default"[^>]*data-src="([^"]+)"[^>]*><svg[^>]*><rect[^>]*fill="#ccd6dd"\/><path fill="#657786"/.exec(html)?.[1];
1411
+ const profile = await page('/plainface');
1412
+ const post = await page(`/plainface/status/${posted}`);
1413
+ const photo = await page('/photoface');
1414
+ return typeof api === 'string' && api.includes('default_profile_images')
1415
+ && drawn(profile) === api && drawn(post) === api
1416
+ && !photo.includes('avatar-default') && photo.includes('Photo Face');
1417
+ })),
1418
+ todo('x.web.profile_photo', 'web', "A photo the account set drawn on its x.com pages (the twin serves no profile photos: pbs.twimg.com/profile_images is not claimed, so the page keeps the initial on its hue)", 'ui', 'niche'),
1419
+ todo('x.web.post_page_replies', 'web', "The replies under a post on its x.com page (the conversation), and the parent a reply answers", 'ui', 'common'),
1420
+ todo('x.web.logged_out_prompts', 'web', "x.com's logged-out chrome around a public page (the Log in / Sign up bar and its flows)", 'ui', 'niche'),
1325
1421
  todo('x.mirror.notifications_all', 'mirror', "Notifications beyond Mentions (likes, reposts, follows) — the twin models no engagement events to list", 'ui', 'common'),
1326
1422
  todo('x.mirror.profile_counts', 'mirror', "The Following / Followers counts under the profile header (needs user public_metrics)", 'ui', 'common'),
1327
1423
 
package/src/x-server.ts CHANGED
@@ -12,11 +12,14 @@
12
12
  // pbs.twimg.com's `/media/<name>.<ext>` for an image and `/ext_tw_video_thumb/<id>/pu/img/…`
13
13
  // for a video's preview, video.twimg.com's `/ext_tw_video/<id>/pu/vid/avc1/<w>x<h>/<name>.mp4`
14
14
  // for the video file — with HTTP Range (206), so a <video> can seek. Those hosts are public
15
- // at X (an <img> or <video> carries no bearer), so these routes are too.
15
+ // at X (an <img> or <video> carries no bearer), so these routes are too;
16
+ // - x.com's own HTML pages — a post at `/<handle>/status/<id>`, a profile at `/<handle>` — for a
17
+ // request routed from x.com or twitter.com (x-web.ts).
16
18
  //
17
19
  // The server is one line of Bun.serve around that same closure.
18
20
  import { readMediaRecord, readXBlob, readXBlobType, videoPosterSvg } from './x-media.ts';
19
21
  import { handleXTwinRequest } from './x-twin.ts';
22
+ import { serveXWeb } from './x-web.ts';
20
23
  import { createTwinFetchFromHandler, runWithCorrelationId, serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
21
24
 
22
25
  /** Options every X-twin HTTP surface needs, independent of who owns the socket. */
@@ -118,6 +121,9 @@ export function createXTwinFetch(options: XTwinFetchOptions = {}): (request: Req
118
121
  if (!bytes || !type) return new Response('Not Found', { status: 404 });
119
122
  return bytesResponse(request, bytes, type);
120
123
  }
124
+ // x.com's own post and profile pages (x-web.ts), for a request the World routed from x.com / twitter.com
125
+ const page = reading ? serveXWeb(request, { ...(options.root !== undefined ? { root: options.root } : {}), nowMs: Date.parse(worldNow()) }) : null;
126
+ if (page) return page;
121
127
  const thumb = reading ? VIDEO_THUMB_PATH.exec(url.pathname) : null;
122
128
  if (thumb) {
123
129
  const record = await readMediaRecord(thumb[1]!, options.root);
package/src/x-twin.ts CHANGED
@@ -1489,6 +1489,52 @@ function armedRateRefusal(all: Resource[]): XResponse | undefined {
1489
1489
  });
1490
1490
  }
1491
1491
 
1492
+ // ── the public web read: what x.com shows a visitor (x-web.ts renders it) ───────────────────
1493
+ // x.com's post page and profile page read the SAME posts and accounts the API serves, through the
1494
+ // SAME projections (projectPost / projectUser / projectMedia / buildIncludes), with a fixed shape
1495
+ // instead of a caller's query. No bearer: X shows a public post and profile to anyone. A deleted
1496
+ // post is gone here as it is from every read.
1497
+
1498
+ /** The fields and expansions the web pages draw (the mirror's POST_READ_QUERY, plus the profile's user fields). */
1499
+ const WEB_READ_SHAPE = 'tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id,referenced_tweets'
1500
+ + '&expansions=author_id,in_reply_to_user_id,referenced_tweets.id,referenced_tweets.id.author_id,attachments.media_keys'
1501
+ + '&user.fields=created_at,description,location,url,verified,protected,profile_image_url'
1502
+ + '&media.fields=url,type,width,height,duration_ms,preview_image_url,variants';
1503
+
1504
+ function webShape(publicBase?: string): ReadShape {
1505
+ const shape = parseReadShape(new URL(`http://twin.local/?${WEB_READ_SHAPE}`), (publicBase ?? PBS_BASE).replace(/\/+$/, ''));
1506
+ if (!isShape(shape)) throw new Error('x: the web read shape is not one the read parser accepts');
1507
+ return shape;
1508
+ }
1509
+
1510
+ export type XWebPost = { post: Record<string, unknown>; includes: Record<string, unknown> };
1511
+ export type XWebProfile = { user: Record<string, unknown>; posts: Record<string, unknown>[]; includes: Record<string, unknown> };
1512
+
1513
+ /** One live post as its page shows it, or undefined when no live post has that id. Media URLs are
1514
+ * X's own hosts (pbs.twimg.com, video.twimg.com) unless a served base is given. */
1515
+ export function xWebPost(id: string, options: { root?: string; publicBase?: string } = {}): XWebPost | undefined {
1516
+ if (!/^\d{1,19}$/.test(id)) return undefined;
1517
+ const all = resources(options.root);
1518
+ const post = findPost(all, id);
1519
+ if (!post) return undefined;
1520
+ const shape = webShape(options.publicBase);
1521
+ return { post: projectPost(post, shape.fields), includes: buildIncludes([post], all, shape) };
1522
+ }
1523
+
1524
+ /** An account's profile and its posts (not its replies), newest first, at most `limit`; undefined
1525
+ * when no account has that handle (case-insensitive, as X's are). */
1526
+ export function xWebProfile(username: string, options: { root?: string; publicBase?: string; limit?: number } = {}): XWebProfile | undefined {
1527
+ if (!/^[A-Za-z0-9_]{1,15}$/.test(username)) return undefined;
1528
+ const all = resources(options.root);
1529
+ const account = ofType(all, 'account').find((a) => String(a.username).toLowerCase() === username.toLowerCase());
1530
+ if (!account) return undefined;
1531
+ const shape = webShape(options.publicBase);
1532
+ const own = newestFirst(livePosts(all).filter((p) => String(p.author_id) === String(account.id) && p.in_reply_to_user_id === undefined
1533
+ && !(Array.isArray(p.referenced_tweets) && p.referenced_tweets.some((r: Resource) => r?.type === 'replied_to'))))
1534
+ .slice(0, options.limit ?? 100);
1535
+ return { user: projectUser(account, shape.userFields, all), posts: own.map((p) => projectPost(p, shape.fields)), includes: buildIncludes(own, all, shape) };
1536
+ }
1537
+
1492
1538
  // ── the router ──────────────────────────────────────────────────────────────────────────────
1493
1539
 
1494
1540
  const TWEETS_PATH = '/2/tweets';
package/src/x-web.ts ADDED
@@ -0,0 +1,283 @@
1
+ // X's PUBLIC WEB PAGES — what x.com serves a visitor at `https://x.com/<handle>/status/<id>` (a post) and
2
+ // `https://x.com/<handle>` (a profile with its posts), from the twin's own store. An application that
3
+ // posts links the person to the post where it was published (Postiz's x.provider.ts `releaseURL`:
4
+ // `https://twitter.com/${profile}/status/${id}`), and in a World that link has to open the post as X
5
+ // shows it: author, text, media, time.
6
+ //
7
+ // These are x.com's own paths on x.com's own host, not the mirror (the mirror is the signed-in app on
8
+ // the twin's origin, x-mirror-ui.ts). The page reads the same projections the API serves
9
+ // (x-twin.ts `xWebPost` / `xWebProfile`) and draws them with the mirror's own pure helpers, server-side:
10
+ // no bearer, as x.com shows a public post to anyone. twitter.com answers as X does, with a permanent
11
+ // redirect to the same path on x.com. Media is drawn from X's own media hosts (pbs.twimg.com,
12
+ // video.twimg.com), which this pack claims beside x.com and serves from its blob store (x-server.ts).
13
+ //
14
+ // x.com is the whole X web property and `xidentity` serves its OAuth consent screen there, so the
15
+ // claim is two anchored path shapes and nothing else (X_WEB_PATHS); the router below answers exactly
16
+ // those shapes, only for a request the World routed from one of X's web hosts (X_WEB_HOSTS).
17
+ import { avatarHue, avatarInitial, displayUrl, durationLabel, fullTimestamp, joinedLabel, linkHref, mediaByKey, postMedia, referencedId, relativeTime, textSegments, tweetsById, usersById, videoSource, type XRow } from './x-mirror-ui.ts';
18
+ import { X_DEFAULT_PROFILE_IMAGE_URL, xWebPost, xWebProfile } from './x-twin.ts';
19
+
20
+ /** x.com's top-level routes that are not accounts (and the twin's own doors): never a profile. Each was
21
+ * observed as a route of its own at x.com (2026-09-28, logged out, `curl -A 'Mozilla/5.0' -o /dev/null
22
+ * -w '%{http_code} %{redirect_url}' https://x.com/<name>`): home, explore, notifications, settings,
23
+ * search, login, signup answer 307 to the login flow; messages 307 to /i/chat; i 301 to /; premium 301;
24
+ * tos and privacy 302 to /en/…; about 301 to about.twitter.com; grok, jobs, communities, lists,
25
+ * bookmarks, compose, account, share, hashtag, download and creators 200 with a page of their own.
26
+ * `intent` and `twin`/`_twin` are held back for X's intent URLs and the twin's doors. */
27
+ const RESERVED = 'i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin';
28
+ const PROFILE_PATH = `^/(?!(?:${RESERVED})/?$)[A-Za-z0-9_]{1,15}/?$`;
29
+ const STATUS_PATH = '^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$';
30
+
31
+ /** The x.com / twitter.com paths this pack serves, as a RegExp SOURCE for the descriptor's `hosts`
32
+ * rule: a post page and a profile page. ANCHORED, and disjoint from xidentity's
33
+ * `^/i/oauth2/authorize/?$|^/_twin/` by construction (`i` and `_twin` are never a handle here). */
34
+ export const X_WEB_PATHS = `${STATUS_PATH}|${PROFILE_PATH}`;
35
+
36
+ /** X's image and video-thumbnail paths on pbs.twimg.com, and its video file path on video.twimg.com —
37
+ * the shapes x-server.ts serves from the blob store. */
38
+ export const X_PBS_PATHS = '^/media/[0-9a-f]{64}\\.(?:jpg|png|gif|webp)$|^/ext_tw_video_thumb/[0-9]{1,19}/pu/img/[0-9a-f]{64}\\.svg$';
39
+ export const X_VIDEO_PATHS = '^/ext_tw_video/[0-9]{1,19}/pu/vid/avc1/[0-9]+x[0-9]+/[0-9a-f]{64}\\.mp4$';
40
+
41
+ /** X's web hosts and what each answers a page path with, as observed (2026-09-28, the curl above over
42
+ * `/jack/status/20`): x.com serves it; twitter.com 301s to x.com; www.x.com and www.twitter.com 301 to
43
+ * twitter.com (which then moves to x.com); mobile.twitter.com 302s to twitter.com and mobile.x.com to x.com. */
44
+ export const X_WEB_HOSTS = ['x.com', 'twitter.com', 'www.x.com', 'www.twitter.com', 'mobile.twitter.com', 'mobile.x.com'] as const;
45
+ const MOVES: Record<string, { status: number; to: string }> = {
46
+ 'twitter.com': { status: 301, to: 'https://x.com' },
47
+ 'www.x.com': { status: 301, to: 'https://twitter.com' },
48
+ 'www.twitter.com': { status: 301, to: 'https://twitter.com' },
49
+ 'mobile.twitter.com': { status: 302, to: 'https://twitter.com' },
50
+ 'mobile.x.com': { status: 302, to: 'https://x.com' },
51
+ };
52
+ const WEB_HOSTS = new Set<string>(X_WEB_HOSTS);
53
+ const STATUS = /^\/([A-Za-z0-9_]{1,15})\/status\/([0-9]{1,19})\/?$/;
54
+ const PROFILE = new RegExp(PROFILE_PATH);
55
+
56
+ const esc = (value: unknown): string => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
57
+
58
+ /** The host a request was made to: the World's proxy keeps it in Host, or names it when it rewrote Host. */
59
+ function namedHost(request: Request): string {
60
+ const named = request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? '';
61
+ return named.replace(/:\d+$/, '').toLowerCase();
62
+ }
63
+
64
+ /**
65
+ * x.com's post and profile pages. Null for any request that is not a GET/HEAD to one of those paths on
66
+ * x.com or twitter.com — the caller serves it as the API. `nowMs` is the World's instant (a profile's
67
+ * "5m" is drawn against it).
68
+ */
69
+ export function serveXWeb(request: Request, options: { root?: string; nowMs: number }): Response | null {
70
+ if (request.method !== 'GET' && request.method !== 'HEAD') return null;
71
+ const host = namedHost(request);
72
+ if (!WEB_HOSTS.has(host)) return null;
73
+ const url = new URL(request.url);
74
+ const status = STATUS.exec(url.pathname);
75
+ const profile = status ? null : PROFILE.test(url.pathname) ? url.pathname.replace(/^\/|\/$/g, '') : null;
76
+ if (!status && profile === null) return null;
77
+ const move = MOVES[host];
78
+ if (move) return new Response(null, { status: move.status, headers: { location: `${move.to}${url.pathname}${url.search}` } });
79
+ // x.com drops a trailing slash with a 307 (observed: /jack/status/20/ and /XDevelopers/)
80
+ if (url.pathname.length > 1 && url.pathname.endsWith('/')) return new Response(null, { status: 307, headers: { location: `https://x.com${url.pathname.replace(/\/+$/, '')}${url.search}` } });
81
+ const page = status ? postPage(status[1]!, status[2]!, options) : profilePage(profile!, options);
82
+ if (page.location) return new Response(null, { status: page.status, headers: { location: page.location } });
83
+ return new Response(request.method === 'HEAD' ? null : page.html, { status: page.status, headers: { 'content-type': 'text/html; charset=utf-8' } });
84
+ }
85
+
86
+ // ── the pages ──────────────────────────────────────────────────────────────────────────────────
87
+
88
+ // STATUS CODES, as observed at x.com (2026-09-28, logged out, the curl above): a live post under its
89
+ // author's handle 200 (/jack/status/20); the same post under another handle or another case 307 to its
90
+ // author's (/JACK/status/20 and /nosuchaccountzzq9/status/20 → /jack/status/20); an id no post has 404
91
+ // (/XDevelopers/status/404404404404404404); a profile 200 in any case (/xdevelopers); a handle no
92
+ // account has 404 (/nosuchaccountzzq9). X is an SPA and draws the page's words client-side; the words
93
+ // here are the ones its page shows, the codes the ones its server sends.
94
+ type Page = { status: number; html: string; location?: string };
95
+
96
+ function postPage(handle: string, id: string, options: { root?: string; nowMs: number }): Page {
97
+ const read = xWebPost(id, { root: options.root });
98
+ if (!read) return notFound("Hmm...this page doesn’t exist. Try searching for something else.");
99
+ const post = read.post as XRow;
100
+ const users = usersById(read.includes);
101
+ const tweets = tweetsById(read.includes);
102
+ const media = mediaByKey(read.includes);
103
+ const author = users.get(String(post.author_id));
104
+ if (author && String(author.username) !== handle) return { status: 307, html: '', location: `https://x.com/${String(author.username)}/status/${String(post.id)}` };
105
+ if (author?.protected === true) {
106
+ return { status: 200, html: shell('Post / X', header('Post', true) + `<div class="notice"><div class="notice-title">These posts are protected</div><div class="muted">Only approved followers can see @${esc(author.username)}’s posts.</div></div>`) };
107
+ }
108
+ const repliedTo = post.in_reply_to_user_id ? users.get(String(post.in_reply_to_user_id)) : undefined;
109
+ const quotedId = referencedId(post, 'quoted');
110
+ const quoted = quotedId ? tweets.get(quotedId) : undefined;
111
+ const body = header('Post', true)
112
+ + `<article class="post-detail" data-post-id="${esc(post.id)}">`
113
+ + `<div class="detail-head">${avatar(author, 40)}<div class="detail-names">${nameLine(author, '')}</div></div>`
114
+ + (repliedTo ? `<div class="muted replying">Replying to <a class="entity" href="/${esc(repliedTo.username)}">@${esc(repliedTo.username)}</a></div>` : '')
115
+ + (post.text ? postText(post.text, true) : '')
116
+ + mediaBlock(postMedia(post, media), true)
117
+ + (quoted ? quoteCard(quoted, users, options.nowMs) : '')
118
+ + `<div class="detail-time muted"><a href="/${esc(author?.username ?? 'i')}/status/${esc(post.id)}"><time datetime="${esc(post.created_at)}">${esc(fullTimestamp(post.created_at, 'UTC'))}</time></a></div>`
119
+ + actions(true)
120
+ + '</article>';
121
+ const title = `${String(author?.name ?? author?.username ?? 'X')} on X: "${String(post.text ?? '')}" / X`;
122
+ return { status: 200, html: shell(title, body) };
123
+ }
124
+
125
+ function profilePage(handle: string, options: { root?: string; nowMs: number }): Page {
126
+ const read = xWebProfile(handle, { root: options.root });
127
+ if (!read) return notFound('This account doesn’t exist', 'Try searching for another.');
128
+ const user = read.user as XRow;
129
+ const users = usersById(read.includes);
130
+ users.set(String(user.id), { ...users.get(String(user.id)), ...user });
131
+ const tweets = tweetsById(read.includes);
132
+ const media = mediaByKey(read.includes);
133
+ const count = read.posts.length;
134
+ const meta = [
135
+ typeof user.location === 'string' && user.location ? `<span>${esc(user.location)}</span>` : '',
136
+ typeof user.url === 'string' && user.url ? `<span><a class="entity" href="${esc(linkHref(user.url))}" rel="noreferrer">${esc(displayUrl(user.url))}</a></span>` : '',
137
+ joinedLabel(user.created_at, 'UTC') ? `<span>${esc(joinedLabel(user.created_at, 'UTC'))}</span>` : '',
138
+ ].join('');
139
+ const posts = user.protected === true
140
+ ? `<div class="notice"><div class="notice-title">These posts are protected</div><div class="muted">Only approved followers can see @${esc(user.username)}’s posts.</div></div>`
141
+ : count === 0
142
+ ? `<div class="notice"><div class="notice-title">@${esc(user.username)} hasn’t posted</div><div class="muted">When they do, their posts will show up here.</div></div>`
143
+ : `<div class="timeline">${read.posts.map((p) => postRow(p as XRow, users, tweets, media, options.nowMs)).join('')}</div>`;
144
+ const body = header(String(user.name ?? user.username), true, `${count} post${count === 1 ? '' : 's'}`)
145
+ + '<div class="profile"><div class="banner"></div>'
146
+ + `<div class="profile-top"><div class="profile-avatar">${avatar(user, 134)}</div></div>`
147
+ + '<div class="profile-info">'
148
+ + `<div class="profile-name"><span>${esc(user.name)}</span>${user.verified === true ? VERIFIED_SVG : ''}${user.protected === true ? '<span class="lock">🔒</span>' : ''}</div>`
149
+ + `<div class="muted profile-handle">@${esc(user.username)}</div>`
150
+ + (typeof user.description === 'string' && user.description ? `<div class="profile-bio">${postText(user.description, false)}</div>` : '')
151
+ + (meta ? `<div class="muted profile-meta">${meta}</div>` : '')
152
+ + '</div></div>'
153
+ + '<div class="tabs"><span class="tab active">Posts</span><span class="tab">Replies</span><span class="tab">Media</span></div>'
154
+ + posts;
155
+ return { status: 200, html: shell(`${String(user.name ?? user.username)} (@${String(user.username)}) / X`, body) };
156
+ }
157
+
158
+ function notFound(title: string, sub = ''): Page {
159
+ return { status: 404, html: shell('Page not found / X', header('', true) + `<div class="notice"><div class="notice-title">${esc(title)}</div>${sub ? `<div class="muted">${esc(sub)}</div>` : ''}</div>`) };
160
+ }
161
+
162
+ // ── pieces ─────────────────────────────────────────────────────────────────────────────────────
163
+
164
+ const X_LOGO_SVG = '<svg viewBox="0 0 24 24" width="30" height="30" aria-label="X"><path fill="currentColor" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>';
165
+ const VERIFIED_SVG = '<svg class="badge" viewBox="0 0 24 24" width="18" height="18" aria-label="Verified account"><path fill="currentColor" d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.33 2.19c-1.4-.46-2.91-.2-3.92.81s-1.26 2.52-.8 3.91c-1.31.67-2.2 1.91-2.2 3.34s.89 2.67 2.2 3.34c-.46 1.39-.21 2.9.8 3.91s2.52 1.26 3.91.81c.67 1.31 1.91 2.19 3.34 2.19s2.68-.88 3.34-2.19c1.39.45 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.71 4.2L6.8 12.46l1.41-1.42 2.26 2.26 4.8-5.23 1.47 1.36-6.2 6.77z"/></svg>';
166
+ const ICON = (d: string) => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
167
+
168
+ function header(title: string, back: boolean, sub = ''): string {
169
+ return `<div class="col-header">${back ? '<button type="button" class="back" onclick="history.back()" aria-label="Back">←</button>' : ''}<div><div class="col-title">${esc(title)}</div>${sub ? `<div class="muted col-sub">${esc(sub)}</div>` : ''}</div></div>`;
170
+ }
171
+
172
+ /** X's default avatar as x.com draws it for an account without a photo — the image at
173
+ * `X_DEFAULT_PROFILE_IMAGE_URL`, redrawn inline because the twin claims no abs.twimg.com: a #657786
174
+ * head and shoulders on #ccd6dd (its colours and shape read off that PNG, 2026-09-28: the palette's
175
+ * background entry and the silhouette's pixels, head 124–276 × 88–256 and a shoulder circle centred
176
+ * at 200,409 radius 131 in its 400×400 rendition). */
177
+ const DEFAULT_AVATAR_SVG = '<svg viewBox="0 0 400 400" width="100%" height="100%" aria-hidden="true"><rect width="400" height="400" fill="#ccd6dd"/>'
178
+ + '<path fill="#657786" d="M200 88C240 88 276 120 276 176C276 234 240 256 200 256C160 256 124 234 124 176C124 120 160 88 200 88Z"/>'
179
+ + '<circle cx="200" cy="409" r="131" fill="#657786"/></svg>';
180
+
181
+ /** An account's picture: X's default avatar when it has none (its `profile_image_url` is the default,
182
+ * as the API answers it). A photo the account set is not drawn: the twin serves no profile photos
183
+ * (x.web.profile_photo, todo), so it keeps the initial on its hue. */
184
+ function avatar(user: XRow | undefined, size: number): string {
185
+ if (user?.profile_image_url === X_DEFAULT_PROFILE_IMAGE_URL) {
186
+ return `<div class="avatar avatar-default" style="width:${size}px;height:${size}px" data-src="${esc(X_DEFAULT_PROFILE_IMAGE_URL)}" aria-hidden="true">${DEFAULT_AVATAR_SVG}</div>`;
187
+ }
188
+ return `<div class="avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size * 0.42)}px;background:hsl(${avatarHue(user?.username)} 45% 38%)" aria-hidden="true">${esc(avatarInitial(user))}</div>`;
189
+ }
190
+
191
+ function nameLine(user: XRow | undefined, tail: string): string {
192
+ return `<div class="name-line"><a class="display-name" href="/${esc(user?.username ?? 'i')}">${esc(user?.name ?? 'Unknown')}</a>${user?.verified === true ? VERIFIED_SVG : ''}<span class="muted handle">@${esc(user?.username ?? 'unknown')}</span>${tail}</div>`;
193
+ }
194
+
195
+ function postText(text: unknown, big: boolean): string {
196
+ const parts = textSegments(text).map((s) => {
197
+ if (s.kind === 'text') return esc(s.value);
198
+ if (s.kind === 'url') return `<a class="entity" href="${esc(linkHref(s.value))}" target="_blank" rel="noreferrer">${esc(displayUrl(s.value))}</a>`;
199
+ if (s.kind === 'mention') return `<a class="entity" href="/${esc(s.value.slice(1))}">${esc(s.value)}</a>`;
200
+ return `<span class="entity">${esc(s.value)}</span>`;
201
+ });
202
+ return `<div class="${big ? 'post-text post-text-big' : 'post-text'}">${parts.join('')}</div>`;
203
+ }
204
+
205
+ function mediaBlock(media: XRow[], big: boolean): string {
206
+ if (media.length === 0) return '';
207
+ const cls = big ? 'media media-big' : 'media';
208
+ const video = media.find((m) => m.type === 'video');
209
+ if (video) {
210
+ const src = videoSource(video);
211
+ const ratio = Number(video.width) > 0 && Number(video.height) > 0 ? `${video.width} / ${video.height}` : '16 / 9';
212
+ const badge = durationLabel(video.duration_ms);
213
+ return `<div class="${cls}" data-media-key="${esc(video.media_key)}">${src ? `<video class="media-video" style="aspect-ratio:${ratio}" src="${esc(src)}#t=0.001" controls playsinline preload="metadata"></video>` : ''}${badge ? `<span class="media-badge">${esc(badge)}</span>` : ''}</div>`;
214
+ }
215
+ const photos = media.filter((m) => m.type === 'photo' && typeof m.url === 'string').slice(0, 4);
216
+ if (photos.length === 0) return '';
217
+ return `<div class="${cls} media-${photos.length}">${photos.map((m) => {
218
+ const ratio = photos.length === 1 && Number(m.width) > 0 && Number(m.height) > 0 ? ` style="aspect-ratio:${m.width} / ${m.height}"` : '';
219
+ return `<img class="media-img" src="${esc(m.url)}" alt="Image"${ratio}>`;
220
+ }).join('')}</div>`;
221
+ }
222
+
223
+ function quoteCard(quoted: XRow, users: Map<string, XRow>, nowMs: number): string {
224
+ const author = users.get(String(quoted.author_id));
225
+ return `<a class="quote" href="/${esc(author?.username ?? 'i')}/status/${esc(quoted.id)}"><div class="quote-head">${avatar(author, 20)}${nameLine(author, `<span class="muted">·</span><span class="muted">${esc(relativeTime(quoted.created_at, nowMs, 'UTC'))}</span>`)}</div>${postText(quoted.text, false)}</a>`;
226
+ }
227
+
228
+ /** The engagement row. The twin models no public_metrics, so it carries no counts, never an invented zero. */
229
+ function actions(big: boolean): string {
230
+ const icons = ['M7.9 20A9 9 0 1 0 4 16.1L2 22Z', 'M2 9l3-3 3 3M13 18H7a2 2 0 0 1-2-2V6M22 15l-3 3-3-3M11 6h6a2 2 0 0 1 2 2v10', 'M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z', 'M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z', 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12'];
231
+ return `<div class="${big ? 'actions actions-big' : 'actions'}">${icons.map((d) => `<span class="action">${ICON(d)}</span>`).join('')}</div>`;
232
+ }
233
+
234
+ function postRow(post: XRow, users: Map<string, XRow>, tweets: Map<string, XRow>, media: Map<string, XRow>, nowMs: number): string {
235
+ const author = users.get(String(post.author_id));
236
+ const quotedId = referencedId(post, 'quoted');
237
+ const quoted = quotedId ? tweets.get(quotedId) : undefined;
238
+ const href = `/${esc(author?.username ?? 'i')}/status/${esc(post.id)}`;
239
+ return `<article class="post-row" data-post-id="${esc(post.id)}">${avatar(author, 40)}<div class="post-body">`
240
+ + nameLine(author, `<span class="muted">·</span><a class="muted" href="${href}"><time datetime="${esc(post.created_at)}" title="${esc(fullTimestamp(post.created_at, 'UTC'))}">${esc(relativeTime(post.created_at, nowMs, 'UTC'))}</time></a>`)
241
+ + (post.text ? postText(post.text, false) : '')
242
+ + mediaBlock(postMedia(post, media), false)
243
+ + (quoted ? quoteCard(quoted, users, nowMs) : '')
244
+ + actions(false)
245
+ + '</div></article>';
246
+ }
247
+
248
+ function shell(title: string, column: string): string {
249
+ return `<!doctype html>
250
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><style>${STYLES}</style></head>
251
+ <body><div class="layout"><header class="sidenav"><a class="nav-logo" href="/">${X_LOGO_SVG}</a></header><main class="column">${column}</main><aside class="rail"></aside></div></body></html>`;
252
+ }
253
+
254
+ // x.com's own look ("Lights out"), as the mirror's stylesheet draws it; exempt from the Volter brand.
255
+ const STYLES = `
256
+ :root{--bg:#000;--text:#e7e9ea;--muted:#71767b;--line:#2f3336;--blue:#1d9bf0;--banner:#333639}
257
+ *{box-sizing:border-box}
258
+ html,body{margin:0;background:var(--bg);color:var(--text);font-family:"TwitterChirp",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px}
259
+ a{color:inherit;text-decoration:none}
260
+ .muted{color:var(--muted)}.entity{color:var(--blue)}a.entity:hover{text-decoration:underline}
261
+ .layout{display:grid;grid-template-columns:1fr minmax(0,600px) 1fr;min-height:100vh}
262
+ .sidenav{justify-self:end;padding:4px 12px}.nav-logo{display:flex;align-items:center;justify-content:center;width:52px;height:52px}
263
+ .column{border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:100vh;min-width:0}
264
+ .col-header{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:24px;min-height:53px;padding:0 16px;background:rgba(0,0,0,.65);backdrop-filter:blur(12px)}
265
+ .col-title{font-size:20px;line-height:24px;font-weight:700}.col-sub{font-size:13px;line-height:16px}.back{font-size:20px;border:0;background:none;color:inherit;cursor:pointer;padding:0}
266
+ .avatar{flex:none;border-radius:9999px;overflow:hidden;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
267
+ .name-line{display:flex;align-items:center;gap:4px;min-width:0;white-space:nowrap}.display-name{font-weight:700;overflow:hidden;text-overflow:ellipsis}.badge{color:var(--blue);flex:none}
268
+ .post-row{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line)}.post-body{flex:1;min-width:0}
269
+ .post-text{white-space:pre-wrap;overflow-wrap:anywhere}.post-text-big{font-size:17px;line-height:24px;margin-top:12px}
270
+ .post-detail{padding:12px 16px 0;border-bottom:1px solid var(--line)}.detail-head{display:flex;gap:8px;align-items:center}.detail-names .name-line{flex-direction:row}
271
+ .detail-time{padding:16px 0;border-bottom:1px solid var(--line)}.detail-time a:hover{text-decoration:underline}
272
+ .replying{margin-top:4px}
273
+ .media{margin-top:12px;border:1px solid var(--line);border-radius:16px;overflow:hidden;position:relative;display:grid;gap:2px}
274
+ .media-2,.media-3,.media-4{grid-template-columns:1fr 1fr}.media-img{width:100%;display:block;object-fit:cover;max-height:510px}.media-video{width:100%;display:block;background:#000}
275
+ .media-badge{position:absolute;left:12px;bottom:12px;background:rgba(0,0,0,.77);border-radius:4px;padding:0 4px;font-size:13px}
276
+ .quote{display:block;margin-top:12px;border:1px solid var(--line);border-radius:16px;padding:12px}.quote-head{display:flex;gap:4px;align-items:center;margin-bottom:4px}
277
+ .actions{display:flex;justify-content:space-between;max-width:425px;margin-top:12px;color:var(--muted)}.actions-big{max-width:none;padding:4px 0 12px;margin-top:12px}.action{display:inline-flex}
278
+ .banner{height:200px;background:var(--banner)}.profile-top{position:relative;height:72px}.profile-avatar{position:absolute;left:16px;top:-72px;border:4px solid var(--bg);border-radius:9999px}
279
+ .profile-info{padding:4px 16px 16px}.profile-name{display:flex;align-items:center;gap:4px;font-size:20px;line-height:24px;font-weight:800}.profile-bio{margin-top:12px}
280
+ .profile-meta{display:flex;flex-wrap:wrap;column-gap:12px;row-gap:4px;margin-top:12px}
281
+ .tabs{display:flex;border-bottom:1px solid var(--line)}.tab{flex:1;text-align:center;padding:16px 0;color:var(--muted);font-weight:500}.tab.active{color:var(--text);font-weight:700;box-shadow:inset 0 -4px 0 var(--blue)}
282
+ .notice{padding:32px;max-width:400px;margin:0 auto}.notice-title{font-size:31px;line-height:36px;font-weight:800;margin-bottom:8px}
283
+ `;