From 4e008ca605cfd86efae70a83c7b34142ee5040d1 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 1 Nov 2026 21:09:04 +0100 Subject: [PATCH 1/4] Freeze the slice F build contract --- docs/specs/PHASE-30-SPEC.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 5b8707c..40c46f9 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -355,6 +355,17 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - `src/viewer.ts:603-604` and `:756` (thumb sizing), `:515` / `:679` (card iframes), `:1107-1109` and `:1350-1385` (detail sizing, breakpoint buttons, compare grid), `:1474-1476` (active-state detection). Profile table imported from `src/surfaces.ts`. - Tests: `test-viewer-perf.ts` must stay green (thumbnail change cannot cost render time); a new viewer route test for card sizing and the framed detail markup. +- **Build contract (slice F, frozen before build).** Pins what the FR rows leave open. Where it changes an FR, it says so. No pre-freeze verifier: nothing in this slice has a verdict that depends on prior runs or stored state. + - *Artboard size.* Every viewer path reads the root's numeric `width` / `height`, else 1440 / 900, as today. + - *Card thumbnails (FR-F1).* One helper builds the thumb for the project page and the archive page. The thumb box carries `aspect-ratio: W / H`, clamped to between the phone profile's portrait ratio (390 / 844) and 2 (spec change: a 1440×6000 landing page would otherwise be a card 1,333px tall; a taller artboard shows its top, as every artboard taller than 16:10 does today). The iframe is `W`×`H` px, scaled from its top-left corner. The server writes the scale as `0.222 × 1440 / W` (so a 1440 canvas reads `scale(0.222)` and a 390 canvas `0.8197`); a small script then sets each thumb's scale to its rendered width / `W` and keeps it there on resize, so the canvas fills the card at every grid width (today a card wider or narrower than 320px shows a blank strip or a crop). Cards in a row align to their top, so a portrait card beside a landscape one does not stretch it. Empty canvases keep their placeholder in the clamped box. + - *Device mode (FR-F2).* A canvas whose `resolveSurface` type is `mobile` opens in device mode: the iframe (`#frame`, same id, so comment mode and refresh keep working) sits 1:1 at the artboard size inside a CSS device frame: a dark bezel (12px), outer corner radius 59, screen corner radius 47, screen clipped to its radius, centred in the preview with breathing room. No notch, no images. The iframe scrolls inside the screen, so `fixed` chrome stays pinned while content scrolls, as on the phone. + - *Safe-area overlay.* A toolbar toggle (`Safe area`, `#btn-safe`) shows two bands over the screen's top and bottom edges, fixed to the screen (not the document), hatched, `pointer-events: none`, each labelled with its height. Each band's height is that side of `usableSafeArea(root.safeArea)` on a `document` root, else the device profile's inset for that side (the hardware intrudes there whether or not the canvas reserves it). Off by default. + - *Toolbar on mobile.* The Mobile / Tablet / Desktop / Compare buttons, the compare grid, and Fit are not rendered for a mobile canvas (a phone canvas has one device and is shown 1:1). The dimension label reads `390 x 844 · iphone-390`. + - *Toolbar on web.* The Mobile button and the compare grid's mobile cell read the size from `DEVICE_PROFILES[DEFAULT_DEVICE]` (390×844). Tablet stays 768×1024: no tablet profile ships (non-goal). The active breakpoint is the button that was pressed, not inferred from the width (a 390-wide web canvas's Desktop button used to highlight Mobile), and switching breakpoints clears only the breakpoint buttons' state (it used to clear Dark, JSON and Comment too). + - *Render.* Device mode is viewer presentation only: `/canvas/:id/html` is unchanged, so screenshots and `versionHash` are unaffected (C10). + - *Tests.* New `test-viewer-surface.ts` (Chrome-free, markup): a 390×844 mobile canvas's card has a 390×844 iframe and `aspect-ratio: 390 / 844`; a 1440×900 card keeps 1440×900 at `scale(0.222)`; the clamp at both ends; the archive page uses the same helper; a mobile detail page has the device frame, the safe-area toggle and bands of 47 and 34, and no breakpoint buttons; a mobile canvas without `safeArea` gets the profile's bands; a web detail page's Mobile button and compare cell use the profile size. A full-tier check in the same style as `test-viewer-compare.ts` (Chrome) loads the mobile detail page: the screen is 390×844, the overlay toggles, and a thumb's computed scale equals its width / W after the script runs. `test-viewer-perf.ts` and `test-viewer-compare.ts` stay green. + - *Docs.* The README viewer paragraph and GUIDELINES' mobile section say phone canvases show as portrait cards and open in a device frame with a safe-area overlay; VISION's Phase 30 checklist ticks slice F. No MCP tool changes, so no docstring or INSTRUCTIONS edit unless one describes the viewer's breakpoint buttons. + **Slice G — acceptance (FR-G1).** Rebuild Ridgeline from the attempt brief (`.framesmith/mobile-surface-attempt/`, untracked) on the merged phase, quote the results in the final PR. Slice order: A → B → C → D → E → F → G. A untangles the field B's ruleset table reads. B gives C a type to key on. C's target rule exists before D so each D primitive is born under it. E needs D's primitives. F needs only B, and can move earlier if convenient. G needs everything. From ab3e215dfdcd0c180bf0219544ed7e2f66c3c727 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 1 Nov 2026 21:14:49 +0100 Subject: [PATCH 2/4] Show canvases at their artboard proportions and phones in a device frame --- README.md | 4 +- VISION.md | 2 +- docs/GUIDELINES.md | 1 + src/viewer.ts | 175 ++++++++++++++++++++++++--------- test-viewer-surface-browser.ts | 124 +++++++++++++++++++++++ test-viewer-surface.ts | 133 +++++++++++++++++++++++++ 6 files changed, 388 insertions(+), 51 deletions(-) create mode 100644 test-viewer-surface-browser.ts create mode 100644 test-viewer-surface.ts diff --git a/README.md b/README.md index 939a86b..2ceda99 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,12 @@ Left to itself, an AI agent tends to produce UI that *looks* AI-generated — ge | **Design systems** | `generate_design_system`: one seed + one personality (`technical` / `editorial` / `soft` / `data-dense`) → the complete design language — colors, curated font pairing, typography roles, radius/density stances, `$elevation` depth tokens (dark-aware), `$motion` defaults · layered `$token`s (workspace ▸ project ▸ canvas) · style presets · `DESIGN.md` import · `generate_scale` derives a modular type + spacing scale from a named ratio (optional fluid `clamp()` sizes) · `generate_color_system` turns one seed color into OKLCH ramps + AA-checked semantic tokens, a categorical chart palette, and a tint layer for chips/tiles/badges, dark theme included · dual-theme rendering + evaluation (`theme: "dark"` on screenshot/export, both-theme contrast in `canvas_evaluate` with APCA advisory) · token-detachment lint re-attaches literals that drift from a token | | **Primitives** | Lucide + Material Symbols icons · Google Fonts by name · real form controls · components with instance overrides — `create_component` promotes existing work, `copy_nodes` carries subtrees (and their component defs) across canvases | | **Import from code** | `canvas_import_html` / `canvas_import_url` — token-mapped, structure-reconstructed · `canvas_sync_from_url` pixel drift · `canvas_check_drift` structural drift · `framesmith verify` / `check-drift` CLI for CI/pre-commit gates, no MCP client needed | -| **Viewer** | Browser gallery + detail view · quality inspector (score, issues, click-to-highlight) · design-system token panel · point-and-tell feedback (Comment mode + Feedback tab) | +| **Viewer** | Browser gallery + detail view · phone canvases in a device frame with a safe-area overlay · quality inspector (score, issues, click-to-highlight) · design-system token panel · point-and-tell feedback (Comment mode + Feedback tab) | | **Open by design** | MIT · plain HTML/CSS · open JSON you own in your repo · content-hash versioning (`canvas_version`) so approvals reference an exact design, not just a name · works with any MCP-compatible client | ## Viewer -Run `npx -p framesmith framesmith-viewer` to start the standalone browser viewer (it takes the first free port from 3001 up; pass a port to pin one). Open any canvas to review it at multiple breakpoints, compare them side-by-side, inspect the underlying JSON, or archive / delete. +Run `npx -p framesmith framesmith-viewer` to start the standalone browser viewer (it takes the first free port from 3001 up; pass a port to pin one). Open any canvas to review it at multiple breakpoints, compare them side-by-side, inspect the underlying JSON, or archive / delete. Cards keep each artboard's proportions, so phone screens are portrait cards, and a canvas declared `mobile` opens at 1:1 in a device frame with a **Safe area** toggle that shades the status-bar and home-indicator insets. ![framesmith canvas detail view — the dashboard pattern rendered (grouped sidebar navigation with an account row, KPI cards with sparklines and delta pills, a real multi-series line chart with an area fill and a dashed reference line beside a recent-activity feed) with the Quality inspector open on the right: a 99/100 "Excellent" score, per-category bars (spacing, color, typography, structure, consistency, usability, coverage, cliche), and honest APCA advisories in the issue list. The toolbar shows the canvas's designed states (default / loading / empty), theme and viewport toggles, and a Comment toggle.](https://raw.githubusercontent.com/vicmaster/framesmith/master/docs/framesmith-canvas.png?v=2.0) diff --git a/VISION.md b/VISION.md index a2196dc..74e2ee3 100644 --- a/VISION.md +++ b/VISION.md @@ -550,7 +550,7 @@ The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and go - [x] Slice C — the mobile ruleset: 44px targets as an error on every control (the slice D primitives joined it), 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 D — vocabulary: `fixed` / `sticky` positioning with the renderer reserving room for pinned chrome, a root that grows with long content (closing slice C's long-screen gap), the `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row` and `sheet` primitives, the primitives joining the 44px target rule, `mobile-tab-bar`, and pinned bars in the safe-area rule (`safeArea` on the root landed in slice B) - [x] 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 +- [x] Slice F — the viewer: thumbnails at the artboard's real proportions and a device frame with a safe-area overlay - [ ] Slice G — the acceptance re-run _Full spec-driven breakdown in [`docs/specs/PHASE-30-SPEC.md`](docs/specs/PHASE-30-SPEC.md). Deliberately out of scope: an Android profile, tablet and other types, a tappable flag on frames, a thumb-reach rule, interaction and flows, mobile import, and mobile commerce archetypes (those come next, on the anatomy/realisation split this phase creates)._ diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 196c0ba..d669b05 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -46,6 +46,7 @@ A canvas declares what medium it is on, separately from its genre (what it is fo - **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only, so it never moves the `versionHash`. It never resizes the artboard and never writes or removes `safeArea`, because either would reflow what you placed; clearing the type leaves the root as it is. When a mobile result has a `hint`, follow it: it names `U("document", { safeArea: { top: 47, bottom: 34 } })` when the root doesn't reserve both insets, and `U("document", { width: 390, height: 844 })` when the root is another size. Both ops change the render, so re-check the layout after them. State variants keep the type they were created with, so re-declaring a base does not reach them: the result's `variantsOutOfSync` lists each variant whose type now differs (`{ state, canvasId, type }`); declare those too. - **`generate_design_system` on a mobile canvas** pivots the type scale on the device body size: 17px for every personality except `data-dense`, which uses 16px. An explicit `baseSize` wins. The result reports the generated pivot as `baseSize` and `baseSizeSource` (`"mobile"` for both mobile pivots, `"explicit"`, or `"personality"` on web). A `$body` inherited from a workspace or project design system is preserved by default and listed in `designSystemConflicts`; pass `preserveInherited: false` to take the mobile body. - **Every evaluate result carries a `surface` report**: `{ type, device, source, rulesOn, rulesOff, conflicts }`. Each axis value (type, genre, preset) contributes a ruleset, and the active rules are their union. `rulesOn` and `rulesOff` name the rule ids the rulesets turned on and off. A rule both enabled and disabled stays on: it is listed in `rulesOn` and in `conflicts`, so composing declarations can never switch a guardrail off. On a web canvas `rulesOn` is `["mobile-artboard"]`; on a mobile canvas it lists every rule in [the mobile ruleset](#the-mobile-ruleset). No ruleset turns a rule off today. +- **The viewer shows the declaration to the human.** Gallery cards take the artboard's proportions, so a phone screen is a portrait card. A canvas declared `mobile` opens at 1:1 in a device frame, with a **Safe area** toggle that shades the top and bottom insets (the root's `safeArea`, else the device's), and without the breakpoint buttons. An undeclared 390px canvas opens as a web page with breakpoint buttons, so a phone screen that was never declared looks wrong to the reviewer as well as to `mobile-artboard`. ## The mobile ruleset diff --git a/src/viewer.ts b/src/viewer.ts index 90bb4d4..12004b4 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -6,7 +6,7 @@ import { getProject, listProjects, listWorkspaces, getCanvasTokens, getProjectDe import { getRepoLocation, archiveRepoCanvas, deleteRepoCanvas, updateRepoCanvas } from './aggregate.js'; import { DEFAULT_PROJECT_ID, type Canvas, type SceneNode } from './types.js'; import { evaluateCanvas, resolveGenre, type EvaluationResult, type EvaluationIssue } from './evaluate.js'; -import { resolveSurface } from './surfaces.js'; +import { resolveSurface, usableSafeArea, deviceProfile, DEVICE_PROFILES, DEFAULT_DEVICE, type DeviceProfile } from './surfaces.js'; import { isPresetName } from './genre.js'; import { addFeedback, listFeedback, resolveFeedback, deleteFeedback, openFeedbackCount } from './feedback.js'; @@ -475,6 +475,57 @@ function renderSidebar(active: string): string { `; } +/** The root's artboard size, defaulting the way every render path does. */ +function artboardSize(canvas: Canvas): { w: number; h: number } { + const { width, height } = canvas.root; + return { + w: typeof width === 'number' && width > 0 ? width : 1440, + h: typeof height === 'number' && height > 0 ? height : 900, + }; +} + +// Phase 30 slice F — card thumbnails at the artboard's real proportions. The +// box takes the artboard's aspect ratio, clamped between a phone held upright +// and 2:1 (a long landing page shows its top instead of becoming a 1,300px +// card). The iframe renders at the artboard size; the server writes a scale +// that makes it 0.222 × 1440 wide, and THUMB_FIT_JS refits it to the card. +const PHONE = DEVICE_PROFILES[DEFAULT_DEVICE]; +const THUMB_WIDTH = 0.222 * 1440; +const THUMB_MAX_ASPECT = 2; + +function thumbAspect(w: number, h: number): string { + if (w / h < PHONE.width / PHONE.height) return `${PHONE.width} / ${PHONE.height}`; + if (w / h > THUMB_MAX_ASPECT) return `${THUMB_MAX_ASPECT} / 1`; + return `${w} / ${h}`; +} + +function renderThumb(canvas: Canvas, badge = ''): string { + const { w, h } = artboardSize(canvas); + const isEmpty = !canvas.root.children || canvas.root.children.length === 0; + const body = isEmpty + ? `
+ + + +
` + : ``; + const scale = Number((THUMB_WIDTH / w).toFixed(4)); + return `
${badge}${body}
`; +} + +const THUMB_FIT_JS = ` + // Scale each thumbnail's artboard to its card's width, and keep it there. + (() => { + const fit = (thumb) => { + const w = Number(thumb.dataset.artboardW); + if (w > 0 && thumb.clientWidth > 0) thumb.style.setProperty('--thumb-scale', String(thumb.clientWidth / w)); + }; + const thumbs = document.querySelectorAll('.thumb[data-artboard-w]'); + if (typeof ResizeObserver !== 'function') { thumbs.forEach(fit); return; } + const observer = new ResizeObserver((entries) => entries.forEach((e) => fit(e.target))); + thumbs.forEach((t) => observer.observe(t)); + })();`; + /** Project-scoped main page. Sidebar on left, breadcrumb + canvas grid on right. * Returns null when the project doesn't exist so the route handler can 404. */ export async function renderProjectPage(projectId: string, port: number): Promise { @@ -499,22 +550,13 @@ export async function renderProjectPage(projectId: string, port: number): Promis const shown = canvases.filter((c) => !(c.variant && present.has(c.variant.of))); const cards = (await Promise.all(shown.map(async (c) => { const canvas = getCanvas(c.id)!; - const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440; - const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900; + const { w, h } = artboardSize(canvas); const date = new Date(c.createdAt).toLocaleString(); - const isEmpty = !canvas.root.children || canvas.root.children.length === 0; // Phase 19 Slice A — gallery score badge (fast eval, cached; empty → none). const ev = await evalFor(canvas, statesById.get(c.id) ?? []); const scoreBadge = ev ? `
${ev.overallScore}
` : ''; - const thumbBody = isEmpty - ? `
- - - -
` - : ``; const stateChips = c.variants?.length ? `
default @@ -525,7 +567,7 @@ export async function renderProjectPage(projectId: string, port: number): Promis ? `
-
${scoreBadge}${thumbBody}
+ ${renderThumb(canvas, scoreBadge)}
${esc(c.name)}
${w} x ${h} · ${esc(date)}
@@ -535,7 +577,7 @@ export async function renderProjectPage(projectId: string, port: number): Promis
` : `
-
${scoreBadge}${thumbBody}
+ ${renderThumb(canvas, scoreBadge)}
${esc(c.name)}
${w} x ${h} · ${esc(date)}
@@ -597,13 +639,13 @@ ${FAVICON_HTML} .project-meta { font-size: 13px; font-weight: 500; color: var(--text-tertiary); margin-top: 8px; } /* Card grid */ - .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(264px, 1fr)); gap: 20px; padding: 28px 36px 36px; } + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(264px, 1fr)); gap: 20px; align-items: start; padding: 28px 36px 36px; } .card { background: var(--surface); border-radius: 12px; overflow: hidden; text-decoration: none; color: inherit; transition: transform 0.18s ease, box-shadow 0.18s ease; box-shadow: 0 0 0 1px var(--border-rim) inset, 0 4px 12px -2px rgba(0,0,0,0.4); } .card:hover { transform: translateY(-2px); box-shadow: 0 0 0 1px rgba(245,158,11,0.30) inset, 0 8px 20px -4px rgba(0,0,0,0.5); } .card-link { display: block; text-decoration: none; color: inherit; } .card-states { padding: 0 14px 12px; } .thumb { width: 100%; aspect-ratio: 16/10; overflow: hidden; background: var(--bg-0); position: relative; } - .thumb iframe { width: 1440px; height: 900px; border: none; transform-origin: 0 0; transform: scale(0.222); pointer-events: none; position: absolute; top: 0; left: 0; } + .thumb iframe { border: none; transform-origin: 0 0; transform: scale(var(--thumb-scale, 0.222)); pointer-events: none; position: absolute; top: 0; left: 0; } .thumb--empty { background: #15110b; } /* Phase 19 Slice A — heuristic quality score badge on each card thumbnail. */ .card-score { position: absolute; top: 8px; right: 8px; z-index: 2; font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; background: rgba(9,7,10,0.78); backdrop-filter: blur(4px); border: 1px solid var(--border); border-radius: 6px; padding: 3px 8px; line-height: 1; } @@ -636,6 +678,7 @@ ${FAVICON_HTML}