Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and go

- [x] Slice A — untangle the declarations: genre gets its own field so `apply_preset` stops overwriting it, every stored canvas keeps its verdict through a legacy read, and `data` becomes the canonical purpose genre with `dashboard` as its alias
- [x] Slice B — the type axis: `metadata.surface` with one device profile (`iphone-390`), `canvas_create({ type })` and `canvas_set_type`, a ruleset table keyed by type, genre and preset that composes by union and can turn rules on, a `surface` report on every evaluation, and a mobile type scale from `generate_design_system`
- [ ] Slice C — the mobile ruleset: 44px targets as an error on every tappable primitive, safe areas, a type floor, undeclared phone-width canvases told to declare, hand-built tap targets and multi-column prose named as advisories, and stress that knows the type
- [x] Slice C — the mobile ruleset: 44px targets as an error on every control (the slice D primitives join the set as they land), safe areas, a type floor, undeclared phone-width canvases told to declare, hand-built tap targets and multi-column prose named as advisories, and stress that knows the type
- [ ] Slice D — vocabulary: `fixed` / `sticky` positioning, `safeArea` on the root, and the `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row` and `sheet` primitives
- [ ] Slice E — starting points: anatomy and realisation on structures, resolution by canvas type, six mobile realisations, and the pattern gate extended to them
- [ ] Slice F — the viewer: thumbnails at the artboard's real proportions and a device frame with a safe-area overlay
Expand Down
28 changes: 23 additions & 5 deletions docs/GUIDELINES.md

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions docs/specs/PHASE-30-SPEC.md

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions src/directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import { appendFeedbackDirective } from './feedback.js';
export const SCORE_BAR = 95;

/** The last canvas_stress verdict as the directive sees it. `current` = it ran
* on the canvas's present versionHash. */
* on the canvas's present versionHash AND under its present type. */
export interface StressState {
warnings: number;
current: boolean;
/** Set when the canvas type changed since the run (Phase 30), so the note
* says so instead of blaming an edit. */
ranAsType?: string;
}

