diff --git a/README.md b/README.md index 939a86b..144fa25 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 a web canvas to review it at multiple breakpoints and compare them side-by-side; open any canvas to inspect its JSON, leave comments, or archive / delete it. Cards keep each artboard's proportions (between a phone held upright and 2:1; a longer page shows its top), so phone screens are portrait cards. A canvas declared `mobile` opens at 1:1 in a device frame instead of the breakpoint buttons, with a **Safe area** toggle that shades the status-bar and home-indicator insets (47 and 34 on the default `iphone-390`). The overlay is viewer-only: it never changes a screenshot. ![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) @@ -1301,7 +1301,7 @@ The standalone viewer: - **Gallery** (`/`) — browse all canvases as clickable cards with live thumbnails - **Project** (`/project/:id`) — same gallery but scoped to one project - **Archive** (`/archive`) — soft-deleted canvases with restore / permadelete actions -- **Canvas detail** (`/canvas/:id`) — full rendered design with responsive viewport buttons (Mobile / Tablet / Desktop), Compare mode, Fit toggle, and JSON inspector +- **Canvas detail** (`/canvas/:id`) — full rendered design with responsive viewport buttons (Mobile / Tablet / Desktop), Compare mode, Fit toggle, and JSON inspector; a canvas declared `mobile` opens 1:1 in a device frame with a Safe area toggle instead of the viewport, Compare and Fit buttons - **Raw HTML** (`/canvas/:id/html`) — the rendered HTML for embedding or inspection - **JSON API** (`/api/canvases`, `/api/canvas/:id/meta`) — programmatic access - **Live auto-refresh** — the viewer polls for changes every 2 seconds, so the browser updates automatically as your agent runs `batch_design` 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..7370857 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, between a phone held upright (390:844) and 2:1 (a longer page shows its top), so a phone screen is a portrait card. A canvas declared `mobile` opens at 1:1 in a device frame, without the breakpoint, Compare and Fit buttons, and with a **Safe area** toggle that shades the top and bottom insets: each side is the root's `safeArea` when it sets one, else the device's (47 and 34 on `iphone-390`), so a canvas made mobile with `canvas_set_type` shows the bands its content must clear. The overlay is presentation only and never reaches a screenshot or the `versionHash`. 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`; `canvas_set_type({ canvasId, type: "mobile" })` fixes both, and the open page switches to the device frame on its own. ## The mobile ruleset diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 5b8707c..05d736c 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -114,7 +114,7 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO | **FR-E2** | **Resolution by canvas type.** Structure names stay unique: a mobile realisation is named `-mobile` (e.g. `dashboard-mobile`, `anatomy: "dashboard"`), and existing names are unchanged. `apply_structure({ structure })` resolves an anatomy name to the canvas type's realisation (so `dashboard` on a mobile canvas means `dashboard-mobile`). A name that is not an anatomy name (such as `dashboard-mobile`) is used as is, subject to the same surface check; `realisation: "web"` forces the web realisation. If no realisation exists for the canvas type, it refuses with an error naming the anatomies that do have one. `list_structures` accepts `canvasId` (or `type`), defaults to the web listing, and returns each entry's `anatomy` and the surfaces it is realised on. | `apply_structure("dashboard")` on a mobile canvas stamps the mobile home realisation; `apply_structure("pricing")` on a mobile canvas refuses and lists the mobile anatomies. | | **FR-E3** | **Mobile realisations.** Six, named `-mobile` (FR-E2), built only from primitives and $tokens: `dashboard` (summary home), `settings` (grouped list rows), `auth` (sign-in), `onboarding` (paged), and two new mobile-first anatomies, `feed` (a list of rows) and `detail` (an item with a pinned primary action). Each starts with `status-bar` and ends with `home-indicator` or a pinned `tab-bar`. | See FR-E4. | | **FR-E4** | **The pattern gate covers mobile.** `test-patterns.ts` evaluates every mobile realisation on a canvas created with `type: "mobile"`. In fast mode, across its five themes, with the same categories and the same `> 95` bar as the web set, plus zero findings from any `mobile-*` rule. Once per realisation in the full tier: detailed mode with zero `mobile-*` findings, and a CLEAN full `canvas_stress` run (both are layout checks and theme-independent). Every component scaffold marked `surface: "any"` is stamped onto a mobile canvas and held to the same zero-mobile-findings rule in both modes. | The gate fails if any mobile realisation scores 95 or below in any theme, raises any mobile finding in either mode, or stresses FRAGILE, or if any `any` component raises a mobile finding. | -| **FR-F1** | **Artboard-aware thumbnails.** Gallery and archive cards size their iframe from the canvas's root width and height and scale it to the card width; the card's aspect ratio is the root's width / height, so phone canvases are portrait cards. | A 390×844 canvas gets a 390×844 iframe and a card aspect of 390/844; a 1440×900 canvas still gets a 1440×900 iframe at `scale(0.222)`. | +| **FR-F1** | **Artboard-aware thumbnails.** Gallery and archive cards size their iframe from the canvas's root width and height and scale it to the card width; the card's aspect ratio is the root's width / height (clamped between 390 / 844 and 2, see the slice F build contract), so phone canvases are portrait cards. | A 390×844 canvas gets a 390×844 iframe and a card aspect of 390/844; a 1440×900 canvas still gets a 1440×900 iframe at `scale(0.222)`. | | **FR-F2** | **Device presentation.** The detail view shows a `mobile` canvas at 1:1 inside a CSS device frame (rounded screen, bezel; no images), with a toggle that overlays the root's `safeArea` bands. The breakpoint buttons are hidden for mobile canvases (a phone canvas has one device). For web canvases the buttons read their widths from the device profile table instead of hardcoded 390/768. | A mobile canvas opens framed; the overlay shows bands of 47 and 34; a web canvas's phone button uses 390 from the table. | | **FR-G1** | **Acceptance re-run.** Ridgeline Today is rebuilt from the same brief on this phase: `canvas_create` with `type: "mobile"`, `generate_design_system`, `apply_structure("dashboard")`, then adaptation. It must reach CLEAN in fast and detailed mode in both themes, with one full `canvas_stress` run CLEAN (stress renders one theme, `src/index.ts:2145`; its findings are layout, not colour), with every tap target a primitive (zero `mobile-handbuilt-target` infos) and no hand-built device chrome, in fewer than 93 nodes. | The PR quotes both themes' scores in both modes, the stress verdict, the node count, the zero handbuilt-target count, and the `surface` report showing every mobile rule ran. | @@ -352,9 +352,30 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *Known limit, not fixed here.* The renderer scales any font size of 24px or more fluidly against a 1440 design width (`responsiveFontSize`: `clamp(max(16, 0.6 × size), …)`), so on a 390 artboard `$title` (28) renders at 17px, the same as body. The nav-bar's 34px large title is fixed and unaffected. The gate cannot see it (the rules read the authored 28), and it predates this slice and applies to every narrow artboard, so changing it is a render change outside slice E. Slice G's Ridgeline rebuild will show it. **Slice F — the viewer (FR-F1..F2)** — needs B's type, profile table and `root.safeArea` (for the overlay). -- `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`. +- (Pre-build pointers; the lines moved.) `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; an artboard taller than 390:844 shows its top, as every artboard taller than 16:10 did before; one wider than 2:1 sits at the top of its box). 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), tinted with a dashed inner edge, `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. +- **As built (slice F).** Where the build departs from the contract above, or settles what it left open: + - *Sizes are finite and positive.* `artboardSize` reads a root width or height only when it is a finite number above 0, else 1440 / 900; the contract's "numeric" let 0, a negative number or Infinity through to a `width: 0px` iframe and a zero or infinite scale. The `/canvas/:id/html` route still renders the raw number (render unchanged, C10). + - *The fit reads the fractional card width* (`getBoundingClientRect`), so the iframe matches the card to the sub-pixel instead of being up to half a pixel short or clipped. + - *The bottom band's label sits right of centre* so it does not cover the home-indicator pill; a side whose inset is 0 draws no band. + - *Verify round.* Two verifiers, the docs steward and a cold reader checked ab3e215; these changes came out of it: + - *An open detail page follows the canvas.* The meta poll carries the resolved `type`, and the page reloads when the type, the root width, the root height or the root `safeArea` changes (the 3b round caught bands left stale by a `safeArea` edit), so `canvas_set_type` switches an open page between device mode and breakpoints (it used to bump only the iframes, leaving the old toolbar and frame until a manual reload). Gallery cards still refresh only when the project's canvas count changes, as before. + - *An overflowing preview scrolls to its left edge.* The preview centres with `justify-content: safe center`: a mobile canvas 1440 wide, or the device in a window under about 460px, had its left edge at a negative offset that no scroll could reach. Before this slice the same was true of Compare's grid in a narrow preview (a single web frame shrinks to fit). + - *A breakpoint switch keeps the dark render.* `setViewport` keeps `theme=dark` while Dark is on; the frame used to drop to light while the button still read dark (partly pre-existing). + - *Accepted:* the reload resets the page's own state (Dark, the JSON panel, the overlay toggle), and a change between two invalid sizes reloads once although the artboard stays 1440×900. Both are rare and harmless. + - *Docs.* README's viewer paragraph no longer says every canvas has breakpoints, and the route list and GUIDELINES say what a mobile canvas shows instead, the inset fallback values, the clamp, and that the overlay never reaches a screenshot. + **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. diff --git a/src/viewer.ts b/src/viewer.ts index 90bb4d4..99a1f34 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'; @@ -106,6 +106,8 @@ export async function startViewer(port: number): Promise { contentKey: evalCacheKey(canvas), width: canvas.root.width, height: canvas.root.height, + type: resolveSurface(canvas).type, + safeArea: canvas.root.safeArea, })); return; } @@ -475,6 +477,58 @@ 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' && Number.isFinite(width) && width > 0 ? width : 1440, + h: typeof height === 'number' && Number.isFinite(height) && 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); + const width = thumb.getBoundingClientRect().width; + if (w > 0 && width > 0) thumb.style.setProperty('--thumb-scale', String(width / 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 +553,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 +570,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 +580,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 +642,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 +681,7 @@ ${FAVICON_HTML}