stitch2 0.22.7 → 0.22.8

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.
@@ -21,12 +21,14 @@ export interface PageComponents {
21
21
  selector: string;
22
22
  text: string;
23
23
  }[];
24
+ /** Provenance declared by the page. App snapshots are faithful renders, not component implementations. */
25
+ source?: string;
24
26
  }
25
27
  export interface CollectOptions {
26
28
  /** Component marker: elements with data-<prefix>="name". */
27
29
  prefix: string;
28
30
  /** DESIGN.md's primary colour as [r, g, b]: filled buttons in it are primary buttons. */
29
- primary: number[];
31
+ primary?: number[];
30
32
  }
31
33
  /** The options for collectComponents from the config and DESIGN.md's primary colour. */
32
34
  export declare function collectOptions(): CollectOptions;
@@ -10,8 +10,31 @@ import { config } from './config.js';
10
10
  import { readDesign } from './design-md.js';
11
11
  /** The options for collectComponents from the config and DESIGN.md's primary colour. */
12
12
  export function collectOptions() {
13
- const hex = (readDesign().colors.primary ?? '#000000').replace('#', '');
14
- return { prefix: config.prefix, primary: [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)) };
13
+ const design = readDesign();
14
+ // DESIGN.md projects created by stitch2 have `colors.primary`; projects that use a richer palette often name
15
+ // the same role and reference it from components.button-primary. Resolve that reference here. Falling back to
16
+ // black made every transparent element look like a primary button.
17
+ const component = design.components['button-primary']?.backgroundColor;
18
+ const ref = /^\{colors\.([\w.-]+)\}$/.exec(component ?? '')?.[1];
19
+ const value = design.colors.primary ?? (ref ? design.colors[ref] : undefined) ?? component;
20
+ const primary = parseColour(value);
21
+ return { prefix: config.prefix, ...(primary ? { primary } : {}) };
22
+ }
23
+ /** CSS colours accepted in DESIGN.md, represented as RGB for the page-side detector. */
24
+ function parseColour(value) {
25
+ if (!value)
26
+ return undefined;
27
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value.trim())?.[1];
28
+ if (hex) {
29
+ const expanded = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex;
30
+ return [0, 2, 4].map((i) => Number.parseInt(expanded.slice(i, i + 2), 16));
31
+ }
32
+ const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(value.trim());
33
+ if (!rgb)
34
+ return undefined;
35
+ return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])].every(Number.isFinite)
36
+ ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
37
+ : undefined;
15
38
  }
16
39
  /** Runs in the page. Plain JS on purpose: Playwright sends its source to the browser. */
17
40
  export function collectComponents(opts) {
@@ -73,11 +96,37 @@ export function collectComponents(opts) {
73
96
  });
74
97
  const iconSet = (root) => [...new Set(icons(root).map(([s]) => s))].join('+');
75
98
  const box = (el) => el.getBoundingClientRect();
