// Renders the real page HTML with representative fixtures so the design can be looked at, not
// guessed at. The render functions are pure, so this needs no worker, no auth and no network.
//
// bun run preview # writes .preview/*.html
// bun run preview --shot # ...and screenshots them with chromium
import {mkdirSync, writeFileSync} from 'node:fs'
import {join} from 'node:path'
import {renderDebugPage, type DebugState} from '../src/render/debug'
import {renderPage} from '../src/render/page'
import type {ActorCandidate} from '../src/candidates'
import type {AppState, ManagementTarget, Recommendation, StoredFeature, UserList} from '../src/types'
const OUT = join(import.meta.dir, '..', '.preview')
function target(overrides: Partial = {}): ManagementTarget {
return {
actorDid: 'did:plc:a',
actorHandle: 'a.example',
actorName: 'Ay',
sourceUris: [],
currentUnreadCount: 0,
recent24hCount: 0,
recent7dCount: 0,
allTimeCount: 0,
reasons: {},
subscriptionPosts: false,
subscriptionReplies: false,
muted: false,
blocked: false,
examples: [],
...overrides,
}
}
function recommendation(overrides: Partial = {}): Recommendation {
return {
description: 'Quiet likes from strangers',
context: '',
why: 'Likes are most of what reaches this account, and they come from people you do not follow. Limiting them to follows keeps the ones you care about.',
code: 'async function run(agent, ctx) {\n await agent.app.bsky.notification.putPreferencesV2({\n like: {include: "follows", list: true, push: false}\n })\n}',
sourceUris: [],
actorDids: [],
actors: [],
evidence: [],
...overrides,
}
}
const state: AppState = {
activeAccount: {did: 'did:plc:me', handle: 'zzstoatzz.io', avatar: 'https://placehold.co/88/1b2735/7bb4ff/png?text=z'},
accounts: [
{did: 'did:plc:me', handle: 'zzstoatzz.io', avatar: 'https://placehold.co/88/1b2735/7bb4ff/png?text=z'},
{did: 'did:plc:2', handle: 'plyr.fm', avatar: 'https://placehold.co/88/101820/5ea3ff/png?text=p'},
{did: 'did:plc:3', handle: 'zat.dev', avatar: 'https://placehold.co/88/1c3326/9fd6a8/png?text=z'},
{did: 'did:plc:4', handle: 'find-bufo.com', avatar: 'https://placehold.co/88/2b2413/e0b23d/png?text=b'},
{did: 'did:plc:5', handle: 'prefect.io', avatar: 'https://placehold.co/88/171b2b/8f9bff/png?text=P'},
{did: 'did:plc:6', handle: 'waow.tech', needsReconnect: true},
],
canManageAccounts: true,
isAdmin: true,
groupNotifications: [
{
sequence: 3,
accountDid: 'did:plc:me',
accountHandle: 'zzstoatzz.io',
indexedAt: new Date(Date.now() - 3 * 60_000).toISOString(),
observedAt: new Date().toISOString(),
notification: {
uri: 'at://did:plc:phi/app.bsky.feed.post/1',
reason: 'reply',
authorDid: 'did:plc:phi',
authorHandle: 'phi.zzstoatzz.io',
authorName: 'ϕ',
indexedAt: new Date(Date.now() - 3 * 60_000).toISOString(),
isRead: false,
text: 'the labeler adapter landed — trust tiers are wired through the candidate ranker now',
isReply: true,
},
},
{
sequence: 2,
accountDid: 'did:plc:2',
accountHandle: 'plyr.fm',
indexedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
observedAt: new Date().toISOString(),
notification: {
uri: 'at://did:plc:x/app.bsky.feed.post/2',
reason: 'like',
authorDid: 'did:plc:x',
authorHandle: 'brookie.blog',
authorName: 'Brookie',
indexedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
isRead: false,
subjectText: 'shipped a thing',
isReply: false,
},
},
],
groupUnreadCount: 2,
identityCard: {
note: 'busy week, and @phi.zzstoatzz.io is most of it',
statKeys: ['week', 'loudest', 'unread', 'subscriptions'],
tone: 'busy',
stats: [
{key: 'week', label: 'this week', value: '155', notable: true, iconName: 'bell'},
{key: 'loudest', label: 'most from', value: '@phi.zzstoatzz.io', notable: true, labelFirst: true},
{key: 'unread', label: 'unread now', value: '2'},
{key: 'subscriptions', label: 'subscribed', value: '6', iconName: 'subscription', detail: '@phi.zzstoatzz.io, @doodl.waow.tech, @zat.dev, @plyr.fm, @bufo.uk, @find-bufo.com'},
{key: 'muted', label: 'muted', value: '1', iconName: 'bellOff', detail: '@loud.example'},
],
entities: [{handle: 'phi.zzstoatzz.io', displayName: 'ϕ', relationship: 'subscribed'}],
},
unreadCount: 2,
recommendations: [
recommendation(),
recommendation({
description: 'Turn off doodl post subscription',
context: 'doodl',
why: 'doodl has a posts subscription on but has not sent anything in the past week.',
actorDids: ['did:plc:d'],
actors: [{did: 'did:plc:d', handle: 'doodl.waow.tech', name: 'doodl', profileUrl: 'https://bsky.app/profile/doodl.waow.tech'}],
code: 'async function run(agent, ctx) {\n await agent.app.bsky.notification.putActivitySubscription({subject: "did:plc:d", activitySubscription: {post: false, reply: false}})\n}',
}),
],
currentSubscriptions: [
target({actorHandle: 'phi.zzstoatzz.io', actorName: 'ϕ', subscriptionPosts: true, subscriptionReplies: true}),
target({actorDid: 'did:plc:d', actorHandle: 'doodl.waow.tech', actorName: 'doodl', subscriptionPosts: true}),
],
currentBlocks: [],
placeholder: 'e.g. turn off replies from @phi.zzstoatzz.io',
recommendationPrompt: 'system prompt',
recommendationGuidance: '',
customRecommendationPrompt: false,
webhookIntegration: {configured: false, failureCount: 0, reasons: []},
queueState: 'ready',
}
// /me fixtures. The page is mostly derived from candidates, so the interesting axes are
// "does this person have a rich evidence record" and "does the user keep any lists" — the
// second one being the branch that renders an empty-state instead of rows.
function candidate(overrides: Partial = {}): ActorCandidate {
const evidence = {
did: 'did:plc:a',
handle: 'a.example',
displayName: 'Ay',
allTimeCount: 42,
recent7dCount: 12,
recent24hCount: 3,
replyCount: 4,
muted: false,
blocked: false,
follows: true,
followedBy: true,
mutual: true,
subscribedPosts: true,
subscribedReplies: false,
labels: [],
listCount: 1,
...overrides.evidence,
}
return {
did: evidence.did,
handle: evidence.handle,
displayName: evidence.displayName,
score: 30,
reasons: ['3 notifications in the last 24h', 'mutual'],
...overrides,
evidence,
}
}
const meCandidates: ActorCandidate[] = [
candidate(),
candidate({score: 22, evidence: {did: 'did:plc:phi', handle: 'phi.zzstoatzz.io', displayName: 'ϕ', mutual: false, follows: true, followedBy: false, recent7dCount: 55, recent24hCount: 9, replyCount: 1, labels: ['spam'], allTimeCount: 155, listCount: 0, blocked: false, muted: false, subscribedPosts: true, subscribedReplies: true}}),
candidate({score: 8, evidence: {did: 'did:plc:d', handle: 'doodl.waow.tech', displayName: 'doodl', mutual: false, follows: false, followedBy: true, recent7dCount: 2, recent24hCount: 0, replyCount: 0, labels: [], allTimeCount: 9, listCount: 0, blocked: false, muted: false, subscribedPosts: true, subscribedReplies: false}}),
]
const meLists: UserList[] = [
{uri: 'at://did:plc:self/app.bsky.graph.list/1', name: 'zig people', memberCount: 7},
{uri: 'at://did:plc:self/app.bsky.graph.list/2', name: 'atproto folks', memberCount: 23},
]
const meState: DebugState = {
userDid: 'did:plc:self',
unreadCount: 2,
targets: state.currentSubscriptions,
avatars: new Map(),
// Mirrors the real shape that read as a contradiction: several subscribed labelers plus noti's
// own default, which is why "checked" exceeds "subscribed" by exactly one.
labelers: [
{did: 'did:plc:mod', name: 'bluesky moderation', tier: 'noti-default', subscribed: false},
{did: 'did:plc:l1', name: 'Bookmarks', tier: 'user-subscribed', subscribed: true},
{did: 'did:plc:l2', name: 'Alt Heroes', tier: 'user-subscribed', subscribed: true},
{did: 'did:plc:l3', name: 'Skywatch Blue', tier: 'user-subscribed', subscribed: true},
],
subscribedLabelers: [
{did: 'did:plc:l1', handle: 'bookmarks.example', name: 'Bookmarks', profileUrl: 'https://bsky.app/profile/bookmarks.example'},
{did: 'did:plc:l2', handle: 'alt.example', name: 'Alt Heroes', profileUrl: 'https://bsky.app/profile/alt.example'},
{did: 'did:plc:l3', handle: 'skywatch.example', name: 'Skywatch Blue', profileUrl: 'https://bsky.app/profile/skywatch.example'},
],
backfill: [
{collection: 'app.bsky.graph.block', status: 'complete', cursor: null, recordsSeen: 12, recordsWritten: 12, attemptCount: 1, nextAttemptAt: null, lastError: null},
{collection: 'app.bsky.graph.listitem', status: 'complete', cursor: '3lkzq7xk2ps2h', recordsSeen: 96, recordsWritten: 96, attemptCount: 1, nextAttemptAt: null, lastError: null},
{collection: 'app.bsky.graph.follow', status: 'error', cursor: null, recordsSeen: 210, recordsWritten: 180, attemptCount: 3, nextAttemptAt: new Date(Date.now() + 9 * 60_000).toISOString(), lastError: 'listRecords 502 from https://pds.example — upstream unavailable'},
],
parity: [
{featureKey: 'viewer.blocked', pdsCount: 14, appviewCount: 5, agreed: 5, onlyPds: Array.from({length: 9}, () => 'did:plc:x'), onlyAppview: [], inAgreement: true},
],
backfillReady: {ready: true, reasons: []},
candidates: meCandidates,
candidateAvatars: new Map(),
// Real provenance shapes: a contradiction between sources on the same key (so the shadowed row
// renders), a multi-valued list membership keyed by list URI, and a labeler-sourced label.
candidateFeatures: new Map([
[
'did:plc:phi',
[
{actorDid: 'did:plc:phi', key: 'viewer.follows', value: 'true', source: 'pds', sourceId: 'self-repo', observedAt: new Date(Date.now() - 3 * 3600_000).toISOString()},
{actorDid: 'did:plc:phi', key: 'viewer.follows', value: 'false', source: 'viewer', sourceId: 'bsky-appview-profile', observedAt: new Date(Date.now() - 5 * 60_000).toISOString()},
{actorDid: 'did:plc:phi', key: 'viewer.mutual', value: 'true', source: 'viewer', sourceId: 'bsky-appview-profile', observedAt: new Date(Date.now() - 5 * 60_000).toISOString()},
{actorDid: 'did:plc:phi', key: 'list.member', value: 'true', source: 'pds', sourceId: 'at://did:plc:self/app.bsky.graph.list/3labc', observedAt: new Date(Date.now() - 26 * 3600_000).toISOString()},
{actorDid: 'did:plc:phi', key: 'label.spam', value: '{"tier":"user-subscribed"}', source: 'labeler', sourceId: 'did:plc:ar7c4by46qjdydhdevvrndac', observedAt: new Date(Date.now() - 40 * 3600_000).toISOString(), expiresAt: new Date(Date.now() + 5 * 86400_000).toISOString()},
],
],
]),
lists: meLists,
// Deliberately larger than the candidate list: these are totals over the whole history, and
// the fixture should make it obvious that the strip is not summing the three balls below it.
totals: {peopleNoticed: 48, pings7d: 315, directReplies: 167, mutuals: 22, labeled: 3},
advanced: false,
}
const pages = {
home: renderPage(state),
// Elicitation: the model asked one question and offered answers the user can click.
clarify: renderPage({
...state,
placeholder: 'answer above, or type something else',
proposal: {
kind: 'clarify',
request: 'mute alex',
message: 'Which account do you mean? Two of the people you talk to go by “alex”.',
examples: ['mute @alex.bsky.team', 'mute @alex.example.com'],
},
// A conversation several turns deep, so the render is exercised as a thread rather than a pair.
thread: [
{role: 'user', text: 'can you make a core atproto dev list', createdAt: ''},
{role: 'noti', text: 'I can create a public list called “Core atproto dev”. Applying the suggestion below will create it, empty — you can add people to it after that.', kind: 'action', createdAt: ''},
{role: 'user', text: 'mute alex', createdAt: ''},
{role: 'noti', text: 'Which account do you mean? Two of the people you talk to go by “alex”.', kind: 'clarify', createdAt: ''},
],
}),
me: renderDebugPage(meState),
'me-advanced': renderDebugPage({...meState, advanced: true}),
'me-empty': renderDebugPage({...meState, lists: [], candidates: [], unreadCount: 0}),
loading: renderPage({...state, queueState: 'loading', recommendations: []}),
empty: renderPage({...state, recommendations: [], groupNotifications: [], groupUnreadCount: 0, unreadCount: 0,
identityCard: {
note: 'nothing needs attention',
statKeys: ['unread'],
tone: 'quiet',
stats: [{key: 'unread', label: 'unread', value: '0'}, {key: 'age', label: 'on bluesky', value: '2 years'}],
entities: [],
}}),
} satisfies Record
mkdirSync(OUT, {recursive: true})
for (const [name, html] of Object.entries(pages)) {
writeFileSync(join(OUT, `${name}.html`), html)
console.log(`wrote .preview/${name}.html`)
}
if (process.argv.includes('--shot')) {
const {chromium} = await import('playwright')
const browser = await chromium.launch()
for (const [name, width] of [['home', 1100], ['home-mobile', 420], ['loading', 1100], ['empty', 1100], ['me', 1100], ['me-mobile', 420], ['me-advanced', 1100], ['me-empty', 1100]] as const) {
const file = name.replace('-mobile', '')
const page = await browser.newPage({viewport: {width, height: 1000}, deviceScaleFactor: 2})
await page.goto(`file://${join(OUT, `${file}.html`)}`)
await page.waitForTimeout(400)
await page.screenshot({path: join(OUT, `${name}.png`), fullPage: true})
console.log(`shot .preview/${name}.png`)
await page.close()
}
await browser.close()
}