stitch2 0.22.12 → 0.24.0

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/README.md CHANGED
@@ -42,6 +42,8 @@ file that already exists. From there the agent spends its effort on the design.
42
42
  A width switch (`W`) shows every screen at the narrowest or widest window its layout covers.
43
43
  With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
44
44
  shows a screen side by side in each.
45
+ With app states configured (`modes`: signed in or out, a plan, a role), a dropdown for each (`M` cycles the first)
46
+ shows every screen in one of them, live pages and pictures alike, or as drawn.
45
47
  Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
46
48
  `home mobile v1 (design/screens/home/mobile-v1.html:36) <gg-button> “Start Push”`; Shift-click collects
47
49
  several to copy at once or add to the version's note.
@@ -57,7 +59,7 @@ file that already exists. From there the agent spends its effort on the design.
57
59
  icon-only controls without a name, more than five bottom-navigation destinations and controls without visible
58
60
  focus. Motion: transitions whose length isn't one of DESIGN.md's motion tokens, and anything that still moves
59
61
  with reduced motion on. Each screen is also run in long and right-to-left
60
- pseudo-languages and your locales (see below).
62
+ pseudo-languages and your locales (see below), and, with `modes`, in each app state it is not drawn in.
61
63
  - **States** (from `stitch2 check` on the component catalog): every control at rest, hovered, focused and
62
64
  pressed, forced through Chromium so the components need nothing extra, side by side on one sheet; with
63
65
  warnings for a control that gives no feedback and text that loses contrast in a state.
@@ -260,7 +262,7 @@ layout first, then a shorter natural word, never a smaller type size.
260
262
  | `stitch2 init --name N --primary #hex [options]` | Writes a complete DESIGN.md from a few choices |
261
263
  | `stitch2 extract <url or file…>` | Writes a DESIGN.md measured from an existing site or screens |
262
264
  | `stitch2 canvas` | The canvas at http://localhost:4400 (`PORT` and `HOST` to change) |
263
- | `stitch2 check [filter] [--shots] [--strict] [--locales l,…]` | All checks, in every theme and check language; `--shots` saves renders to `<design>/renders/` (`--light.png`, `--pseudo.png`…) |
265
+ | `stitch2 check [filter] [--shots] [--strict] [--locales l,…]` | All checks, in every theme, app state and check language; `--shots` saves renders to `<design>/renders/` (`--light.png`, `--pseudo.png`, `--auth-user.png`…) |
264
266
  | `stitch2 i18n [filter] [--locale l] [--suggest] [--json]` | Message keys the source or a locale lacks, and screen text with no key |
265
267
  | `stitch2 consistency [kind…]` | The consistency report, `<design>/consistency/index.html` |
266
268
  | `stitch2 type [filter] [--json]` | The type audit |
@@ -77,6 +77,31 @@
77
77
  "items": { "type": "string" },
78
78
  "description": "The themes every screen must work in, by their data-theme value. Defaults to DESIGN.md's: dark (the plain colours), plus light when it has light-* colours. The canvas switches between them and check runs in each."
79
79
  },