76
- const ember = (el) => getComputedStyle(el).backgroundColor.includes(opts.primary.join(', '));
99
+ const ember = (el) => {
100
+ if (!opts.primary)
101
+ return false;
102
+ const background = getComputedStyle(el).backgroundColor.trim();
103
+ // Computed sRGB backgrounds are rgb()/rgba() in supported browsers. Do not extract arbitrary numbers from
104
+ // oklch(), color-mix() or a CSS variable that did not resolve: a false primary match is worse than no match.
105
+ const values = /^rgba?\(/i.test(background) ? background.match(/[\d.]+/g)?.map(Number) ?? [] : [];
106
+ const alpha = values.length > 3 ? values[3] : 1;
107
+ return (alpha >= 0.05 &&
108
+ values.length >= 3 &&
109
+ opts.primary.every((value, i) => Math.abs(value - values[i]) <= 2));
110
+ };
77
111
  const visible = (el) => {
78
112
  const r = box(el);
79
113
  const c = getComputedStyle(el);
80
- return r.width > 0 && r.height > 0 && c.visibility !== 'hidden' && c.display !== 'none';
114
+ if (r.width <= 0 || r.height <= 0 || c.visibility === 'hidden' || c.display === 'none')
115
+ return false;
116
+ // A closed <details> exposes only its summary. Its descendants can still have boxes in a static snapshot,
117
+ // especially when the snapshot was captured at a responsive breakpoint.
118
+ const closed = el.closest('details:not([open])');
119
+ if (closed && !closed.querySelector(':scope > summary')?.contains(el))
120
+ return false;
121
+ // Match audit/type checks: clipped screen-reader-only nodes and transparent ancestors are not drawn.
122
+ if (r.width <= 1 && r.height <= 1 && c.position === 'absolute')
123
+ return false;
124
+ if (c.clip === 'rect(0px, 0px, 0px, 0px)' || c.clipPath === 'inset(100%)')
125
+ return false;
126
+ let opacity = 1;
127
+ for (let e = el; e; e = e.parentElement)
128
+ opacity *= Number(getComputedStyle(e).opacity);
129
+ return opacity >= 0.05;
81
130
  };
82
131
  const sigOf = (kind, el) => {
83
132
  const c = getComputedStyle(el);
@@ -223,7 +272,8 @@ export function collectComponents(opts) {
223
272
  el.matches('h2, h3, h4, p, span, div, label'))
224
273
  found('section', el);
225
274
  }
226
- return { components, handBuilt };
275
+ const source = document.querySelector(`meta[name="${opts.prefix}-source"]`)?.getAttribute('content') ?? undefined;
276
+ return { components, handBuilt, ...(source ? { source } : {}) };
227
277
  }
228
278
  /** The components whose look is measured and compared; the rest follow their content (see sigOf). */
229
279
  const MEASURED = new Set(['tab-bar', 'header', 'section', 'card', 'button', 'icon-button', 'chip']);
@@ -332,7 +382,9 @@ export function consistencyIssues(known, paths) {
332
382
  }
333
383
  /** Lab screens build catalog components with the components, not by hand. */
