morphit/apps/web/scripts/chat-header-layout-smoke.ts

232 lines
14 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Smoke: tt.txt #7 — the chat header, restructured (Ken).
*
* Ken's reason was mobile: the old header spent its horizontal budget on a
* single-line IdentityLabel plus a LIVE pip stacked under the kebab, leaving the
* sprout / trade-count / reputation nowhere to go. The header is now shaped like
* an order card, and LIVE moved inside the kebab menu.
*
* "Chatting with:"
* ┌──────┐ display name 🌱
* │ 48px │ (posting key) · N trades · ⭐ 4.87 ⋮ ← top-aligned
* └──────┘ RE: <order title>
*
* Menu order, top to bottom: LIVE · divider · Chat Security · Verify peer ·
* Block @username · Export chat.
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB = join(__dirname, '..');
const src = readFileSync(join(WEB, 'src', 'lib', 'components', 'ConversationView.svelte'), 'utf8');
/** Match CODE, not prose — the fix's own comments describe the old layout. */
const code = src
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('//'))
.join('\n');
let pass = 0;
let fail = 0;
// v1.7.5 — third arg is an optional failure detail. The rewritten checks pass one
// (they explain WHY the requirement holds when the landmark it used to key off
// has moved), and printing it only on failure is where it's actually wanted.
const check = (name: string, ok: boolean, detail?: string): void => {
if (ok) {
pass++;
console.log(` \u2713 ${name}`);
} else {
fail++;
console.error(` \u2717 ${name}${detail ? `\n ${detail}` : ''}`);
}
};
/** Index of the first occurrence, or Infinity. Used to assert ORDER. */
const at = (needle: string): number => {
const i = code.indexOf(needle);
return i === -1 ? Number.POSITIVE_INFINITY : i;
};
// ─── the identity cluster ────────────────────────────────────────────
check('the avatar is bigger (48px) and rendered without its inline handle', /avatarSize=\{48\}/.test(code) && /hideHandle/.test(code));
// v1.8.13 (Ken) — line 1 is still the display name, but it no longer falls
// back to `@peer` UNCONDITIONALLY. While the peer profile is loading it shows a
// neutral placeholder instead: asserting the handle and then rewriting it to a
// display name is a mid-conversation identity change, which Ken rightly called
// out as indistinguishable from a swap attack. The requirement is the intent
// (display name leads) plus the new one (never assert before we know).
check('line 1 leads with the display name', /\{peerLabelProps\.displayName\}/.test(code));
check(
'…and does not assert @peer before the profile resolves',
/\{:else if peerProfileResolved\}/.test(code),
'an identity that appears then changes reads as a swap attack'
);
check('the sprout sits at the end of the display-name line', at('peerLabelProps.displayName') < at('<NewTraderChip />'));
check('line 2 carries the truncated posting key', /truncatePublicKey\(peerPostingKey\)/.test(code));
check('line 2 carries the trade count', at('truncatePublicKey(peerPostingKey)') < at('orderbook.card.trades_only'));
check('the sprout is on line 1, ABOVE the key/trades line', at('<NewTraderChip />') < at('truncatePublicKey(peerPostingKey)'));
check(
'line 3 is the RE: order link, below all of it',
at('<TradeRepCluster') < at('chat.header.re'),
// v1.7.5 — was proxied by `orderbook.card.trades_only`, which moved INTO
// TradeRepCluster and no longer appears here. The REQUIREMENT (RE: comes
// last) is unchanged; only the landmark moved.
'the RE: line must sit below the identity cluster'
);
// ─── Ken: the reputation must NEVER wrap ─────────────────────────────
// On a narrow viewport line 2 (key · trades · ⭐) is the line that runs out of
// room, so the score renders on the DISPLAY-NAME line instead. Two variants,
// split by a breakpoint — deterministic, no measuring. There are therefore TWO
// occurrences of the score, and the naive "first occurrence" ordering that used
// to hold no longer does.
// Each variant uses the score three times: aria-label, title, and the visible
// text. Two variants → six. Pinning the number keeps a third stray copy out.
// v1.7.5 (t.txt #8) — the four checks below pinned the HAND-ROLLED score
// (`peerReputation.score.toFixed(2)` and a long literal class string). That
// implementation is the bug Ken reported — a GOLD emoji where the app's star has
// been emerald since v1.5.5, and no counts beside the average. Pinning its
// literals would have forced it to stay.
//
// Every REQUIREMENT below is unchanged and still pinned. They now assert against
// the shared cluster, which is what actually carries them.
const clusterHits = (code.match(/<TradeRepCluster/g) ?? []).length;
check(
'the reputation is rendered in exactly two places (mobile + desktop)',
clusterHits === 2,
`found ${clusterHits}`
);
check(
'the mobile cluster sits on the display-name line and hides from `sm` up',
/<span class="sm:hidden">[\s\S]{0,200}<TradeRepCluster/.test(code) &&
at('<span class="sm:hidden">') < at('truncatePublicKey(peerPostingKey)'),
'Ken: the reputation must NEVER wrap — on a phone it rides line 1 instead'
);
check(
'the desktop cluster sits on line 2 and appears only from `sm` up',
/<span class="hidden sm:inline-flex">[\s\S]{0,200}<TradeRepCluster/.test(code) &&
at('<span class="hidden sm:inline-flex">') > at('truncatePublicKey(peerPostingKey)'),
'the posting key is the trust anchor and must never be truncated for the score'
);
check('line 2 cannot wrap at all (flex-nowrap)', /mt-0\.5 flex min-w-0 flex-nowrap items-center/.test(code));
check('if anything must give, it is the posting key that truncates', /<span class="truncate font-mono">\(\{truncatePublicKey\(peerPostingKey\)\}\)<\/span>/.test(code));
// v1.7.5 — `flex-none whitespace-nowrap` was on the hand-rolled trades span. The
// rule now lives inside TradeRepCluster, which is where it belongs: the cluster
// is ONE unbreakable chunk by contract (Ken: "none of that chunk ever gets
// broken, no wrap"). Assert it where it actually is, rather than pinning a
// literal in a file that no longer owns the rule.
check(
'the trade count never wraps or truncates',
/inline-flex flex-none items-center gap-1\.5 whitespace-nowrap/.test(
readFileSync(join(WEB, 'src', 'lib', 'components', 'TradeRepCluster.svelte'), 'utf8')
),
'the cluster must stay nowrap + flex-none or a narrow phone breaks it mid-chunk'
);
check('the RE: line still links to the order', /\/@\$\{orderOwner \?\? peer\}\/\$\{orderPermlink\}/.test(code));
// ─── the kebab ───────────────────────────────────────────────────────
// NB: match the kebab's MARKUP (`bind:this=`), not the identifier — the latter
// is declared in <script>, thousands of characters before any of this.
check('the kebab is the last item of the identity ROW (top-aligned with the name)', at('items-start gap-3') < at('bind:this={overflowTriggerEl}'));
// v1.8.10 (Ken) — this used to assert the RE: line came BEFORE the kebab, i.e.
// that it lived inside the identity row. That was the bug: the kebab is
// `flex-none`, so sitting in the same row it reserved its footprint against all
// three lines and the subject truncated early with visible empty space beside
// it. The RE: line is now a SIBLING of the row, so it spans the full header
// width — which means it must come AFTER the kebab in source order. Pinning the
// new direction keeps the old structure from creeping back.
check(
'the RE: line sits AFTER the identity row, so it spans the full header width',
at('chat.header.re') > at('bind:this={overflowTriggerEl}'),
'inside the row it shares width with the flex-none kebab and truncates early'
);
check('the old right-hand column wrapper is gone', !/flex flex-none flex-col items-end/.test(code));
// ─── LIVE moved into the menu, in Ken's order ────────────────────────
const menu = code.slice(code.indexOf('role="menu"'));
const m = (needle: string): number => {
const i = menu.indexOf(needle);
return i === -1 ? Number.POSITIVE_INFINITY : i;
};
check('LIVE is inside the menu', m("chat.live") < Number.POSITIVE_INFINITY);
check('there is exactly ONE animated pip left in the file', (code.match(/animate-ping/g) ?? []).length === 1);
check('LIVE sits above a hairline divider', m("chat.live") < m('border-t border-ink-200'));
check('divider → Chat Security', m('border-t border-ink-200') < m('chat.security.menu_label'));
check('Chat Security → Verify peer', m('chat.security.menu_label') < m('chat.menu.verify_peer'));
check('Verify peer → Block @username', m('chat.menu.verify_peer') < m('chat.block.confirm'));
check('Block @username → Export chat', m('chat.block.confirm') < m('chat.export.menu_label'));
check('LIVE is a status readout, not a focusable menuitem', !/role="menuitem"[\s\S]{0,200}chat\.live/.test(menu));
check('LIVE only renders while streaming (unchanged semantics, just relocated)', /\{#if streaming\}[\s\S]{0,900}chat\.live/.test(menu));
// ─── each label must sit in the SAME button as its handler ───────────
// Without this, swapping two onclick handlers would leave the menu reading
// correctly while "Chat Security" opened Verify peer — an order check keyed off
// labels alone cannot see that. (Found by tamper-testing this very smoke.)
const buttons = [...menu.matchAll(/<button[\s\S]*?<\/button>/g)].map((mm) => mm[0]);
const pairedIn = (handler: string, label: string): boolean =>
buttons.some((b) => b.includes(handler) && b.includes(label));
check('Chat Security button carries the Chat Security label', pairedIn('onclick={openChatSecurity}', 'chat.security.menu_label'));
check('Verify peer button carries the Verify peer label', pairedIn('onclick={openVerifyPeer}', 'chat.menu.verify_peer'));
check('Block button carries the block/unblock label', pairedIn('onToggleBlock();', 'chat.block.confirm'));
check('Export button carries the Export label', pairedIn('onclick={exportChatToPdf}', 'chat.export.menu_label'));
// ─── Ken's batch: order status, header tint ──────────────────────────
check('the RE: line shows the order\u2019s current status in parens', /\{#if orderStatusLabel\}[\s\S]{0,220}\(\{orderStatusLabel\}\)/.test(code));
check('…and the status never truncates (the title does)', /flex-none">\(\{orderStatusLabel\}\)<\/span>/.test(code));
check('the status reuses order_detail.status_* (no new locale keys)', /order_detail\.status_live/.test(code) && /order_detail\.status_cancelled/.test(code) && /order_detail\.status_expired/.test(code));
check('the header row wears the FAQ dim-emerald (same tokens as .card-hover-emerald)', /bg-emerald-50\/30[\s\S]{0,80}dark:bg-morphit-emerald\/\[0\.05\]/.test(code));
// ─── nothing lost ────────────────────────────────────────────────────
check('Block/Unblock still opens the confirm modal', /closeOverflowMenu\(\);\s*\n\s*onToggleBlock\(\);/.test(code));
check('Chat Security still clears its one-time nudge dot', /onclick=\{openChatSecurity\}/.test(code) && /chatSecurityNudgeSeen/.test(code));
check('Export chat still wired', /onclick=\{exportChatToPdf\}/.test(code));
check('the whole menu is still gated on an unlocked session', /\{#if \$isUnlocked\}/.test(code));
// ─── v1.7.5 (t.txt #8 avatar alignment, #9 whoami avatar) ───────────
//
// #8: Ken — "the avatar image is not properly vertically aligned with the 3
// (sometimes 2) lines of text that appear to the right of the avatar image. i
// love its current size though, so please do not change that."
check(
'the header avatar centres itself against the 2-3 text lines',
/<div class="flex-none self-center">/.test(code),
'items-start left a 48px avatar pinned to the top of a 3-line block'
);
check(
'…while the ROW stays items-start, because the kebab depends on it',
/<div class="flex items-start gap-3">/.test(code),
'the kebab is the last flex item of this same row; its top must sit level ' +
'with the display name, so centring the row would fix the avatar and break it'
);
check(
'the header avatar keeps its size (Ken: "do not change that")',
/avatarSize=\{48\}/.test(code)
);
// #9: Ken — "the avatar image is too small. make it span the full height of the
// username/postingkey lines." 34px is measured, not eyeballed: the row inherits
// the 16px page base and IdentityLabel's keyed path stacks two leading-tight
// (1.25) lines where the key is text-[0.7em] — 16x1.25 + 16x0.7x1.25 = 34.
const chatMessage = readFileSync(join(WEB, 'src', 'lib', 'components', 'ChatMessage.svelte'), 'utf8');
check(
'the whoami avatar spans the full height of the handle + key stack',
/avatarSize=\{34\}/.test(chatMessage),
'was 18px against a two-line stack — barely half of what it labelled'
);
check(
'the whoami still renders the posting key (the anti-impersonation anchor)',
/publicKeyString=\{senderPostingKey \?\? undefined\}/.test(chatMessage)
);
console.log('');
if (fail === 0) console.log(`\u2713 all ${pass} chat-header-layout scenarios passed`);
else {
console.error(`\u2717 ${fail} of ${pass + fail} chat-header-layout checks FAILED`);
process.exit(1);
}