morphit/apps/web/scripts/ui-polish-batch-smoke.ts

193 lines
11 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Smoke: the cp442 UI-polish batch (Ken, 2026-07-08).
*
* 1. WALLET CARD — the fiat approximation sits on the balance's baseline,
* separated by exactly one space; the three columns get an even gutter.
* 2. FOCUS BORDER — text fields get the crisp 1px emerald focus border
* site-wide, not the dim 3px translucent glow (`--focus-ring`, alpha .35).
* 3. SEND BLURT — the button stays disabled until every field validates,
* including the active-key password, and the amount can't be silently
* rounded by `toFixed(3)`.
* 4. DOWNLOAD PAGE — cards gain the same dim-emerald hover the order cards
* have, via a single shared `.card-hover-emerald` class rather than a
* fourth copy-paste of the four-class incantation.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SUPPORTED_LOCALES } from '../src/lib/i18n/locales';
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB = join(__dirname, '..');
const LOCALES = SUPPORTED_LOCALES.map((l) => l.code);
const css = readFileSync(join(WEB, 'src', 'app.css'), 'utf8');
const wallet = readFileSync(join(WEB, 'src', 'lib', 'components', 'MyBalanceCard.svelte'), 'utf8');
const send = readFileSync(join(WEB, 'src', 'lib', 'components', 'SendBlurtModal.svelte'), 'utf8');
const validation = readFileSync(join(WEB, 'src', 'lib', 'blurt', 'sendValidation.ts'), 'utf8');
const download = readFileSync(join(WEB, 'src', 'routes', '[lang]', 'download', '+page.svelte'), 'utf8');
const orderCard = readFileSync(join(WEB, 'src', 'lib', 'components', 'OrderCard.svelte'), 'utf8');
let pass = 0;
let fail = 0;
function check(name: string, ok: boolean): void {
if (ok) {
pass++;
console.log(` \u2713 ${name}`);
} else {
fail++;
console.error(` \u2717 ${name}`);
}
}
// ─── 1. wallet card ──────────────────────────────────────────────────
check('balance + fiat share one baseline row', /flex flex-wrap items-baseline gap-x-1/.test(wallet));
check('fiat is no longer an inline-block with ml-1 (double-space + off-baseline)', !/ml-1 inline-block align-baseline/.test(wallet));
check('the fiat keeps its own text-xs line-box (no phantom gap)', /font-sans text-xs font-normal leading-tight/.test(wallet));
check('the three columns get an even, roomier gutter', /grid grid-cols-3 gap-x-6 gap-y-3/.test(wallet));
// ─── 2. focus border ─────────────────────────────────────────────────
check('a crisp 1px emerald focus border is defined for text fields', /:focus-visible \{[\s\S]{0,200}border-color: theme\('colors\.morphit\.emerald'\)/.test(css));
check('it paints a 1px ring, not a 3px translucent glow', /box-shadow: 0 0 0 1px theme\('colors\.morphit\.emerald'\)/.test(css));
check('invalid fields keep their red border on focus', /:not\(\[aria-invalid='true'\]\):not\(\[class\*='border-red'\]\):focus-visible/.test(css));
check('buttons/links keep the soft glow (rule is field-only)', !/:where\(a, button[^)]*\)[\s\S]{0,80}border-color: theme/.test(css));
// ─── 3. Send BLURT validation ────────────────────────────────────────
check('send validation lives in a pure, testable module', /export function validateBlurtAmount/.test(validation));
check('amount precision is capped at BLURT\u2019s 3 decimals', /\^\\d\*\(\\\.\\d\{0,3\}\)\?\$/.test(validation));
check('a sub-precision amount cannot become 0.000 BLURT', /MIN_BLURT = 0\.001/.test(validation) && /n >= MIN_BLURT/.test(validation));
check('the modal uses the shared validator', /import \{[^}]*\bvalidateBlurtAmount\b[^}]*\} from '\$lib\/blurt\/sendValidation';/.test(send) && /validateBlurtAmount\(amountInput, blurtBalance\)/.test(send));
// deep-deep: "use full balance" must FLOOR, never round — toFixed(3) can fill
// the field with more BLURT than the user has, which the validator then rejects.
check('use-full-balance floors to BLURT precision (never rounds up)', /floorToBlurtPrecision\(blurtBalance\)/.test(send) && !/amountInput = blurtBalance\.toFixed\(3\)/.test(send));
check('canSend requires the active-key password', /passwordFilled/.test(send) && /canSend = \$derived\([\s\S]{0,220}passwordFilled/.test(send));
check('canSend still requires a resolved recipient + valid amount', /recipientState === 'valid'[\s\S]{0,80}amountValid/.test(send));
check('a precision error gets its own message (not "up to your balance")', /!amountPrecisionOk[\s\S]{0,400}error_amount_precision/.test(send) && /\{:else if[\s\S]{0,200}error_amount'/.test(send));
check('amount + password fields expose aria-invalid', /aria-invalid=\{amountInput\.trim\(\)\.length > 0 && !amountValid\}/.test(send) && /aria-invalid=\{passwordError\.length > 0\}/.test(send));
let allLoc = true;
for (const loc of LOCALES) {
const v = JSON.parse(readFileSync(join(WEB, 'src', 'lib', 'i18n', 'locales', `${loc}.json`), 'utf8'))
?.profile?.wallet?.error_amount_precision as string | undefined;
if (typeof v !== 'string' || !v || !v.includes('BLURT')) allLoc = false;
}
check('error_amount_precision exists in all 10 locales (BLURT untranslated)', allLoc);
// ─── 4. download hover, defined once ─────────────────────────────────
check('.card-hover-emerald exists as a shared class', /\.card-hover-emerald \{/.test(css));
check('it is defined AFTER .card-interactive so it wins the hover', css.indexOf('.card-hover-emerald') > css.indexOf('.card-interactive'));
check('OrderCard now uses the shared class', /card-hover-emerald/.test(orderCard));
check('no inline copy of the four-class incantation remains', !/hover:bg-emerald-50\/30/.test(orderCard) && !/hover:bg-emerald-50\/30/.test(download));
check('download cards gained the hover', (download.match(/card-hover-emerald/g) ?? []).length >= 4);
check('the already-emerald sections were left alone (hover would dim their border)', /<section class="card border-morphit-emerald\/40 bg-morphit-emerald\/5">/.test(download));
// ─── 5. double-green focus border, repo-wide (cp468) ─────────────────
// The §2 site-wide rule turns a text field's border emerald + paints a 1px
// ring on :focus-visible. A field that is `border-2` (2px) reads that as a
// 2px emerald border PLUS the ring = TWO green edges (Ken, kentest3). A field
// that ALSO declares its own emerald ring double-declares the same thing.
// The fix made `border` (1px) + app.css the single source for EVERY text
// field; these guards walk the whole .svelte tree so a new field can't
// silently reintroduce the double. Error fields (border-red, app.css-exempt)
// may keep a thicker/own-ring treatment. See docs/UX-STANDARD.md.
function walkSvelte(dir: string, out: string[] = []): string[] {
for (const e of readdirSync(dir)) {
const p = join(dir, e);
if (statSync(p).isDirectory()) walkSvelte(p, out);
else if (e.endsWith('.svelte')) out.push(p);
}
return out;
}
// strip HTML + JS comments so a doc-comment describing the bug can't trip us.
function stripComments(s: string): string {
return s
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
// capture a field's opening tag, honouring quotes + {expr} so a `>` inside a
// class expression doesn't end the tag early.
function openTag(src: string, start: number): string {
let i = start,
depth = 0,
q: string | null = null;
for (; i < src.length; i++) {
const c = src[i];
if (q) {
if (c === q) q = null;
} else if (c === '"' || c === "'") q = c;
else if (c === '{') depth++;
else if (c === '}') depth--;
else if (c === '>' && depth === 0) return src.slice(start, i + 1);
}
return src.slice(start);
}
const FIELD = /<(input|textarea|select)\b/g;
const svelteFiles = walkSvelte(join(WEB, 'src'));
const thick: string[] = [];
const ownRing: string[] = [];
const branchRing: string[] = [];
for (const f of svelteFiles) {
const src = stripComments(readFileSync(f, 'utf8'));
const rel = f.slice(f.indexOf('src/') + 4);
if (src.includes('focus:ring-2 focus:ring-morphit-emerald')) ownRing.push(rel);
let m: RegExpExecArray | null;
FIELD.lastIndex = 0;
while ((m = FIELD.exec(src))) {
const tag = openTag(src, m.index);
// A border-2 in a {ternary} branch is conditional (e.g. only the red
// error state); harmless. A border-2 in the STATIC classes is
// unconditional — it renders 2px in the normal state, which app.css
// then turns into a 2px emerald border + the 1px ring = a double edge.
// So strip the {expr} branches and test what's left. Always-red static
// fields (border-red in the static part, app.css-exempt) are allowed.
let staticPart = tag,
prev = '';
while (staticPart !== prev) {
prev = staticPart;
staticPart = staticPart.replace(/\{[^{}]*\}/g, '');
}
if (/\bborder-2\b/.test(staticPart) && !/border-red/.test(staticPart))
thick.push(`${rel} <${m[1]}>`);
if (/border-ink-\d+ focus:ring-morphit-emerald/.test(tag)) branchRing.push(`${rel} <${m[1]}>`);
}
}
check(
'no text field declares its own emerald focus ring (app.css owns it site-wide)',
ownRing.length === 0
);
if (ownRing.length) console.error(' offenders: ' + [...new Set(ownRing)].join(', '));
check(
'no non-error input/textarea/select uses border-2 (app.css would double it)',
thick.length === 0
);
if (thick.length) console.error(' offenders: ' + thick.join(', '));
check(
'no field keeps an emerald ring in its normal ternary branch (E6 pattern)',
branchRing.length === 0
);
if (branchRing.length) console.error(' offenders: ' + branchRing.join(', '));
// the custom-select opt-out must stay wired: app.css exempts `no-app-focus-ring`
// so the borderless inner inputs of the Fiat/Payment selects don't draw a 2nd
// ring inside the wrapper's own emerald edge.
const fiatSel = readFileSync(join(WEB, 'src', 'lib', 'components', 'FiatCurrencySelect.svelte'), 'utf8');
const paySel = readFileSync(join(WEB, 'src', 'lib', 'components', 'PaymentFilterSelect.svelte'), 'utf8');
check(
'app.css exempts no-app-focus-ring inputs from the field focus ring',
/input[\s\S]{0,260}:not\(\[class\*='no-app-focus-ring'\]\)[\s\S]{0,80}:focus-visible/.test(css)
);
check(
'the Fiat + Payment custom-select inner inputs carry no-app-focus-ring',
/no-app-focus-ring/.test(fiatSel) && /no-app-focus-ring/.test(paySel)
);
console.log('');
if (fail === 0) {
console.log(`\u2713 all ${pass} ui-polish-batch scenarios passed`);
} else {
console.error(`\u2717 ${fail} of ${pass + fail} ui-polish-batch checks FAILED`);
process.exit(1);
}