334
384
  export function reuseIssues(path, page) {
335
- if (familyOf(path) !== 'screens')
385
+ // App snapshots preserve the exact product markup for visual review. They are not authored by the lab and cannot
386
+ // truthfully be rewritten to custom elements; only hand-authored lab screens are checked for component reuse.
387
+ if (familyOf(path) !== 'screens' || page.source === 'app')
336
388
  return [];
337
389
  return page.handBuilt.slice(0, 10).map((h) => ({
338
390
  severity: 'error',
@@ -24,6 +24,8 @@ export declare function motionDurations(design?: Design): number[];
24
24
  * colours are the dark theme and light-* colours make a light one.
25
25
  */
26
26
  export declare function designThemes(): string[];
27
+ /** Keep an authored font stack intact, quoting only a single family whose name needs it. */
28
+ export declare function cssFontStack(value: string | undefined): string;
27
29
  /** A type level in pixels, for comparing with computed styles. */
28
30
  export interface Level {
29
31
  name: string;
package/dist/design-md.js CHANGED
@@ -55,19 +55,51 @@ export function designThemes() {
55
55
  return ['dark'];
56
56
  }
57
57
  }
58
+ /** Convert a CSS length from DESIGN.md to pixels. CSS rem uses the 16px root; em and percentages use `relative`. */
59
+ function cssLength(value, relative, unitless) {
60
+ if (value === undefined || value === null)
61
+ return 0;
62
+ if (typeof value === 'number')
63
+ return unitless === 'relative' ? value * relative : value;
64
+ const match = /^(-?(?:\d+|\d*\.\d+))\s*(px|rem|em|%)?$/i.exec(value.trim());
65
+ if (!match)
66
+ return 0;
67
+ const n = Number(match[1]);
68
+ switch ((match[2] ?? '').toLowerCase()) {
69
+ case 'rem':
70
+ return n * 16;
71
+ case 'em':
72
+ case '%':
73
+ return n * relative * (match[2] === '%' ? 0.01 : 1);
74
+ case 'px':
75
+ return n;
76
+ default:
77
+ return unitless === 'relative' ? n * relative : n;
78
+ }
79
+ }
80
+ /** Keep an authored font stack intact, quoting only a single family whose name needs it. */
81
+ export function cssFontStack(value) {
82
+ const stack = value?.trim() ?? '';
83
+ if (!stack)
84
+ return '';
85
+ if (stack.includes(',') || /^['"].*['"]$/.test(stack) || /^[\w-]+$/.test(stack) || /^var\(/i.test(stack))
86
+ return stack;
87
+ return `"${stack.replaceAll('"', '\\"')}"`;
88
+ }
58
89
  export function typeLevels(design = readDesign()) {
59
90
  return Object.entries(design.typography).map(([name, t]) => {
60
- const size = Number.parseFloat(String(t.fontSize));
61
- const lh = String(t.lineHeight);
91
+ const size = cssLength(t.fontSize, 16, 'px');
92
+ const lineHeightValue = t.lineHeight;
93
+ const lineHeight = typeof lineHeightValue === 'string' && lineHeightValue.trim().toLowerCase() === 'normal'
94
+ ? size * 1.2
95
+ : cssLength(lineHeightValue, size, 'relative');
62
96
  return {
63
97
  name,
64
98
  mono: /mono/i.test(t.fontFamily ?? ''),
65
99
  size,
66
100
  weight: Number(t.fontWeight ?? 400),
67
- lineHeight: lh.endsWith('px') ? Number.parseFloat(lh) : Number.parseFloat(lh) * size,
68
- tracking: t.letterSpacing?.endsWith('em')
69
- ? Number.parseFloat(t.letterSpacing) * size
70
- : Number.parseFloat(t.letterSpacing ?? '0') || 0,
101
+ lineHeight,
102
+ tracking: cssLength(t.letterSpacing, size, 'px'),
71
103
  };
72
104
  });
73
105
  }
package/dist/screens.d.ts CHANGED
@@ -28,8 +28,8 @@ export interface Screen {
28
28
  */
29
29
  page: string;
30
30
  device: 'mobile' | 'tablet' | 'desktop';
31
- /** Where the version came from: stitch, lab or trial. */
32
- source: 'stitch' | 'lab' | 'trial';
31
+ /** Where the version came from: app snapshot, generated draft or authored design. */
32
+ source: 'stitch' | 'lab' | 'trial' | 'app';
33
33
  /** Short version label: "v3", "Stitch", "Trial 2". */
34
34
  version: string;
35
35
  status: Status;
package/dist/tokens.js CHANGED
@@ -10,7 +10,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { argbFromHex, Hct, hexFromArgb, MaterialDynamicColors, SchemeContent, SchemeExpressive, SchemeFidelity, SchemeFruitSalad, SchemeMonochrome, SchemeNeutral, SchemeRainbow, SchemeTonalSpot, SchemeVibrant, } from '@material/material-color-utilities';
12
12
  import { config as lab } from './config.js';
13
- import { readDesign } from './design-md.js';
13
+ import { cssFontStack, readDesign } from './design-md.js';
14
14
  import { POINTER, writeExports } from './exports.js';
15
15
  import { ROOT } from './screens.js';
16
16
  const SCHEMES = {
@@ -71,7 +71,7 @@ const typeDecls = {};
71
71
  const typeRules = Object.entries(design.typography).map(([name, t]) => {
72
72
  const fallback = `var(--${lab.prefix}-font-${/mono/i.test(t.fontFamily ?? '') ? 'mono' : 'sans'}-fallback)`;
73
73
  const decl = [
74
- `font: ${t.fontWeight} ${t.fontSize}/${t.lineHeight} "${t.fontFamily}", ${fallback}`,
74
+ `font: ${t.fontWeight} ${t.fontSize}/${t.lineHeight} ${cssFontStack(t.fontFamily) || 'sans-serif'}, ${fallback}`,
75
75
  `letter-spacing: ${t.letterSpacing ?? '0em'}`,
76
76
  t.fontFeature ? `font-feature-settings: ${t.fontFeature}` : '',
77
77
  name.endsWith('-caps') ? 'text-transform: uppercase' : '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.22.7",
3
+ "version": "0.22.8",
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",
@@ -76,6 +76,11 @@ series) is added to DESIGN.md first, with a line in the prose saying where it is
76
76
  a primary-filled button, a pill chip, a header icon button, a caps label). A screen that duplicates a
77
77
  component instead of using it is not handed over: use the component, or extend it (step 4 of the ladder)
78
78
  when it cannot do what the screen needs.
79
+ - The reuse detector reports what is actually drawn: descendants of a closed `<details>`, screen-reader-only or
80
+ clipped nodes, and elements hidden by a transparent ancestor are ignored. This keeps collapsed menus and
81
+ accessibility-only labels from becoming false component errors. A faithful app snapshot marked with
82
+ `<meta name="<prefix>-source" content="app">` is a visual baseline, not a component implementation, so its
83
+ markup is not reuse-checked; make a new lab-authored version before editing the snapshot.
79
84
  - **consistency**: a component whose size, radius, font, icon set or icons differ from the catalog's version
80
85
  (or, without a catalog, from the majority of screens in its family), or that appears in two versions on one
81
86
  screen. `stitch2 consistency` shows the same as cropped images; see the stitch2-consistency skill.
@@ -13,7 +13,7 @@ and build on it. Meta tag names below use the project's prefix (`<p>`, from `sti
13
13
  (`mobile-v3.html` and `desktop-v3.html` are v3). A new version takes the next free N across the screen's
14
14
  variants; a variant added to an existing version takes that version's N (a desktop for v3 is
15
15
  `desktop-v3.html`, never v4). A version may start with one variant and get the others later.
16
- Its head carries `<p>-screen`, `<p>-page` (see Pages), `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab),
16
+ Its head carries `<p>-screen`, `<p>-page` (see Pages), `<p>-device` (mobile | desktop), `<p>-status`, `<p>-source` (stitch | lab | app),
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
 
@@ -22,6 +22,12 @@ It is not the document height: long pages still appear whole. In canvas frames a
22
22
  `vh`, `svh`, `dvh` and `lvh` keep this viewport height when the frame grows, so full-height sections
23
23
  do not create ever-growing gaps. Width responsiveness stays active.
24
24
 
25
+ `source=app` means an unchanged capture of a running application's rendered DOM,
26
+ not a hand-authored design. Layout and visual checks still apply; the `reuse`
27
+ rule does not demand replacing its already-rendered framework components with
28
+ lab elements. Keep the import URL as provenance. A new design based on a capture
29
+ uses `source=lab` and the shared component catalog, not the snapshot exception.
30
+
25
31
  ## Pages and their screens
26
32
 
27
33
  A place in the app often has several states or steps: the workout with its number pad, its menu and its discard
@@ -6,6 +6,9 @@ project's DESIGN.md defines the levels (front matter `typography`); `stitch2 tok
6
6
  `.type-<level>` class, `stitch2 check` warns about text that is not one of them, and `stitch2 type` lists every
7
7
  style a screen uses.
8
8
 
9
+ Use CSS lengths for the type scale (`px`, `rem`, `em`); checks normalize them to rendered pixels
10
+ using a 16px root. A font-family list stays a CSS stack, not one quoted family name.
11
+
9
12
  ## 1. A type scale is a short list of levels
10
13
 
11
14
  Google's DESIGN.md format suggests 9–15 levels, each fixing family, size, line height, weight and tracking
@@ -40,6 +40,11 @@ majority of screens in the same family.
40
40
  | Two versions of one component on a screen | Pick the catalog's; if both are needed, they are two variants: add the variant to the component |
41
41
  | The catalog itself is missing the variant a screen needs | Add it to the component and the catalog first, then use it |
42
42
 
43
+ The reuse check only considers what is drawn. Descendants of a closed `<details>`, clipped or screen-reader-only
44
+ nodes, and elements hidden by a transparent ancestor are excluded. Screens imported as faithful app snapshots may
45
+ declare `<meta name="<prefix>-source" content="app">`; those baselines are checked visually, but are not expected to
46
+ contain the lab's custom elements. Create a new lab-authored version before changing one.
47
+
43
48
  Never fix consistency by editing one screen to imitate another by hand, and never edit an approved version:
44
49
  make the next version.
45
50