stitch2 0.22.5 → 0.22.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/canvas.html CHANGED
@@ -92,6 +92,7 @@
92
92
  .frame.ready .device iframe { visibility: visible; }
93
93
  .device .shot { display: block; left: 0; pointer-events: none; position: absolute; top: 0; user-select: none; }
94
94
  .device .shot:not([src]) { display: none; }
95
+ .frame.ready .device .shot { visibility: hidden; }
95
96
  .device .shield { cursor: pointer; inset: 0; position: absolute; }
96
97
  .frame.live .shield { display: none; }
97
98
  .frame.changed .device { animation: flash 900ms ease-out; }
@@ -288,7 +289,9 @@ const copyButton = (text, title) => {
288
289
  b.onclick = (e) => { e.stopPropagation(); copy(text) }
289
290
  return b
290
291
  }
291
- let view = JSON.parse(localStorage.getItem('stitch2-view') ?? 'null') ?? { x: 40, y: 60, z: 0.4 }
292
+ const VIEW_KEY = 'stitch2-view-v2'
293
+ const savedView = JSON.parse(localStorage.getItem(VIEW_KEY) ?? 'null')
294
+ let view = savedView ?? { x: 40, y: 60, z: 0.4 }
292
295
  const filters = JSON.parse(localStorage.getItem('stitch2-filters') ?? 'null') ?? { status: 'active' }
293
296
  if (typeof filters.device === 'string') { filters.devices = filters.device === 'all' ? [] : [filters.device]; delete filters.device }
294
297
  let screens = []
@@ -297,9 +300,9 @@ let reports = { consistency: null, compare: null, states: null }
297
300
  let selected = null
298
301
  const frames = new Map() // path -> { el, device, img, iframe (while live), screen, height }
299
302
  // Heights measured before, so the layout doesn't jump while pictures and pages load.
300
- const HEIGHTS = JSON.parse(localStorage.getItem('stitch2-heights') ?? '{}')
303
+ const HEIGHTS = JSON.parse(localStorage.getItem('stitch2-heights-v2') ?? '{}')
301
304
  let heightsTimer
302
- const saveHeights = () => { clearTimeout(heightsTimer); heightsTimer = setTimeout(() => localStorage.setItem('stitch2-heights', JSON.stringify(HEIGHTS)), 1000) }
305
+ const saveHeights = () => { clearTimeout(heightsTimer); heightsTimer = setTimeout(() => localStorage.setItem('stitch2-heights-v2', JSON.stringify(HEIGHTS)), 1000) }
303
306
  let layoutQueued = false
304
307
  /** Many frames measure themselves at once while loading: one layout per animation frame. */
305
308
  const scheduleLayout = () => { if (!layoutQueued) { layoutQueued = true; requestAnimationFrame(() => { layoutQueued = false; layout() }) } }
@@ -311,7 +314,7 @@ const apply = () => {
311
314
  viewport.style.backgroundSize = `${s}px ${s}px`
312
315
  viewport.style.backgroundPosition = `${view.x}px ${view.y}px`
313
316
  document.getElementById('zoom').textContent = `${Math.round(view.z * 100)}%`
314
- localStorage.setItem('stitch2-view', JSON.stringify(view))
317
+ localStorage.setItem(VIEW_KEY, JSON.stringify(view))
315
318
  scheduleSync()
316
319
  }
