@volter/twin-x 0.1.3 → 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.
@@ -1336,6 +1336,23 @@ export const X_CAPABILITIES = [
1336
1336
  return wrong.status === 307 && wrong.headers.get('location') === canonical && cased.status === 307 && cased.headers.get('location') === canonical
1337
1337
  && slash.status === 307 && slash.headers.get('location') === canonical && right.status === 200 && (await right.text()).includes('where it lives');
1338
1338
  })),
1339
+ 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', () => withWorld(async (h, root) => {
1340
+ const fetchTwin = createXTwinFetch({ root });
1341
+ await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3200', username: 'plainface', name: 'Plain Face' }, headers: {} });
1342
+ 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: {} });
1343
+ await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'plainface-token', account_id: '3200', scopes: FULL_SCOPES }, headers: {} });
1344
+ const posted = (await h({ m: 'POST', p: '/2/tweets', b: { text: 'no photo yet' }, headers: { authorization: 'Bearer plainface-token' } })).body.data.id;
1345
+ const api = (await h({ m: 'GET', p: '/2/users/3200?user.fields=profile_image_url' })).body.data?.profile_image_url;
1346
+ const page = async (path) => (await fetchTwin(new Request(`http://127.0.0.1:9${path}`, { headers: { host: 'x.com' } }))).text();
1347
+ const drawn = (html) => /class="avatar avatar-default"[^>]*data-src="([^"]+)"[^>]*><svg[^>]*><rect[^>]*fill="#ccd6dd"\/><path fill="#657786"/.exec(html)?.[1];
1348
+ const profile = await page('/plainface');
1349
+ const post = await page(`/plainface/status/${posted}`);
1350
+ const photo = await page('/photoface');
1351
+ return typeof api === 'string' && api.includes('default_profile_images')
1352
+ && drawn(profile) === api && drawn(post) === api
1353
+ && !photo.includes('avatar-default') && photo.includes('Photo Face');
1354
+ })),
1355
+ 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'),
1339
1356
  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'),
1340
1357
  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'),
1341
1358
  todo('x.mirror.notifications_all', 'mirror', "Notifications beyond Mentions (likes, reposts, follows) — the twin models no engagement events to list", 'ui', 'common'),
@@ -1484,7 +1484,7 @@ function armedRateRefusal(all) {
1484
1484
  /** The fields and expansions the web pages draw (the mirror's POST_READ_QUERY, plus the profile's user fields). */
1485
1485
  const WEB_READ_SHAPE = 'tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id,referenced_tweets'
1486
1486
  + '&expansions=author_id,in_reply_to_user_id,referenced_tweets.id,referenced_tweets.id.author_id,attachments.media_keys'
1487
- + '&user.fields=created_at,description,location,url,verified,protected'
1487
+ + '&user.fields=created_at,description,location,url,verified,protected,profile_image_url'
1488
1488
  + '&media.fields=url,type,width,height,duration_ms,preview_image_url,variants';
1489
1489
  function webShape(publicBase) {
1490
1490
  const shape = parseReadShape(new URL(`http://twin.local/?${WEB_READ_SHAPE}`), (publicBase ?? PBS_BASE).replace(/\/+$/, ''));
package/dist/src/x-web.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // claim is two anchored path shapes and nothing else (X_WEB_PATHS); the router below answers exactly
16
16
  // those shapes, only for a request the World routed from one of X's web hosts (X_WEB_HOSTS).
17
17
  import { avatarHue, avatarInitial, displayUrl, durationLabel, fullTimestamp, joinedLabel, linkHref, mediaByKey, postMedia, referencedId, relativeTime, textSegments, tweetsById, usersById, videoSource } from "./x-mirror-ui.js";
18
- import { xWebPost, xWebProfile } from "./x-twin.js";
18
+ import { X_DEFAULT_PROFILE_IMAGE_URL, xWebPost, xWebProfile } from "./x-twin.js";
19
19
  /** x.com's top-level routes that are not accounts (and the twin's own doors): never a profile. Each was
20
20
  * observed as a route of its own at x.com (2026-09-28, logged out, `curl -A 'Mozilla/5.0' -o /dev/null
21
21
  * -w '%{http_code} %{redirect_url}' https://x.com/<name>`): home, explore, notifications, settings,
@@ -154,7 +154,21 @@ const ICON = (d) => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
154
154
  function header(title, back, sub = '') {
155
155
  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>`;
156
156
  }
157
+ /** X's default avatar as x.com draws it for an account without a photo — the image at
158
+ * `X_DEFAULT_PROFILE_IMAGE_URL`, redrawn inline because the twin claims no abs.twimg.com: a #657786
159
+ * head and shoulders on #ccd6dd (its colours and shape read off that PNG, 2026-09-28: the palette's
160
+ * background entry and the silhouette's pixels, head 124–276 × 88–256 and a shoulder circle centred
161
+ * at 200,409 radius 131 in its 400×400 rendition). */
162
+ const DEFAULT_AVATAR_SVG = '<svg viewBox="0 0 400 400" width="100%" height="100%" aria-hidden="true"><rect width="400" height="400" fill="#ccd6dd"/>'
163
+ + '<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"/>'
164
+ + '<circle cx="200" cy="409" r="131" fill="#657786"/></svg>';
165
+ /** An account's picture: X's default avatar when it has none (its `profile_image_url` is the default,
166
+ * as the API answers it). A photo the account set is not drawn: the twin serves no profile photos
167
+ * (x.web.profile_photo, todo), so it keeps the initial on its hue. */
157
168
  function avatar(user, size) {
169
+ if (user?.profile_image_url === X_DEFAULT_PROFILE_IMAGE_URL) {
170
+ 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>`;
171
+ }
158
172
  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>`;
159
173
  }
160
174
  function nameLine(user, tail) {
@@ -230,7 +244,7 @@ a{color:inherit;text-decoration:none}
230
244
  .column{border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:100vh;min-width:0}
231
245
  .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)}
232
246
  .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}
233
- .avatar{flex:none;border-radius:9999px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
247
+ .avatar{flex:none;border-radius:9999px;overflow:hidden;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
234
248
  .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}
235
249
  .post-row{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line)}.post-body{flex:1;min-width:0}
236
250
  .post-text{white-space:pre-wrap;overflow-wrap:anywhere}.post-text-big{font-size:17px;line-height:24px;margin-top:12px}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-x",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local X (Twitter) API v2 twin (post, reply, delete, timelines, user lookup) with an x.com mirror, built on @volter/world-core.",
5
5
  "author": "Volter (https://github.com/volter-ai)",
6
6
  "license": "Apache-2.0",
@@ -36,14 +36,14 @@
36
36
  "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
37
37
  },