80
+ "modes": {
81
+ "type": "array",
82
+ "description": "App states every screen can be shown in, picked from the canvas sidebar: signed in or out, a plan, a role. The pick is an attribute on each screen's <html>, so the screens' own CSS and scripts answer to it. The dropdown also offers the screens as drawn, which leaves each one as its file has it. stitch2 check also runs each screen in every value its file does not already have, one mode at a time, and tags what only shows there.",
83
+ "items": {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "required": ["name", "values"],
87
+ "properties": {
88
+ "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "description": "The id: lowercase letters, digits and hyphens. The canvas remembers the pick under it." },
89
+ "label": { "type": "string", "description": "Shown before each value in the dropdown. Defaults to the name, capitalized." },
90
+ "attribute": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "description": "The attribute on the screen's <html> that carries the value. Defaults to data-<name>." },
91
+ "values": {
92
+ "type": "array",
93
+ "minItems": 1,
94
+ "description": "What the canvas can show every screen as: a value, or a value with its label.",
95
+ "items": {
96
+ "oneOf": [
97
+ { "type": "string" },
98
+ { "type": "object", "additionalProperties": false, "required": ["value"], "properties": { "value": { "type": "string" }, "label": { "type": "string" } } }
99
+ ]
100
+ }
101
+ }
102
+ }
103
+ }
104
+ },
80
105
  "i18n": {
81
106
  "type": "object",
82
107
  "additionalProperties": false,
package/dist/canvas.html CHANGED
@@ -44,6 +44,7 @@
44
44
  /* Theme and language: native dropdowns drawn like the versions and variants ones. */
45
45
  .pick { appearance: none; background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 12px 8px; border: 1px solid var(--edge); border-radius: 8px; color: var(--text); cursor: pointer; font: inherit; padding: 5px 30px 5px 8px; width: 100%; }
46
46
  .pick[hidden] { display: none; }
47
+ #modeFilters { display: contents; }
47
48
  #tree { flex: 1; overflow: auto; padding: 6px 8px 16px; }
48
49
  #tree h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 14px 6px 6px; text-transform: uppercase; }
49
50
  .item { align-items: center; background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; display: flex; gap: 8px; padding: 6px 8px; text-align: left; width: 100%; }
@@ -142,6 +143,7 @@
142
143
  <details id="versionFilter" class="multi" hidden title="Show only the versions you pick (several at once), or all of them"><summary>All versions</summary><div class="menu"></div></details>
143
144
  <details id="variantFilter" class="multi" hidden title="Show only the variants you pick: mobile, tablet, desktop (several at once), or all of them"><summary>All variants</summary><div class="menu"></div></details>
144
145
  <select id="themeFilter" class="pick" hidden title="Show every screen in this theme (T)" aria-label="Theme"></select>
146
+ <div id="modeFilters"></div>
145
147
  <select id="widthFilter" class="pick" title="Show every screen at the narrowest or widest window its variant covers, or at one breakpoint (W)" aria-label="Width"></select>
146
148
  <select id="langFilter" class="pick" title="Show every screen in this language (L)" aria-label="Language"></select>
147
149
  </header>
@@ -179,13 +181,20 @@ let designDir = 'design'
179
181
  let THEMES = ['dark']
180
182
  let theme = localStorage.getItem('stitch2-theme') ?? 'dark'
181
183
  const themed = () => THEMES.length > 1
184
+ // Modes: app states (signed in or out, a plan, a role) every screen can be shown in. Each sets an attribute on the
185
+ // screen's document, like the theme; "As drawn" leaves a screen as its file has it. Pictures of the screens that are
186
+ // not live ask the server for the same states (?mode=name:value).
187
+ let MODES = []
188
+ const modePick = {} // mode name -> picked value, '' for as drawn
189
+ const modeValueLabel = (m, v) => (v ? (m.values.find((x) => x.value === v)?.label ?? v) : 'As drawn')
190
+ const modeNotes = () => MODES.filter((m) => modePick[m.name]).map((m) => `${m.label}: ${modeValueLabel(m, modePick[m.name])}`)
182
191
  // Languages: screens reload in the one picked (?lang=…); the source language shows them as written.
183
192
  let LANGS = ['en']
184
193
  let SOURCE = 'en'
185
194
  let lang = localStorage.getItem('stitch2-lang') ?? 'en'
186
195
  const langQuery = () => (lang === SOURCE ? '' : `&lang=${encodeURIComponent(lang)}`)
187
196
  const themeNote = () =>
188
- [themed() && theme !== THEMES[0] ? `in the ${theme} theme` : '', lang !== SOURCE ? `in ${lang}` : '']
197
+ [themed() && theme !== THEMES[0] ? `in the ${theme} theme` : '', lang !== SOURCE ? `in ${lang}` : '', ...modeNotes()]
189
198
  .filter(Boolean)
190
199
  .map((s) => ' ' + s)
191
200
  .join(',')
@@ -245,6 +254,33 @@ function widthOptions() {
245
254
  if (!widthModes().includes(widthMode)) widthMode = 'drawn'
246
255
  wsel.value = widthMode
247
256
  }
257
+ // The document's own value is kept the first time, so "As drawn" puts it back.
258
+ const authored = new WeakMap()
259
+ function applyModes(f) {
260
+ if (!f.iframe) return
261
+ let root
262
+ try { root = f.iframe.contentDocument.documentElement } catch { return }
263
+ if (!root) return
264
+ const was = authored.get(root) ?? authored.set(root, {}).get(root)
265
+ for (const m of MODES) {
266
+ if (!(m.attribute in was)) was[m.attribute] = root.getAttribute(m.attribute)
267
+ const value = modePick[m.name] || was[m.attribute]
268
+ if (value == null) root.removeAttribute(m.attribute)
269
+ else root.setAttribute(m.attribute, value)
270
+ }
271
+ }
272
+ function setMode(name, value) {
273
+ const m = MODES.find((x) => x.name === name)
274
+ if (!m) return
275
+ modePick[name] = m.values.some((x) => x.value === value) ? value : ''
276
+ localStorage.setItem(`stitch2-mode-${name}`, modePick[name])
277
+ const sel = document.querySelector(`#modeFilters [data-mode="${name}"]`)
278
+ if (sel) sel.value = modePick[name]
279
+ for (const f of frames.values()) applyModes(f)
280
+ if (selected) inspector(selected)
281
+ summary()
282
+ scheduleSync()
283
+ }
248
284
  function applyTheme(f) {
249
285
  if (!themed() || !f.iframe) return
250
286
  try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
@@ -640,6 +676,7 @@ function shotUrl(s) {
640
676
  const q = new URLSearchParams({ path: s.path, width: String(s.width), m: String(Math.round(s.mtime)), r: String(shotRev) })
641
677
  if (themed()) q.set('theme', theme)
642
678
  if (lang !== SOURCE) q.set('lang', lang)
679
+ for (const m of MODES) if (modePick[m.name]) q.append('mode', `${m.name}:${modePick[m.name]}`)
643
680
  return `/api/shot?${q}`
644
681
  }
645
682
  const pinned = (f) => f === selected || f.el.classList.contains('live') || picked.some((p) => p.f === f)
@@ -670,6 +707,7 @@ function mount(f) {
670
707
  void (async () => {
671
708
  if (!(await waitForStyles()) || f.iframe !== iframe) return
672
709
  applyTheme(f)
710
+ applyModes(f)
673
711
  measure(f)
674
712
  clearTimeout(f.reveal)
675
713
  // The picture stays until the complete, styled page has been measured.
@@ -882,7 +920,10 @@ function inspector(f) {
882
920
  li.querySelector('code').textContent = i.selector
883
921
  li.onclick = () => {
884
922
  const r = rangeOf(s)
885
- if (i.width && r && i.width !== s.width) { setWidth(i.width === r[0] ? 'narrow' : i.width === r[1] ? 'wide' : `at:${i.width}`); setTimeout(() => locate(f, i.selector), 900) }
923
+ // An issue found in another app state: show every screen in it, then find the element.
924
+ const [mode, value] = i.mode ? i.mode.split(':') : []
925
+ if (mode && modePick[mode] !== value) { setMode(mode, value); setTimeout(() => locate(f, i.selector), 600) }
926
+ else if (i.width && r && i.width !== s.width) { setWidth(i.width === r[0] ? 'narrow' : i.width === r[1] ? 'wide' : `at:${i.width}`); setTimeout(() => locate(f, i.selector), 900) }
886
927
  else locate(f, i.selector)
887
928
  }
888
929
  ul.append(li)
@@ -1176,6 +1217,7 @@ addEventListener('keydown', (e) => {
1176
1217
  if (e.key === 'Escape') { closeDoc(); select(null) }
1177
1218
  if (e.key === 'f' || e.key === 'F') fit()
1178
1219
  if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
1220
+ if ((e.key === 'm' || e.key === 'M') && MODES.length) { const m = MODES[0], all = ['', ...m.values.map((v) => v.value)]; setMode(m.name, all[(all.indexOf(modePick[m.name]) + 1) % all.length]) }
1179
1221
  if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
1180
1222
  if (e.key === 'w' || e.key === 'W') { const m = widthModes(); setWidth(m[(m.indexOf(widthMode) + 1) % m.length]) }
1181
1223
  if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
@@ -1214,6 +1256,20 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
1214
1256
  pick.innerHTML = THEMES.map((t) => `<option value="${t}">${t[0].toUpperCase()}${t.slice(1)} theme</option>`).join('')
1215
1257
  pick.onchange = () => setTheme(pick.value)
1216
1258
  setTheme(theme)
1259
+ MODES = c.modes ?? []
1260
+ MODES.forEach((m, i) => {
1261
+ const stored = localStorage.getItem(`stitch2-mode-${m.name}`) ?? ''
1262
+ modePick[m.name] = m.values.some((v) => v.value === stored) ? stored : ''
1263
+ const sel = document.createElement('select')
1264
+ sel.className = 'pick'
1265
+ sel.dataset.mode = m.name
1266
+ sel.setAttribute('aria-label', m.label)
1267
+ sel.title = `Show every screen with ${m.label.toLowerCase()} set to one of these, or as drawn${i === 0 ? ' (M)' : ''}`
1268
+ for (const v of ['', ...m.values.map((x) => x.value)]) sel.add(new Option(`${m.label}: ${modeValueLabel(m, v)}`, v))
1269
+ sel.value = modePick[m.name]
1270
+ sel.onchange = () => setMode(m.name, sel.value)
1271
+ document.getElementById('modeFilters').append(sel)
1272
+ })
1217
1273
  WIDTHS = c.widths ?? {}
1218
1274
  BREAKPOINTS = c.breakpoints ?? {}
1219
1275
  const wsel = document.getElementById('widthFilter')
package/dist/check.js CHANGED
@@ -5,13 +5,16 @@
5
5
  * the canvas shows as badges.
6
6
  * Every screen is checked in its own theme, then again in each other theme DESIGN.md defines (designThemes):
7
7
  * colours change there, so contrast is checked again, tagged with the theme.
8
+ * Then in each app state (config modes), one mode at a time and in the screen's own theme: a screen is checked with
9
+ * every value its own file does not already have, and what only shows there (a bar that overflows signed in, a control
10
+ * that crowds another) is tagged with the state.
8
11
  * Then again in each check language (i18n.ts: the pseudo-languages and the config's): layout problems that
9
12
  * show only there (a label running into its neighbour, text cut off, a control drawn backwards right to left),
10
13
  * and keys a locale has no translation for, tagged with the locale. Last, each variant at both ends of the widths
11
14
  * it covers (config widths: mobile 360–599, desktop 1024–1920 by default) and at every breakpoint inside them
12
15
  * (config breakpoints, Tailwind's by default), tagged with the width.
13
16
  * Usage: stitch2 check [path filter…] [--shots] [--strict] [--locales ru,ar | all | none]
14
- * --shots also save page-tall renders to design/renders/ (one per theme, language and width)
17
+ * --shots also save page-tall renders to design/renders/ (one per theme, app state, language and width)
15
18
  * --strict exit 1 when any screen has errors (for CI and agent loops)
16
19
  * Screens run four at a time; reruns only visit screens that changed since design/checks.json (or
17
20
  * everything DESIGN.md, the tokens, the components or the catalog changed since).
@@ -22,7 +25,7 @@ import { fileURLToPath } from 'node:url';
22
25
  import { chromium } from '@playwright/test';
23
26
  import { fingerprintPaths } from './cache.js';
24
27
  import { colourIssues } from './colours.js';
25
- import { CONFIG_FILE, checkWidths, config } from './config.js';
28
+ import { CONFIG_FILE, checkWidths, config, modes } from './config.js';
26
29
  import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
27
30
  import { designThemes, motionDurations, typeLevels } from './design-md.js';
28
31
  import { checkLocales } from './i18n.js';
@@ -40,6 +43,7 @@ const filters = args.filter((a, i) => !a.startsWith('--') && (localesFlag < 0 ||
40
43
  const screens = listScreens().filter((s) => !filters.length || filters.some((f) => s.path.includes(f)));
41
44
  const levels = typeLevels();
42
45
  const themes = designThemes();
46
+ const appModes = modes();
43
47
  const collect = collectOptions();
44
48
  /** What the layout audit needs: the comfortable tap size and DESIGN.md's motion durations. */
45
49
  const auditOpts = { comfortable: config.comfortableTarget, durations: (() => { try {
@@ -134,6 +138,23 @@ const checkOne = async (s, worker) => {
134
138
  }
135
139
  if (themes.length > 1)
136
140
  await setTheme(page, base);
141
+ // The app states: each value the screen is not drawn in, one mode at a time, then the screen's own value again.
142
+ for (const mode of appModes) {
143
+ const drawn = await page.evaluate((attribute) => document.documentElement.getAttribute(attribute), mode.attribute);
144
+ for (const value of mode.values.filter((v) => v.value !== drawn)) {
145
+ await setAttribute(page, mode.attribute, value.value);
146
+ for (const i of await page.evaluate(audit, auditOpts)) {
147
+ const key = `${i.type} ${i.selector}`;
148
+ if (seen.has(key))
149
+ continue;
150
+ seen.add(key);
151
+ themed.push({ ...i, ...(i.type === 'contrast' ? { theme: base } : {}), mode: `${mode.name}:${value.value}`, message: `${value.label}: ${i.message}` });
152
+ }
153
+ if (shots)
154
+ await shoot(page, s, undefined, undefined, `${mode.name}-${value.value}`);
155
+ }
156
+ await setAttribute(page, mode.attribute, drawn);
157
+ }
137
158
  const typed = typeIssues(levels, await page.evaluate(collectText));
138
159
  if (shots)
139
160
  await shoot(page, s);
@@ -251,7 +272,16 @@ async function setTheme(page, theme) {
251
272
  }, theme);
252
273
  await page.waitForTimeout(150);
253
274
  }
254
- /** A page-tall render; renders in another theme or language than the screen's own get a --<theme> suffix. */
275
+ async function setAttribute(page, attribute, value) {
276
+ await page.evaluate(([name, to]) => {
277
+ if (to === null)
278
+ document.documentElement.removeAttribute(name);
279
+ else
280
+ document.documentElement.setAttribute(name, to);
281
+ }, [attribute, value]);
282
+ await page.waitForTimeout(150);
283
+ }
284
+ /** A page-tall render; renders in another theme, app state or language than the screen's own get a --<name> suffix. */
255
285
  async function shoot(page, s, theme, locale, at) {
256
286
  const height = await page.evaluate(() => document.documentElement.scrollHeight);
257
287
  await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, MAX_RENDER_HEIGHT)) });
package/dist/config.d.ts CHANGED
@@ -1,3 +1,20 @@
1
+ /**
2
+ * An app state every screen can be shown in, picked from the canvas sidebar: signed in or out, a plan, a role. The pick
3
+ * is an attribute on each screen's <html> (data-auth="user"), so the screens' own CSS and scripts answer to it.
4
+ */
5
+ export interface ModeConfig {
6
+ /** The id: lowercase letters, digits and hyphens. The canvas remembers the pick under it. */
7
+ name: string;
8
+ /** Shown before each value in the dropdown; defaults to the name, capitalized. */
9
+ label?: string;
10
+ /** The attribute on the screen's <html> that carries the value; defaults to data-<name>. */
11
+ attribute?: string;
12
+ /** What the canvas can show every screen as: a value, or a value with its label. "As drawn" is always offered too. */
13
+ values: (string | {
14
+ value: string;
15
+ label?: string;
16
+ })[];
17
+ }
1
18
  export interface LabConfig {
2
19
  /** Shown on the canvas. */
3
20
  name: string;
@@ -41,6 +58,12 @@ export interface LabConfig {
41
58
  skills: string[];
42
59
  /** Paths under designDir a sandbox leaves out besides screens (notes that would give the trial away). */
43
60
  sandboxExclude?: string[];
61
+ /**
62
+ * App states shown across all screens (see ModeConfig). The canvas has a dropdown for each, with the screens as drawn
63
+ * first; picking a value sets its attribute on every screen, live ones and the pictures of the rest. stitch2 check also
64
+ * runs each screen in every value its file does not already have, one mode at a time.
65
+ */
66
+ modes?: ModeConfig[];
44
67
  /** The live app, for stitch2 compare (see compare.ts): its URL, a sign-in script and a route per screen. */
45
68
  app?: {
46
69
  url: string;
@@ -91,5 +114,21 @@ export declare const breakpoints: () => Record<string, number>;
91
114
  * rule starts (each one is the narrowest window of its layout, where things are most likely to break).
92
115
  */
93
116
  export declare function checkWidths(device: string): number[];
117
+ export interface Mode {
118
+ name: string;
119
+ label: string;
120
+ attribute: string;
121
+ values: {
122
+ value: string;
123
+ label: string;
124
+ }[];
125
+ }
126
+ /** The configured modes, checked: a name or attribute that isn't plain, a repeat, or a mode without values is left out. */
127
+ export declare function modes(): Mode[];
128
+ /**
129
+ * The attributes a request asks for, from its "name:value" pairs; a pair that isn't a configured mode and value is
130
+ * ignored, so an address can only set what the config offers.
131
+ */
132
+ export declare function modeAttributes(pairs: string[]): Record<string, string>;
94
133
  /** The design folder, with a trailing slash. */
95
134
  export declare const ROOT: string;
package/dist/config.js CHANGED
@@ -51,5 +51,43 @@ export function checkWidths(device) {
51
51
  const inside = Object.values(breakpoints()).filter((b) => b > range[0] && b < range[1]);
52
52
  return [...new Set([range[0], ...inside, range[1]])].sort((a, b) => a - b);
53
53
  }
54
+ const MODE_NAME = /^[a-z][a-z0-9-]*$/;
55
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
56
+ /** The configured modes, checked: a name or attribute that isn't plain, a repeat, or a mode without values is left out. */
57
+ export function modes() {
58
+ const out = [];
59
+ for (const m of Array.isArray(config.modes) ? config.modes : []) {
60
+ if (typeof m?.name !== 'string' || !MODE_NAME.test(m.name) || out.some((o) => o.name === m.name))
61
+ continue;
62
+ const attribute = m.attribute ?? `data-${m.name}`;
63
+ if (!MODE_NAME.test(attribute))
64
+ continue;
65
+ const values = [];
66
+ for (const v of Array.isArray(m.values) ? m.values : []) {
67
+ const value = typeof v === 'string' ? v : v?.value;
68
+ if (typeof value !== 'string' || !value || /[\s"'<>=&:]/.test(value) || values.some((x) => x.value === value))
69
+ continue;
70
+ values.push({ value, label: (typeof v === 'object' && v.label) || capitalize(value) });
71
+ }
72
+ if (values.length)
73
+ out.push({ name: m.name, label: m.label ?? capitalize(m.name), attribute, values });
74
+ }
75
+ return out;
76
+ }
77
+ /**
78
+ * The attributes a request asks for, from its "name:value" pairs; a pair that isn't a configured mode and value is
79
+ * ignored, so an address can only set what the config offers.
80
+ */
81
+ export function modeAttributes(pairs) {
82
+ const picked = {};
83
+ for (const pair of pairs) {
84
+ const at = pair.indexOf(':');
85
+ const mode = modes().find((m) => m.name === pair.slice(0, at));
86
+ const value = pair.slice(at + 1);
87
+ if (at > 0 && mode?.values.some((v) => v.value === value))
88
+ picked[mode.attribute] = value;
89
+ }
90
+ return picked;
91
+ }
54
92
  /** The design folder, with a trailing slash. */
55
93
  export const ROOT = join(resolve(BASE, config.designDir), '/');
package/dist/serve.js CHANGED
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync, statSync, watch } from 'node:fs'
9
9
  import { createServer } from 'node:http';
10
10
  import { dirname, extname, join, normalize } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { breakpoints, CONFIG_FILE, checkWidths, config, widthRanges } from './config.js';
12
+ import { breakpoints, CONFIG_FILE, checkWidths, config, modeAttributes, modes, widthRanges } from './config.js';
13
13
  import { designThemes } from './design-md.js';
14
14
  import { allLocales, localeScript, sourceLocale } from './i18n.js';
15
15
  import { designPage } from './design-page.js';
@@ -156,7 +156,7 @@ export function startServer(port) {
156
156
  return;
157
157
  }
158
158
  if (url.pathname === '/api/config')
159
- return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale(), widths: widthRanges(), breakpoints: breakpoints(), checkWidths: Object.fromEntries(Object.keys(widthRanges()).map((d) => [d, checkWidths(d)])) });
159
+ return json({ ...config, modes: modes(), themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale(), widths: widthRanges(), breakpoints: breakpoints(), checkWidths: Object.fromEntries(Object.keys(widthRanges()).map((d) => [d, checkWidths(d)])) });
160
160
  if (url.pathname === '/api/screens')
161
161
  return json(listScreens());
162
162
  if (url.pathname === '/api/reports')
@@ -169,6 +169,7 @@ export function startServer(port) {
169
169
  width: Math.min(4000, Math.max(200, Number(q.get('width')) || 390)),
170
170
  theme: q.get('theme') || undefined,
171
171
  lang: q.get('lang') || undefined,
172
+ modes: modeAttributes(q.getAll('mode')),
172
173
  });
173
174
  if (!job) {
174
175
  res.writeHead(404, { 'content-type': 'text/plain' });
package/dist/shots.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface ShotRequest {
3
3
  width: number;
4
4
  theme?: string;
5
5
  lang?: string;
6
+ /** Attributes set on the page's <html> before the picture: the app states picked in the canvas (config modes). */
7
+ modes?: Record<string, string>;
6
8
  }
7
9
  /** The server's file watcher calls this: the next picture looks at the shared files again. */
8
10
  export declare function sharedChanged(): void;
package/dist/shots.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * components), so a canvas with a hundred of them stalls the browser; the canvas keeps only the screens in view live
4
4
  * and draws the rest from these. One headless Chromium renders them, three at a time, and they are kept on disk
5
5
  * (os.tmpdir()/stitch2-shots), keyed by everything that changes the picture: the screen file, the files screens
6
- * share (tokens, components, stylesheets), the theme, the language and its messages, and the width.
6
+ * share (tokens, components, stylesheets), the theme, the modes, the language and its messages, and the width.
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -53,6 +53,7 @@ function keyOf(r) {
53
53
  statSync(file).mtimeMs,
54
54
  sharedStamp(),
55
55
  r.theme ?? '',
56
+ JSON.stringify(Object.entries(r.modes ?? {}).sort(([a], [b]) => a.localeCompare(b))),
56
57
  r.lang ?? '',
57
58
  messages && existsSync(messages) ? statSync(messages).mtimeMs : '',
58
59
  r.width,
@@ -98,6 +99,10 @@ export function shot(base, r) {
98
99
  await page.goto(`${base}/${r.path}?${query}`, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
99
100
  if (r.theme)
100
101
  await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, r.theme);
102
+ const attributes = Object.entries(r.modes ?? {});
103
+ if (attributes.length)
104
+ await page.evaluate((list) => { for (const [name, value] of list)
105
+ document.documentElement.setAttribute(name, value); }, attributes);
101
106
  await page.evaluate(() => document.fonts.ready);
102
107
  // Tailwind's browser build and the components settle after load.
103
108
  await page.waitForTimeout(400);
@@ -15,6 +15,8 @@ export interface Issue {
15
15
  locale?: string;
16
16
  /** Set when the issue shows only at another window width: the edge of its variant's range (config widths). */
17
17
  width?: number;
18
+ /** Set when the issue shows only in another app state (config modes): "name:value", as the canvas sidebar names it. */
19
+ mode?: string;
18
20
  }
19
21
  export interface TextItem {
20
22
  selector: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.22.12",
3
+ "version": "0.24.0",
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",
@@ -77,6 +77,8 @@ References, read before the first screen:
77
77
  An issue tagged with a width ("at 1024px: …") breaks at an edge of the windows the variant covers: fix it with a
78
78
  responsive rule in the screen, or a new variant when the layout's structure has to change (screens.md, Widths).
79
79
  Widths include the breakpoints inside the range; `narrow-layout` is a phone column left in a wide window.
80
+ An issue tagged with an app state ("Signed in: …"; `modes` in the config, screens.md) shows only in that state: fix it in
81
+ the screen's rules for that state, and look at its render (`--auth-user.png`). Clicking it on the canvas shows the state.
80
82
  Overlap checks compare the drawn fragments of wrapped inline controls, not the empty corners of their
81
83
  bounding boxes. For fixed bars, the browser's paint order decides coverage: a menu painted in front of a
82
84
  header is not hidden by it. Native modal dialogs and ARIA modals limit coverage and control comparisons
@@ -127,7 +129,7 @@ References, read before the first screen:
127
129
  |---|---|
128
130
  | `setup [--name N --primary #hex] [options]` | Sets a project up in one step; keeps every file that exists |
129
131
  | `canvas` | The canvas at http://localhost:4400: screens and versions, DESIGN.md, the consistency report, approval and notes |
130
- | `check [filter] [--shots] [--strict] [--locales l,…]` | Layout, type, reuse and consistency checks, in every theme and check language, and at both ends of each variant's widths; `--shots` saves renders |
132
+ | `check [filter] [--shots] [--strict] [--locales l,…]` | Layout, type, reuse and consistency checks, in every theme, app state and check language, and at both ends of each variant's widths; `--shots` saves renders |
131
133
  | `i18n [filter] [--locale l] [--suggest]` | Message keys screens use that the source or a locale lacks, and text with no key (stitch2-i18n) |
132
134
  | `consistency [kind…]` | Every shared component cropped from every screen, next to the catalog's version |
133
135
  | `type [filter] [--json]` | Every text style a screen uses and the type level it matches |
@@ -171,13 +171,33 @@ doesn't cover it is drawn at its nearest edge and faded, because the app shows a
171
171
  - A desktop screen is a layout for its range, not a stretched phone: a side navigation instead of the bar,
172
172
  list-detail with both panes, extra columns for feeds.
173
173
 
174
+ ## App states
175
+
176
+ When the product looks different signed in or out, on another plan or for another role, do not draw a copy of each
177
+ screen per state. Draw it once, let its CSS and scripts answer to an attribute on `<html>`, and list the states as
178
+ `modes` in `stitch2.config.json`:
179
+
180
+ ```json
181
+ "modes": [
182
+ { "name": "auth", "label": "Account", "values": [{ "value": "guest", "label": "Guest" }, { "value": "user", "label": "Signed in" }] }
183
+ ]
184
+ ```
185
+
186
+ The canvas sidebar then has a dropdown for each mode. A value sets the attribute (`data-auth="user"`; `attribute`
187
+ names another) on every screen, live frames and the pictures of the rest, and "As drawn" puts each screen back to what
188
+ its file says. `m` cycles the first mode. A screen is still written for the state it shows (a signed-in account page has
189
+ `data-auth="user"` in its file); modes show the other states of the same screen. `stitch2 check` also runs each screen in every
190
+ value its own file does not have, one mode at a time (modes are not combined), and tags what only shows there with the
191
+ state ("Signed in: …"); clicking such an issue on the canvas shows that state. With `--shots` each state is rendered too
192
+ (`<screen>--auth-user.png`): look at it before hand-over.
193
+
174
194
  ## The canvas
175
195
 
176
196
  `stitch2 canvas` → http://localhost:4400. The sidebar lists DESIGN.md (drawn as tokens and rules), Components
177
197
  (the catalog for the variants the filter shows; a click steps through them), the consistency report and app
178
198
  comparison (each with when it last ran, or how many covered screens changed since), Languages, and every screen
179
- with its versions. Filters: status (active, approved, needs review, all), versions, variants, theme, width and
180
- language. The reports and Languages follow the filters too: they show only the screens the canvas shows. Selecting a version opens the inspector: status, note, checks, and
199
+ with its versions. Filters: status (active, approved, needs review, all), versions, variants, theme, width,
200
+ language and each app state (`modes`). The reports and Languages follow the filters too: they show only the screens the canvas shows. Selecting a version opens the inspector: status, note, checks, and
181
201
  Copy for its reference. Pick mode (`p`) highlights the element under the pointer; a click copies its reference,
182
202
  Shift-click collects several (Copy all, Add to note). Keys: `/` search, `j`/`k` next and previous, `a` approve,
183
- `p` pick, `f` fit, `0` actual size, double-click to use a frame, `Esc` to leave.
203
+ `p` pick, `f` fit, `m` next app state, `0` actual size, double-click to use a frame, `Esc` to leave.