14th pre-validation detector + a preventive skill rule. User-reported 2026-05-10 "Bistro" mobile food app shipped with root padding [0,16,0,16] AND a "Today's Specials" section padding [0,24]. Effective gutter = 40px on a 375px page → only 295px of usable content width. Reads as "too much padding" / pinched. Two pieces: 1. layout.md AESTHETIC HYGIENE block now teaches "page gutter goes on ONE layer, not both" — pick root horizontal padding OR per-section horizontal padding, not both. Default convention: root carries the gutter, sections set vertical-only padding. Hero / banner / image-bleed sections then sit edge-to-edge by simply NOT adding horizontal padding (root's gutter shows through). Preventive teaching at prompt time. 2. detectStackedHorizontalPadding (info-only, detect-only). Walks every mobile-shaped root (width 320–480 + tall + multi-child), compares root horizontal padding against each direct child's horizontal padding; flags the section as the offender when both are > 0. Page-shape filter mirrors detectEdgeSectionPadding so the legitimate component-internal padding stacking pattern (chip → badge → icon, etc.) doesn't trip it. Severity is INFO because a section may legitimately want a deeper inset for visual emphasis — let the user/agent decide via audit panel. Side-quest: scripts/ab-corpus/check-stacked-padding.ts ships with this commit so the next stacked-padding-style detector calibration can survey corpus frequency without rebuilding the harness.
111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
/**
|
|
* Empirical check: how common is "root has horizontal padding AND a
|
|
* direct child also has horizontal padding" in real LLM output?
|
|
*
|
|
* Drives the cost-benefit on adding a `detect-stacked-horizontal-
|
|
* padding` detector. The 2026-05-10 user-reported "Bistro" Mobile
|
|
* design hit this pattern — root [0,16,0,16] + section [0,24] = 40px
|
|
* effective gutter, which read as "too much padding". Need to know
|
|
* if it's a one-off or a recurring AI-output shape before adding a
|
|
* detector.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { parseModelOutput } from '@zseven-w/pen-ai-skills';
|
|
import { applyToFreshDoc } from './apply';
|
|
|
|
interface JsonlRow {
|
|
promptId: string;
|
|
category: string;
|
|
difficulty: string;
|
|
variant: string;
|
|
rawOutput: string;
|
|
}
|
|
|
|
function getHorizontalPadding(p: unknown): { left: number; right: number } {
|
|
if (typeof p === 'number') return { left: p, right: p };
|
|
if (Array.isArray(p)) {
|
|
if (p.length === 4) return { left: Number(p[3] ?? 0), right: Number(p[1] ?? 0) };
|
|
if (p.length === 2) return { left: Number(p[1] ?? 0), right: Number(p[1] ?? 0) };
|
|
if (p.length === 1) return { left: Number(p[0] ?? 0), right: Number(p[0] ?? 0) };
|
|
}
|
|
return { left: 0, right: 0 };
|
|
}
|
|
|
|
const FULL_BLEED_ROLES = new Set([
|
|
'hero',
|
|
'banner',
|
|
'cover',
|
|
'header',
|
|
'top-nav',
|
|
'bottom-nav',
|
|
'status-bar',
|
|
'tab-bar',
|
|
'tabbar',
|
|
'navbar',
|
|
]);
|
|
|
|
async function main(): Promise<void> {
|
|
const runId = process.argv[2];
|
|
if (!runId) {
|
|
console.error('usage: bun run scripts/ab-corpus/check-stacked-padding.ts <run-id>');
|
|
process.exit(1);
|
|
}
|
|
const path = join(import.meta.dir, 'runs', runId, 'scores.jsonl');
|
|
const rows: JsonlRow[] = readFileSync(path, 'utf-8')
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((l) => JSON.parse(l));
|
|
|
|
let applied = 0;
|
|
let stacked = 0;
|
|
const examples: string[] = [];
|
|
|
|
for (const r of rows) {
|
|
const parsed = parseModelOutput(r.rawOutput);
|
|
if (parsed.kind === 'garbage') continue;
|
|
const result = await applyToFreshDoc(parsed);
|
|
if (!result.ok || !result.doc) continue;
|
|
applied++;
|
|
const root = result.doc.children?.[0] as
|
|
| (Record<string, unknown> & { padding?: unknown; children?: unknown[] })
|
|
| undefined;
|
|
if (!root) continue;
|
|
const rootP = getHorizontalPadding(root.padding);
|
|
if (rootP.left === 0 && rootP.right === 0) continue;
|
|
if (!Array.isArray(root.children)) continue;
|
|
const offending: string[] = [];
|
|
for (const child of root.children as Array<
|
|
Record<string, unknown> & { id?: string; role?: string; padding?: unknown }
|
|
>) {
|
|
if (child?.type !== 'frame') continue;
|
|
const role = String(child.role ?? '').toLowerCase();
|
|
if (FULL_BLEED_ROLES.has(role)) continue;
|
|
const p = getHorizontalPadding(child.padding);
|
|
if (p.left > 0 || p.right > 0) offending.push(String(child.id ?? '?'));
|
|
}
|
|
if (offending.length > 0) {
|
|
stacked++;
|
|
if (examples.length < 8) {
|
|
examples.push(
|
|
` ${r.promptId}/${r.variant} root=${rootP.left}+${rootP.right} offenders=${offending.length} (${offending.slice(0, 3).join(',')})`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`applied: ${applied}`);
|
|
console.log(
|
|
`stacked horizontal padding (root H>0 AND ≥1 child section H>0): ${stacked} (${((stacked / applied) * 100).toFixed(1)}%)`,
|
|
);
|
|
if (examples.length > 0) {
|
|
console.log('\nexamples:');
|
|
for (const e of examples) console.log(e);
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|