317
320
  const zoomAt = (cx, cy, z) => {
@@ -580,8 +583,11 @@ function measure(f) {
580
583
  if (!f.iframe) return
581
584
  try {
582
585
  const doc = f.iframe.contentDocument
586
+ // A previously tall iframe supplies a scrollHeight floor even after content shrinks.
587
+ f.iframe.style.height = `${f.screen.viewportHeight ?? 844}px`
583
588
  // As tall as the page, up to MAX_FRAME_HEIGHT: long landing pages are drawn whole.
584
589
  setHeight(f, Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
590
+ size(f)
585
591
  } catch {}
586
592
  }
587
593
 
@@ -602,13 +608,13 @@ function size(f) {
602
608
  f.img.style.width = w
603
609
  if (f.iframe) Object.assign(f.iframe.style, { width: w, height: h })
604
610
  }
605
- const frameSrc = (s) => `/${s.path}?pick${langQuery()}`
611
+ const frameSrc = (s) => `/${s.path}?pick&canvas=${s.viewportHeight ?? 844}${langQuery()}`
606
612
 
607
613
  // ---- live frames: a screen is a whole page, so only the frames in view, at a zoom where you can work with them,
608
614
  // are live (at most MAX_LIVE, nearest the middle first), plus the selected one and those with picked elements.
609
615
  // The rest show the picture the server renders (/api/shot). Without pictures (no Chromium), frames in view stay
610
616
  // live at any zoom, still at most MAX_LIVE. ----
611
- const LIVE_ZOOM = 0.3, MAX_LIVE = 12, SHOTS_AT_ONCE = 3
617
+ const LIVE_ZOOM = 0.3, MAX_LIVE = 6, SHOTS_AT_ONCE = 3
612
618
  let shotRev = 0, shotErrors = 0, syncTimer
613
619
  const shotsFailed = () => shotErrors > 2
614
620
  const scheduleSync = () => { clearTimeout(syncTimer); syncTimer = setTimeout(syncLive, 120) }
@@ -637,8 +643,8 @@ function mount(f) {
637
643
  f.el.classList.add('ready')
638
644
  for (const go of f.waiters.splice(0)) go()
639
645
  }, 350)
640
- setTimeout(() => measure(f), 1200)
641
- setTimeout(() => measure(f), 2500)
646
+ setTimeout(() => { if (f.iframe === iframe) measure(f) }, 1200)
647
+ setTimeout(() => { if (f.iframe === iframe) measure(f) }, 2500)
642
648
  })
643
649
  iframe.src = frameSrc(f.screen)
644
650
  f.device.insertBefore(iframe, f.img.nextSibling)
@@ -672,7 +678,8 @@ function shotDone(f, ok) {
672
678
  if (!f.img.dataset.loading) return
673
679
  delete f.img.dataset.loading
674
680
  shotsLoading--
675
- if (ok) { shotErrors = 0; if (!f.iframe) setHeight(f, f.img.naturalHeight) } else if (++shotErrors > 2) scheduleSync()
681
+ if (ok) { shotErrors = 0; if (!f.iframe) setHeight(f, f.img.naturalHeight) } else shotErrors++
682
+ scheduleSync()
676
683
  pumpShots()
677
684
  }
678
685
  function syncLive() {
@@ -693,9 +700,11 @@ function syncLive() {
693
700
  // Frames just out of view stay live while there's room, so panning back and forth doesn't reload them.
694
701
  for (const f of kept) { if (live.size >= MAX_LIVE) break; if (f.iframe) live.add(f) }
695
702
  }
696
- for (const f of frames.values()) if (f.iframe && !live.has(f)) unmount(f)
703
+ const visible = new Set(near.map(({ f }) => f))
704
+ // Keep the page until its picture is loaded; switching zoom must never reveal an empty frame.
705
+ for (const f of frames.values()) if (f.iframe && !live.has(f) && (!visible.has(f) || (f.img.complete && f.img.naturalWidth))) unmount(f)
697
706
  for (const f of live) mount(f)
698
- shotQueue = near.filter(({ f }) => !f.el.classList.contains('ready')).map(({ f }) => [f, shotUrl(f.screen)])
707
+ shotQueue = near.map(({ f }) => [f, shotUrl(f.screen)])
699
708
  pumpShots()
700
709
  }
701
710
 
@@ -991,7 +1000,7 @@ async function load(first, given) {
991
1000
  await loadChecks()
992
1001
  await loadReports()
993
1002
  if (selected) inspector(selected)
994
- if (first && !localStorage.getItem('stitch2-view')) setTimeout(fit, 1500)
1003
+ if (first && !savedView) setTimeout(fit, 1500)
995
1004
  }
996
1005
 
997
1006
  // ---- filters ----
