stitch2 0.22.5 → 0.22.7
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 +53 -21
- package/dist/screens.d.ts +2 -0
- package/dist/screens.js +1 -0
- package/dist/serve.js +7 -1
- package/dist/shots.js +8 -3
- package/dist/viewport.d.ts +3 -0
- package/dist/viewport.js +68 -0
- package/package.json +1 -1
- package/skills/stitch2/references/screens.md +5 -0
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
|
-
|
|
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,13 @@ 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
|
-
|
|
303
|
+
// Height semantics changed when live frames began loading at their authored
|
|
304
|
+
// viewport. Do not carry a pre-fix cache into this layout: one stale 20k page
|
|
305
|
+
// is enough to create a giant gap before its iframe is visited.
|
|
306
|
+
const HEIGHT_KEY = 'stitch2-heights-v3'
|
|
307
|
+
const HEIGHTS = JSON.parse(localStorage.getItem(HEIGHT_KEY) ?? '{}')
|
|
301
308
|
let heightsTimer
|
|
302
|
-
const saveHeights = () => { clearTimeout(heightsTimer); heightsTimer = setTimeout(() => localStorage.setItem(
|
|
309
|
+
const saveHeights = () => { clearTimeout(heightsTimer); heightsTimer = setTimeout(() => localStorage.setItem(HEIGHT_KEY, JSON.stringify(HEIGHTS)), 1000) }
|
|
303
310
|
let layoutQueued = false
|
|
304
311
|
/** Many frames measure themselves at once while loading: one layout per animation frame. */
|
|
305
312
|
const scheduleLayout = () => { if (!layoutQueued) { layoutQueued = true; requestAnimationFrame(() => { layoutQueued = false; layout() }) } }
|
|
@@ -311,7 +318,7 @@ const apply = () => {
|
|
|
311
318
|
viewport.style.backgroundSize = `${s}px ${s}px`
|
|
312
319
|
viewport.style.backgroundPosition = `${view.x}px ${view.y}px`
|
|
313
320
|
document.getElementById('zoom').textContent = `${Math.round(view.z * 100)}%`
|
|
314
|
-
localStorage.setItem(
|
|
321
|
+
localStorage.setItem(VIEW_KEY, JSON.stringify(view))
|
|
315
322
|
scheduleSync()
|
|
316
323
|
}
|
|
317
324
|
const zoomAt = (cx, cy, z) => {
|
|
@@ -580,8 +587,11 @@ function measure(f) {
|
|
|
580
587
|
if (!f.iframe) return
|
|
581
588
|
try {
|
|
582
589
|
const doc = f.iframe.contentDocument
|
|
590
|
+
// A previously tall iframe supplies a scrollHeight floor even after content shrinks.
|
|
591
|
+
f.iframe.style.height = `${f.screen.viewportHeight ?? 844}px`
|
|
583
592
|
// As tall as the page, up to MAX_FRAME_HEIGHT: long landing pages are drawn whole.
|
|
584
593
|
setHeight(f, Math.max(doc.documentElement.scrollHeight, doc.body?.scrollHeight ?? 0))
|
|
594
|
+
size(f)
|
|
585
595
|
} catch {}
|
|
586
596
|
}
|
|
587
597
|
|
|
@@ -602,13 +612,13 @@ function size(f) {
|
|
|
602
612
|
f.img.style.width = w
|
|
603
613
|
if (f.iframe) Object.assign(f.iframe.style, { width: w, height: h })
|
|
604
614
|
}
|
|
605
|
-
const frameSrc = (s) => `/${s.path}?pick${langQuery()}`
|
|
615
|
+
const frameSrc = (s) => `/${s.path}?pick&canvas=${s.viewportHeight ?? 844}${langQuery()}`
|
|
606
616
|
|
|
607
617
|
// ---- live frames: a screen is a whole page, so only the frames in view, at a zoom where you can work with them,
|
|
608
618
|
// are live (at most MAX_LIVE, nearest the middle first), plus the selected one and those with picked elements.
|
|
609
619
|
// The rest show the picture the server renders (/api/shot). Without pictures (no Chromium), frames in view stay
|
|
610
620
|
// live at any zoom, still at most MAX_LIVE. ----
|
|
611
|
-
const LIVE_ZOOM = 0.3, MAX_LIVE =
|
|
621
|
+
const LIVE_ZOOM = 0.3, MAX_LIVE = 6, SHOTS_AT_ONCE = 3
|
|
612
622
|
let shotRev = 0, shotErrors = 0, syncTimer
|
|
613
623
|
const shotsFailed = () => shotErrors > 2
|
|
614
624
|
const scheduleSync = () => { clearTimeout(syncTimer); syncTimer = setTimeout(syncLive, 120) }
|
|
@@ -624,21 +634,40 @@ function mount(f) {
|
|
|
624
634
|
const iframe = document.createElement('iframe')
|
|
625
635
|
iframe.setAttribute('scrolling', 'no')
|
|
626
636
|
f.iframe = iframe
|
|
637
|
+
// A remounted frame can still have a cached full-page height. Start the
|
|
638
|
+
// document at its authored viewport so vh/svh/dvh rules are evaluated
|
|
639
|
+
// against the screen, not against the previous page height.
|
|
627
640
|
size(f)
|
|
641
|
+
iframe.style.height = `${f.screen.viewportHeight ?? 844}px`
|
|
642
|
+
const waitForStyles = async () => {
|
|
643
|
+
const doc = iframe.contentDocument
|
|
644
|
+
if (!doc || doc.readyState !== 'complete') return false
|
|
645
|
+
try { await doc.fonts?.ready } catch {}
|
|
646
|
+
// Browser-built Tailwind and late stylesheet links can settle just after
|
|
647
|
+
// load. Do not measure or reveal the live page until that layout pass.
|
|
648
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
649
|
+
// `load` also fires for the initial about:blank document. Compare the
|
|
650
|
+
// loaded document itself with the current iframe target (which may have
|
|
651
|
+
// changed for a language/theme reload while this mount was pending).
|
|
652
|
+
return f.iframe === iframe && iframe.contentDocument === doc && doc.readyState === 'complete' && doc.location.href === iframe.src
|
|
653
|
+
}
|
|
628
654
|
iframe.addEventListener('load', () => {
|
|
629
655
|
if (f.iframe !== iframe) return
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
// Tailwind's browser build and the components settle just after load: the picture stays until then.
|
|
634
|
-
f.reveal = setTimeout(() => {
|
|
635
|
-
if (f.iframe !== iframe) return
|
|
656
|
+
void (async () => {
|
|
657
|
+
if (!(await waitForStyles()) || f.iframe !== iframe) return
|
|
658
|
+
applyTheme(f)
|
|
636
659
|
measure(f)
|
|
637
|
-
f.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
660
|
+
clearTimeout(f.reveal)
|
|
661
|
+
// The picture stays until the complete, styled page has been measured.
|
|
662
|
+
f.reveal = setTimeout(() => {
|
|
663
|
+
if (f.iframe !== iframe) return
|
|
664
|
+
measure(f)
|
|
665
|
+
f.el.classList.add('ready')
|
|
666
|
+
for (const go of f.waiters.splice(0)) go()
|
|
667
|
+
}, 350)
|
|
668
|
+
setTimeout(() => { if (f.iframe === iframe) measure(f) }, 1200)
|
|
669
|
+
setTimeout(() => { if (f.iframe === iframe) measure(f) }, 2500)
|
|
670
|
+
})()
|
|
642
671
|
})
|
|
643
672
|
iframe.src = frameSrc(f.screen)
|
|
644
673
|
f.device.insertBefore(iframe, f.img.nextSibling)
|
|
@@ -672,7 +701,8 @@ function shotDone(f, ok) {
|
|
|
672
701
|
if (!f.img.dataset.loading) return
|
|
673
702
|
delete f.img.dataset.loading
|
|
674
703
|
shotsLoading--
|
|
675
|
-
if (ok) { shotErrors = 0; if (!f.iframe) setHeight(f, f.img.naturalHeight) } else
|
|
704
|
+
if (ok) { shotErrors = 0; if (!f.iframe) setHeight(f, f.img.naturalHeight) } else shotErrors++
|
|
705
|
+
scheduleSync()
|
|
676
706
|
pumpShots()
|
|
677
707
|
}
|
|
678
708
|
function syncLive() {
|
|
@@ -693,9 +723,11 @@ function syncLive() {
|
|
|
693
723
|
// Frames just out of view stay live while there's room, so panning back and forth doesn't reload them.
|
|
694
724
|
for (const f of kept) { if (live.size >= MAX_LIVE) break; if (f.iframe) live.add(f) }
|
|
695
725
|
}
|
|
696
|
-
|
|
726
|
+
const visible = new Set(near.map(({ f }) => f))
|
|
727
|
+
// Keep the page until its picture is loaded; switching zoom must never reveal an empty frame.
|
|
728
|
+
for (const f of frames.values()) if (f.iframe && !live.has(f) && (!visible.has(f) || (f.img.complete && f.img.naturalWidth))) unmount(f)
|
|
697
729
|
for (const f of live) mount(f)
|
|
698
|
-
shotQueue = near.
|
|
730
|
+
shotQueue = near.map(({ f }) => [f, shotUrl(f.screen)])
|
|
699
731
|
pumpShots()
|
|
700
732
|
}
|
|
701
733
|
|
|
@@ -991,7 +1023,7 @@ async function load(first, given) {
|
|
|
991
1023
|
await loadChecks()
|
|
992
1024
|
await loadReports()
|
|
993
1025
|
if (selected) inspector(selected)
|
|
994
|
-
if (first && !
|
|
1026
|
+
if (first && !savedView) setTimeout(fit, 1500)
|
|
995
1027
|
}
|
|
996
1028
|
|
|
997
1029
|
// ---- 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
|
-
|
|
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
|
|
93
|
-
await page.
|
|
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);
|
package/dist/viewport.js
ADDED
|
@@ -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
|
@@ -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
|