export interface DirectiveParts {
Expand Down Expand Up @@ -66,7 +69,9 @@ export function buildEvaluateDirective(result: EvaluationResult, openFeedback =
? ''
: fragile.current
? ` The last canvas_stress run on this exact version was FRAGILE (${fragile.warnings} layout break(s)) — fix them with fluid widths / minWidth or minHeight floors / wrapping / textOverflow: "ellipsis", then re-run canvas_stress until CLEAN.`
: ` The last canvas_stress run was FRAGILE (${fragile.warnings} layout break(s)) on an earlier version — re-run canvas_stress to confirm your changes fixed them; only a CLEAN run clears this.`;
: fragile.ranAsType
? ` The last canvas_stress run was FRAGILE (${fragile.warnings} layout break(s)) under the canvas's earlier type ("${fragile.ranAsType}") — re-run canvas_stress under the current type; only a CLEAN run clears this.`
: ` The last canvas_stress run was FRAGILE (${fragile.warnings} layout break(s)) on an earlier version — re-run canvas_stress to confirm your changes fixed them; only a CLEAN run clears this.`;

let base: string;
if (ready) {
Expand Down
168 changes: 130 additions & 38 deletions src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { computeLayout, type LayoutRect } from './screenshot.js';
import { extractInventory, expandInstances, isDataLike } from './drift.js';
import { getIconSvg, parseIconRef } from './icons.js';
import { storedGenre } from './genre.js';
import { relaxedByPreset, relaxedByGenre, canonicalGenre, genreRulesets, activeRulesets, composeRulesets, resolveSurface, type SurfaceType } from './surfaces.js';
import { relaxedByPreset, relaxedByGenre, canonicalGenre, genreRulesets, activeRulesets, composeRulesets, resolveSurface, deviceProfile, DEVICE_PROFILES, DEFAULT_DEVICE, type SurfaceType } from './surfaces.js';
import { checkArtboard, checkHandbuiltTargets, checkOneColumn, checkSafeAreaDetailed, checkSafeAreaFast, checkTypeFloor, paddingSides, type MobileContext } from './mobile-rules.js';

// --- Types ---

Expand All @@ -31,6 +32,10 @@ export interface EvaluationIssue {
category: string;
/** Present only on `cliche` issues — which machine-made tell fired. */
tell?: ClicheTell;
/** Phase 30 — the ruleset rule that produced this issue (e.g.
* "mobile-touch-target"); one of the surface report's rulesOn. Absent on
* the checks that have no rule id. */
rule?: string;
/** Phase 25 slice D — set on issues from the DARK-theme evaluation pass
* (color category dual-runs when a dark token layer exists). Absent =
* light/default. */
Expand Down Expand Up @@ -1824,7 +1829,53 @@ function paddingBox(n: SceneNode): { v: number; h: number } {
return { v: 0, h: 0 };
}

function checkUsability(entries: NodeEntry[], rootWidth: number): CheckResult {
/** Phase 30 slice C — the width a node renders at, when the tree alone says:
* a numeric width; or a full or stretching width, which takes its parent's
* content width (parent width minus its rendered padding), up to the root.
* A stretch needs a vertical (or layout-less) parent that does not set
* cross-axis alignment, and an in-flow node. Undefined when the width hugs
* content or depends on something the tree doesn't fix. */
function knownWidth(n: SceneNode, parentOf: Map<string, SceneNode | null>, rootWidth: number): number | undefined {
if (typeof n.width === 'number') return n.width;
const parent = parentOf.get(n.id) ?? null;
if (!parent) return rootWidth;
if (n.position === 'absolute') return undefined;
const columnParent = parent.layout !== 'horizontal' && parent.layout !== 'grid';
const stretches = n.width === undefined && columnParent && (parent.alignItems === undefined || parent.alignItems === 'stretch');
const full = n.width === '100%' || (n.width === 'fill_container' && columnParent);
if (!stretches && !full) return undefined;
const outer = knownWidth(parent, parentOf, rootWidth);
if (outer === undefined) return undefined;
const pad = paddingSides(parent, rootWidth);
return outer - pad.left - pad.right;
}

/** Phase 30 slice C — a control's touch target on a mobile canvas: its own
* box, extended through up to two ancestors that are ROWS (horizontal frames
* holding exactly one control). A row's box per dimension is its known size
* or min size (a full or stretching width takes its host's width), else the
* control plus the row's rendered padding. Unlike the web rule this never
* extends through a card that merely holds one control. */
function mobileTarget(
node: SceneNode, w: number, h: number, parentOf: Map<string, SceneNode | null>,
countControls: (n: SceneNode) => number, rootWidth: number,
): { w: number; h: number } {
let tw = w;
let th = h;
let cur = parentOf.get(node.id) ?? null;
for (let hop = 0; hop < 2 && cur; hop++) {
if (cur.type === 'frame' && cur.layout === 'horizontal' && countControls(cur) === 1) {
const pad = paddingSides(cur, rootWidth);
const min = (v: SceneNode['minWidth']) => (typeof v === 'number' ? v : 0);
tw = Math.max(tw, knownWidth(cur, parentOf, rootWidth) ?? w + pad.left + pad.right, min(cur.minWidth));
th = Math.max(th, typeof cur.height === 'number' ? cur.height : h + pad.top + pad.bottom, min(cur.minHeight));
}
cur = parentOf.get(cur.id) ?? null;
}
return { w: tw, h: th };
}

function checkUsability(entries: NodeEntry[], rootWidth: number, touch?: { floor: number }): CheckResult {
const issues: EvaluationIssue[] = [];
let score = 100;

Expand All @@ -1847,37 +1898,53 @@ function checkUsability(entries: NodeEntry[], rootWidth: number): CheckResult {
let w = typeof node.width === 'number' ? node.width : def.w;
let h = typeof node.height === 'number' ? node.height : def.h;

// The effective target extends to a padded ancestor row when the
// control is the only control inside it (the whole row is tappable by
// convention) — the WCAG 2.5.8 spacing model, applied structurally.
let cur = parentOf.get(node.id) ?? null;
for (let hop = 0; hop < 2 && cur && Math.min(w, h) < 24; hop++) {
if (countControls(cur) === 1) {
const pad = paddingBox(cur);
w += pad.h;
h += pad.v;
if (touch) {
const t = mobileTarget(node, w, h, parentOf, countControls, rootWidth);
if (Math.min(t.w, t.h) < touch.floor) {
score -= 25;
issues.push({
category: 'usability',
rule: 'mobile-touch-target',
severity: 'error',
nodeId: node.id,
nodeName: node.name,
message: `${node.type} has a ${Math.round(t.w)}×${Math.round(t.h)}px touch target — below the ${touch.floor}px floor on a phone (Apple HIG; WCAG 2.5.5).`,
suggestion: `Grow the control, or put it alone in a row (a horizontal frame) at least ${touch.floor}px tall — a lone control's row is its target.`,
});
}
} else {
// The effective target extends to a padded ancestor row when the
// control is the only control inside it (the whole row is tappable by
// convention) — the WCAG 2.5.8 spacing model, applied structurally.
let cur = parentOf.get(node.id) ?? null;
for (let hop = 0; hop < 2 && cur && Math.min(w, h) < 24; hop++) {
if (countControls(cur) === 1) {
const pad = paddingBox(cur);
w += pad.h;
h += pad.v;
}
cur = parentOf.get(cur.id) ?? null;
}
cur = parentOf.get(cur.id) ?? null;
}

if (Math.min(w, h) < 24) {
score -= 25;
issues.push({
category: 'usability',
severity: 'error',
nodeId: node.id,
nodeName: node.name,
message: `${node.type} has a ${Math.round(w)}×${Math.round(h)}px effective hit target — below the 24px floor (WCAG 2.5.8).`,
suggestion: 'Grow the control, or give its row enough padding that the whole row is the target (a lone control inherits its padded row\'s box).',
});
} else if (touchFirst && Math.min(w, h) < 44) {
issues.push({
category: 'usability',
severity: 'info',
nodeId: node.id,
nodeName: node.name,
message: `${node.type} has a ${Math.round(w)}×${Math.round(h)}px effective hit target — fine for pointer input, but this looks like a touch-first screen (44px is the comfortable floor there).`,
});
if (Math.min(w, h) < 24) {
score -= 25;
issues.push({
category: 'usability',
severity: 'error',
nodeId: node.id,
nodeName: node.name,
message: `${node.type} has a ${Math.round(w)}×${Math.round(h)}px effective hit target — below the 24px floor (WCAG 2.5.8).`,
suggestion: 'Grow the control, or give its row enough padding that the whole row is the target (a lone control inherits its padded row\'s box).',
});
} else if (touchFirst && Math.min(w, h) < 44) {
issues.push({
category: 'usability',
severity: 'info',
nodeId: node.id,
nodeName: node.name,
message: `${node.type} has a ${Math.round(w)}×${Math.round(h)}px effective hit target — fine for pointer input, but this looks like a touch-first screen (44px is the comfortable floor there).`,
});
}
}

// Label association: a control the user can't name is a guess. The
Expand Down Expand Up @@ -1924,6 +1991,14 @@ function checkUsability(entries: NodeEntry[], rootWidth: number): CheckResult {
return { score: Math.max(0, score), issues };
}

/** Fold ruleset findings into a category result: −10 per warning (errors
* score themselves inside their check), infos free. */
function withRuleIssues(result: CheckResult, extra: EvaluationIssue[]): CheckResult {
if (extra.length === 0) return result;
const warnings = extra.filter((i) => i.severity === 'warning').length;
return { score: Math.max(0, result.score - warnings * 10), issues: [...result.issues, ...extra] };
}

const CATEGORY_WEIGHTS = new Map([
['spacing', 20],
['color', 25],
Expand Down Expand Up @@ -2051,6 +2126,13 @@ export async function evaluateCanvas(
const preset = canvas.metadata?.provenance?.preset;
const rules = composeRulesets(activeRulesets({ type: declared.type, genre: resolvedGenre.genre, preset }));
const surface: SurfaceReport = { ...declared, rulesOn: rules.on, rulesOff: rules.off, conflicts: rules.conflicts };
const ruleOn = new Set(rules.on);
const mobile: MobileContext = {
root: resolvedRoot,
profile: deviceProfile(declared.device ?? DEFAULT_DEVICE) ?? DEVICE_PROFILES[DEFAULT_DEVICE],
width: typeof resolvedRoot.width === 'number' ? resolvedRoot.width : 1440,
height: typeof resolvedRoot.height === 'number' ? resolvedRoot.height : 900,
};
const entries = buildTreeContext(resolvedRoot);
// Also walk the unresolved tree for token usage stats
const rawEntries = buildTreeContext(canvas.root);
Expand Down Expand Up @@ -2101,22 +2183,26 @@ export async function evaluateCanvas(
}
// Phase 25 slice A — detailed-mode layout is computed ONCE and shared by
// the consistency and typography (measure) checks.
// Phase 30 slice C — mobile-safe-area reads rendered rects too, deeper.
let detailedLayout: LayoutRect[] | undefined;
if (options.mode === 'detailed' && (activeCategories.includes('consistency') || activeCategories.includes('typography'))) {
const safeAreaDetailed = activeCategories.includes('usability') && ruleOn.has('mobile-safe-area');
if (options.mode === 'detailed' && (activeCategories.includes('consistency') || activeCategories.includes('typography') || safeAreaDetailed)) {
const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440;
const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900;
const html = renderToHtml(resolvedRoot, w, h, canvas);
detailedLayout = await computeLayout(html, undefined, 10, { width: w, height: h });
detailedLayout = await computeLayout(html, undefined, ruleOn.has('mobile-safe-area') ? 25 : 10, { width: w, height: h });
}
if (activeCategories.includes('typography')) {
results.set('typography', checkTypography(entries, mergedTokens, {
const typography = checkTypography(entries, mergedTokens, {
rootWidth: typeof canvas.root.width === 'number' ? canvas.root.width : undefined,
layout: detailedLayout,
tables: extractInventory(resolvedRoot).tables,
}));
});
const rawById = new Map(rawEntries.map((en) => [en.node.id, en.node]));
results.set('typography', withRuleIssues(typography, ruleOn.has('mobile-type-floor') ? checkTypeFloor(mobile, rawById) : []));
}
if (activeCategories.includes('structure')) {
results.set('structure', checkStructure(rawEntries, canvas));
results.set('structure', withRuleIssues(checkStructure(rawEntries, canvas), ruleOn.has('mobile-artboard') ? checkArtboard(mobile, declared) : []));
}
if (activeCategories.includes('consistency')) {
const consistencyCtx = { rawEntries, tokens: mergedTokens };
Expand All @@ -2127,8 +2213,14 @@ export async function evaluateCanvas(
}
}
if (activeCategories.includes('usability')) {
const rootWidth = typeof resolvedRoot.width === 'number' ? resolvedRoot.width : 1440;
results.set('usability', checkUsability(entries, rootWidth));
const touch = ruleOn.has('mobile-touch-target') ? { floor: mobile.profile.touchTarget } : undefined;
const usability = checkUsability(entries, mobile.width, touch);
const extra = [
...(ruleOn.has('mobile-handbuilt-target') ? checkHandbuiltTargets(mobile) : []),
...(ruleOn.has('mobile-safe-area') ? (detailedLayout ? checkSafeAreaDetailed(mobile, detailedLayout) : checkSafeAreaFast(mobile)) : []),
...(ruleOn.has('mobile-one-column') ? checkOneColumn(mobile) : []),
];
results.set('usability', withRuleIssues(usability, extra));
}

let coverageReport: EvaluationResult['coverage'];
Expand Down
Loading
Loading