38
38
  "peerDependencies": {
39
- "@volter/world-core": "2.0.3"
39
+ "@volter/world-core": "2.0.4"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "^1.2.20",
43
43
  "@types/node": "^24.0.0",
44
44
  "@types/react": "^19.2.17",
45
45
  "@types/react-dom": "^19.2.3",
46
- "@volter/world-core": "2.0.3",
46
+ "@volter/world-core": "2.0.4",
47
47
  "@volter/world-tooling": "0.1.0",
48
48
  "twitter-api-v2": "^1.29.1",
49
49
  "typescript": "^5.9.0"
@@ -1398,6 +1398,24 @@ export const X_CAPABILITIES: CapabilitySpec[] = [
1398
1398
  return wrong.status === 307 && wrong.headers.get('location') === canonical && cased.status === 307 && cased.headers.get('location') === canonical
1399
1399
  && slash.status === 307 && slash.headers.get('location') === canonical && right.status === 200 && (await right.text()).includes('where it lives');
1400
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'),
1401
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'),
1402
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'),
1403
1421
  todo('x.mirror.notifications_all', 'mirror', "Notifications beyond Mentions (likes, reposts, follows) — the twin models no engagement events to list", 'ui', 'common'),
package/src/x-twin.ts CHANGED
@@ -1498,7 +1498,7 @@ function armedRateRefusal(all: Resource[]): XResponse | undefined {
1498
1498
  /** The fields and expansions the web pages draw (the mirror's POST_READ_QUERY, plus the profile's user fields). */
1499
1499
  const WEB_READ_SHAPE = 'tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id,referenced_tweets'
1500
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'
1501
+ + '&user.fields=created_at,description,location,url,verified,protected,profile_image_url'
1502
1502
  + '&media.fields=url,type,width,height,duration_ms,preview_image_url,variants';
1503
1503
 
1504
1504
  function webShape(publicBase?: string): ReadShape {
package/src/x-web.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  // claim is two anchored path shapes and nothing else (X_WEB_PATHS); the router below answers exactly
16
16
  // those shapes, only for a request the World routed from one of X's web hosts (X_WEB_HOSTS).
17
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 { xWebPost, xWebProfile } from './x-twin.ts';
18
+ import { X_DEFAULT_PROFILE_IMAGE_URL, xWebPost, xWebProfile } from './x-twin.ts';
19
19
 
20
20
  /** x.com's top-level routes that are not accounts (and the twin's own doors): never a profile. Each was
21
21
  * observed as a route of its own at x.com (2026-09-28, logged out, `curl -A 'Mozilla/5.0' -o /dev/null
@@ -169,7 +169,22 @@ function header(title: string, back: boolean, sub = ''): string {
169
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
170
  }
171
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. */
172
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
+ }
173
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>`;
174
189
  }
175
190
 
@@ -248,7 +263,7 @@ a{color:inherit;text-decoration:none}
248
263
  .column{border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:100vh;min-width:0}
249
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)}
250
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}
251
- .avatar{flex:none;border-radius:9999px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
266
+ .avatar{flex:none;border-radius:9999px;overflow:hidden;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700}
252
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}
253
268
  .post-row{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line)}.post-body{flex:1;min-width:0}
254
269
  .post-text{white-space:pre-wrap;overflow-wrap:anywhere}.post-text-big{font-size:17px;line-height:24px;margin-top:12px}