package/dist/screens.d.ts CHANGED
@@ -14,6 +14,8 @@ export interface Screen {
14
14
  title: string;
15
15
  /** Frame width in px: <meta name="<prefix>-frame-width" content="390">, default 390 (phone). */
16
16
  width: number;
17
+ /** Viewport height for vh/svh/dvh layout; independent of the full document height. */
18
+ viewportHeight: number;
17
19
  mtime: number;
18
20
  /** "screen", or "components" for the component catalog. */
19
21
  kind: 'screen' | 'components';
package/dist/screens.js CHANGED
@@ -48,6 +48,7 @@ export function listScreens() {
48
48
  name,
49
49
  title: /<title>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ?? '',
50
50
  width,
51
+ viewportHeight: Math.min(4000, Math.max(200, Number(meta(html, tag('frame-height'))) || 844)),
51
52
  mtime: statSync(file).mtimeMs,
52
53
  kind: under(path, config.catalogDir) ? 'components' : 'screen',
53
54
  screen: meta(html, tag('screen')) ?? (versioned ? group.split('/').pop() : name),
package/dist/serve.js CHANGED
@@ -16,6 +16,7 @@ import { designPage } from './design-page.js';
16
16
  import { languagesPage } from './languages-page.js';
17
17
  import { annotate } from './pick.js';
18
18
  import { closeShots, sharedChanged, shot } from './shots.js';
19
+ import { pinViewportHeight } from './viewport.js';
19
20
  /**
20
21
  * Inlines <link rel="stylesheet" type="text/tailwindcss" href="…">: the Tailwind v4 browser build reads @theme and
21
22
  * @utility only from inline <style type="text/tailwindcss"> blocks. On one line, so source line numbers hold.
@@ -210,7 +211,12 @@ export function startServer(port) {
210
211
  // The canvas loads screens with ?pick: their elements carry source lines for picking.
211
212
  if (extname(file) === '.html') {
212
213
  const html = inlineTailwind(readFileSync(file, 'utf8'), file);
213
- const page = url.searchParams.has('pick') ? annotate(html) : html;
214
+ let page = url.searchParams.has('pick') ? annotate(html) : html;
215
+ if (url.searchParams.has('canvas')) {
216
+ const requested = Number(url.searchParams.get('canvas')) || 844;
217
+ const height = Math.min(4000, Math.max(200, requested));
218
+ page = page.replace(/<head(\s[^>]*)?>/i, (head) => head + `<script>(${pinViewportHeight.toString()})(${height})</script>`);
219
+ }
214
220
  const lang = url.searchParams.get('lang');
215
221
  if (!lang || lang === sourceLocale())
216
222
  return res.end(page);
package/dist/shots.js CHANGED
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { messagesFile, sourceLocale } from './i18n.js';
13
- import { ROOT } from './screens.js';
13
+ import { ROOT, meta, tag } from './screens.js';
14
14
  /** The tallest picture, in CSS px: the same limit as the canvas frames and check's renders. */
15
15
  const MAX_HEIGHT = 20000;
16
16
  const DIR = join(tmpdir(), 'stitch2-shots');
@@ -48,6 +48,7 @@ function keyOf(r) {
48
48
  return null;
49
49
  const messages = r.lang && r.lang !== sourceLocale() ? messagesFile(r.lang) : undefined;
50
50
  const parts = [
51
+ 'viewport-v2',
51
52
  r.path,
52
53
  statSync(file).mtimeMs,
53
54
  sharedStamp(),
@@ -89,8 +90,12 @@ export function shot(base, r) {
89
90
  browser ??= launch();
90
91
  const page = await (await browser).newPage({ viewport: { width: r.width, height: 844 }, deviceScaleFactor: 1 });
91
92
  try {
92
- const lang = r.lang && r.lang !== sourceLocale() ? `?lang=${encodeURIComponent(r.lang)}` : '';
93
- await page.goto(`${base}/${r.path}${lang}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
93
+ const viewport = Math.min(4000, Math.max(200, Number(meta(readFileSync(join(ROOT, r.path), 'utf8'), tag('frame-height'))) || 844));
94
+ await page.setViewportSize({ width: r.width, height: viewport });
95
+ const query = new URLSearchParams({ canvas: String(viewport) });
96
+ if (r.lang && r.lang !== sourceLocale())
97
+ query.set('lang', r.lang);
98
+ await page.goto(`${base}/${r.path}?${query}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
94
99
  if (r.theme)
95
100
  await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, r.theme);
96
101
  await page.evaluate(() => document.fonts.ready);
@@ -0,0 +1,3 @@
1
+ /** Runs inside a page. Canvas frames grow to the document, but viewport units
2
+ * must keep describing the original screen rather than that growing frame. */
3
+ export declare function pinViewportHeight(height: number): void;
@@ -0,0 +1,68 @@
1
+ /** Runs inside a page. Canvas frames grow to the document, but viewport units
2
+ * must keep describing the original screen rather than that growing frame. */
3
+ export function pinViewportHeight(height) {
4
+ // shortcut: cross-origin and shadow-root styles keep native units; localize sheets before supporting those screens.
5
+ const freeze = () => {
6
+ const units = /url\(\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(?:\\.|[^)])*)\s*\)|(["'])(?:\\.|(?!\1)[\s\S])*?\1|(-?(?:\d*\.)?\d+)(?:s|l|d)?v(h|min|max)\b/gi;
7
+ const value = (text) => text.replace(units, (match, quoted, amount, axis) => {
8
+ if (quoted || amount == null)
9
+ return match;
10
+ const unit = axis.toLowerCase();
11
+ const size = unit === 'h' ? height : unit === 'min' ? Math.min(innerWidth, height) : Math.max(innerWidth, height);
12
+ return `${Number(amount) * size / 100}px`;
13
+ });
14
+ const style = (declarations) => {
15
+ for (const property of Array.from(declarations)) {
16
+ const current = declarations.getPropertyValue(property);
17
+ const next = value(current);
18
+ if (next !== current)
19
+ declarations.setProperty(property, next, declarations.getPropertyPriority(property));
20
+ }
21
+ };
22
+ const walk = (rules) => {
23
+ for (const rule of Array.from(rules)) {
24
+ if ('style' in rule)
25
+ style(rule.style);
26
+ if ('cssRules' in rule) {
27
+ try {
28
+ walk(rule.cssRules);
29
+ }
30
+ catch { /* Cross-origin nested rules remain browser-owned. */ }
31
+ }
32
+ if ('styleSheet' in rule) {
33
+ try {
34
+ const imported = rule.styleSheet;
35
+ if (imported)
36
+ walk(imported.cssRules);
37
+ }
38
+ catch { /* Cross-origin imported sheets remain browser-owned. */ }
39
+ }
40
+ }
41
+ };
42
+ for (const sheet of Array.from(document.styleSheets)) {
43
+ try {
44
+ walk(sheet.cssRules);
45
+ }
46
+ catch { /* Cross-origin sheets remain browser-owned. */ }
47
+ }
48
+ for (const element of document.querySelectorAll('[style]'))
49
+ style(element.style);
50
+ };
51
+ let pending = false;
52
+ const schedule = () => {
53
+ if (pending)
54
+ return;
55
+ pending = true;
56
+ requestAnimationFrame(() => { pending = false; freeze(); });
57
+ };
58
+ // Fonts, async style links and browser-built Tailwind can arrive after DOMContentLoaded.
59
+ document.addEventListener('DOMContentLoaded', freeze, { once: true });
60
+ document.addEventListener('load', freeze, true);
61
+ new MutationObserver(schedule).observe(document.documentElement, {
62
+ attributes: true,
63
+ attributeFilter: ['style'],
64
+ childList: true,
65
+ subtree: true,
66
+ });
67
+ freeze();
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.22.5",
3
+ "version": "0.22.6",
4
4
  "description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
5
5
  "license": "MIT",
6
6
  "author": "ifokeev",
@@ -17,6 +17,11 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
17
17
  `<p>-frame-width` (390 mobile, 1280 desktop) and, written by the user, `<p>-note`. Older files elsewhere
18
18
  carry the same tags.
19
19
 
20
+ `<p>-frame-height` optionally sets the viewport height the design was drawn at (default 844 px).
21
+ It is not the document height: long pages still appear whole. In canvas frames and their pictures,
22
+ `vh`, `svh`, `dvh` and `lvh` keep this viewport height when the frame grows, so full-height sections
23
+ do not create ever-growing gaps. Width responsiveness stays active.
24
+
20
25
  ## Pages and their screens
21
26
 
22
27
  A place in the app often has several states or steps: the workout with its number pad, its menu and its discard