stitch2 0.19.3 → 0.20.1

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
@@ -69,7 +69,9 @@ file that already exists. From there the agent spends its effort on the design.
69
69
  rules for scripts without capitals) and a Tailwind config, from DESIGN.md's front matter, plus Material 3
70
70
  colour roles from a seed colour. **Exports** write the same tokens for your app, so DESIGN.md drives the
71
71
  product as well as the screens: plain CSS variables, a Tailwind v4 theme (`@theme` plus `@utility type-*`), or
72
- W3C Design Tokens JSON for Style Dictionary and native platforms. Aliases keep your app's own variable names:
72
+ W3C Design Tokens JSON for Style Dictionary and native platforms. The CSS and Tailwind exports also set the
73
+ cursor: the hand over every enabled control, not-allowed over a disabled one (Tailwind v4 gives buttons the
74
+ arrow). Aliases keep your app's own variable names:
73
75
 
74
76
  ```json
75
77
  "exports": [
package/dist/audit.js CHANGED
@@ -218,6 +218,14 @@ export async function audit(opts) {
218
218
  window.scrollTo(0, 0);
219
219
  // 4. Tap targets: WCAG 2.2 asks for at least 24px; smaller than the config's comfortableTarget is a warning.
220
220
  const interactive = all.filter((el) => el.matches('button, a[href], input:not([type=hidden]), select, textarea, [role=button], [role=tab], [role=switch], [role=checkbox]'));
221
+ // Fixed and sticky layers float over the scrolling page (a running-workout panel over cards): content slides
222
+ // under them while scrolling, so they never crowd in-flow targets. Tap spacing (6d) already compares layers.
223
+ const layerOf = (el) => {
224
+ for (let e = el; e; e = e.parentElement)
225
+ if (['fixed', 'sticky'].includes(getComputedStyle(e).position))
226
+ return e;
227
+ return null;
228
+ };
221
229
  for (const el of interactive) {
222
230
  if (interactive.some((o) => o !== el && o.contains(el)))
223
231
  continue;
@@ -229,7 +237,7 @@ export async function audit(opts) {
229
237
  const cx = r.left + r.width / 2;
230
238
  const cy = r.top + r.height / 2;
231
239
  const crowded = interactive.some((o) => {
232
- if (o === el || o.contains(el) || el.contains(o))
240
+ if (o === el || o.contains(el) || el.contains(o) || layerOf(o) !== layerOf(el))
233
241
  return false;
234
242
  const q = o.getBoundingClientRect();
235
243
  const dx = Math.max(q.left - cx, 0, cx - q.right);
@@ -250,12 +258,8 @@ export async function audit(opts) {
250
258
  // 4b. Controls never overlap one another (a button squeezed over its neighbour). Controls inside one another
251
259
  // and invisible overlays (a native picker laid over a row) are fine; fixed and sticky layers (a docked timer,
252
260
  // a sticky Save bar floating over a list) are compared apart from the scrolling page.
253
- const layer = (el) => {
254
- for (let e = el; e; e = e.parentElement)
255
- if (['fixed', 'sticky'].includes(getComputedStyle(e).position))
256
- return e;
257
- return null;
258
- };
261
+ // layerOf is defined with the tap targets above; fixed and sticky layers are compared apart here too.
262
+ const layer = layerOf;
259
263
  // The part of a control its scrolling or clipping ancestors show: a link scrolled out of a nav strip is not drawn
260
264
  // where its box would be.
261
265
  const visibleBox = (el) => {
package/dist/exports.d.ts CHANGED
@@ -21,5 +21,10 @@ export interface Tokens {
21
21
  root: Record<string, string>;
22
22
  header: string;
23
23
  }
24
+ /**
25
+ * The cursor over controls, in @layer base so any utility (cursor-grab, cursor-text) or component rule wins.
26
+ * check reports a catalog control that still shows another cursor (state-cursor, states.ts).
27
+ */
28
+ export declare const POINTER: string;
24
29
  /** Writes every export in the config; returns their paths. */
25
30
  export declare function writeExports(t: Tokens, list?: TokenExport[]): string[];
package/dist/exports.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * Motion: ease-<name> classes, duration-<name> utilities, and Tailwind's default transition (the
9
9
  * plain transition, transition-colors…) set to the first duration and ease-standard.
10
10
  * dtcg W3C Design Tokens JSON, for Style Dictionary and other platforms (iOS, Android)
11
+ * css and tailwind4 also carry the pointer rule (POINTER): every enabled control shows the hand cursor, a disabled
12
+ * one shows not-allowed. Tailwind v4's preflight gives buttons the arrow, so without it nothing looks clickable.
11
13
  * "aliases" maps the app's own variable names to DESIGN.md colours ({ "background": "floor" } writes
12
14
  * --background: var(--<prefix>-floor) in each theme), so an existing theme (shadcn's, say) keeps its names.
13
15
  */
@@ -15,6 +17,25 @@ import { mkdirSync, writeFileSync } from 'node:fs';
15
17
  import { dirname, resolve } from 'node:path';
16
18
  import { BASE, config } from './config.js';
17
19
  const p = () => config.prefix;
20
+ /**
21
+ * The cursor over controls, in @layer base so any utility (cursor-grab, cursor-text) or component rule wins.
22
+ * check reports a catalog control that still shows another cursor (state-cursor, states.ts).
23
+ */
24
+ export const POINTER = [
25
+ '/* Every enabled control shows the hand cursor, a disabled one not-allowed (Tailwind v4 gives buttons the arrow). */',
26
+ '@layer base {',
27
+ ' button:not(:disabled), [role="button"]:not([aria-disabled="true"]), [role="switch"]:not([aria-disabled="true"]),',
28
+ ' [role="tab"]:not([aria-disabled="true"]), [role="checkbox"]:not([aria-disabled="true"]),',
29
+ ' [role="radio"]:not([aria-disabled="true"]), [role="option"]:not([aria-disabled="true"]),',
30
+ ' [role="menuitem"]:not([aria-disabled="true"]), summary, select:not(:disabled), label[for],',
31
+ ' input:is([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="submit"], [type="button"], [type="reset"]):not(:disabled) {',
32
+ ' cursor: pointer;',
33
+ ' }',
34
+ ' :disabled, [aria-disabled="true"] {',
35
+ ' cursor: not-allowed;',
36
+ ' }',
37
+ '}',
38
+ ].join('\n');
18
39
  function themeBlocks(t, e) {
19
40
  const dark = e.themes?.dark ?? ":root, [data-theme='dark']";
20
41
  const light = e.themes?.light ?? "[data-theme='light']";
@@ -35,7 +56,7 @@ function themeBlocks(t, e) {
35
56
  }
36
57
  function css(t, e) {
37
58
  const type = Object.entries(t.type).map(([n, d]) => `.type-${n} { ${d.join('; ')}; }`);
38
- return [t.header, ...themeBlocks(t, e), ...type, ''].join('\n\n');
59
+ return [t.header, ...themeBlocks(t, e), ...type, POINTER, ''].join('\n\n');
39
60
  }
40
61
  function tailwind4(t, e) {
41
62
  const families = Object.values(t.design.typography).map((x) => x.fontFamily ?? '');
@@ -52,7 +73,7 @@ function tailwind4(t, e) {
52
73
  const durations = Object.keys(t.design.motion)
53
74
  .filter((k) => k.startsWith('duration-'))
54
75
  .map((k) => `@utility ${k} {\n transition-duration: var(--${p()}-${k});\n animation-duration: var(--${p()}-${k});\n}`);
55
- return [t.header, ...themeBlocks(t, e), `@theme inline {\n${theme.join('\n')}\n}`, ...type, ...durations, ''].join('\n\n');
76
+ return [t.header, ...themeBlocks(t, e), `@theme inline {\n${theme.join('\n')}\n}`, ...type, ...durations, POINTER, ''].join('\n\n');
56
77
  }
57
78
  /** @theme entries for motion: --ease-<name> (Tailwind's ease-* classes) and the default transition. */
58
79
  function motionTheme(motion) {
package/dist/states.js CHANGED
@@ -49,7 +49,8 @@ function findControls(opts) {
49
49
  h.setAttribute('data-s2-state', String(out.length));
50
50
  const field = h.matches('input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]), textarea, select, [contenteditable="true"]');
51
51
  const chosen = ['aria-pressed', 'aria-selected', 'aria-checked'].some((a) => h.getAttribute(a) === 'true') || (h.hasAttribute('aria-current') && h.getAttribute('aria-current') !== 'false');
52
- out.push({ i: out.length, name, label, selector: path(h), field, chosen });
52
+ const clickable = h.matches('button, a[href], summary, select, [role="button"], [role="switch"], [role="tab"], [role="checkbox"], [role="radio"], [role="option"], [role="menuitem"], input:is([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="submit"], [type="button"], [type="reset"])');
53
+ out.push({ i: out.length, name, label, selector: path(h), field, chosen, clickable });
53
54
  }
54
55
  }
55
56
  return out;
@@ -89,7 +90,7 @@ function lookOf(i) {
89
90
  const large = parseFloat(cs.fontSize) >= 24 || (parseFloat(cs.fontSize) >= 18.66 && Number(cs.fontWeight) >= 700);
90
91
  worst = Math.min(worst, ratio / (large ? 3 : 4.5));
91
92
  }
92
- return { look, contrast: worst };
93
+ return { look, contrast: worst, cursor: getComputedStyle(el).cursor };
93
94
  }
94
95
  /**
95
96
  * The states of every control in the catalog open in the page, in the current theme. With crop, saves a picture
@@ -147,6 +148,10 @@ export async function catalogStates(page, path, opts) {
147
148
  else if (noPress)
148
149
  add('state-pressed', who + ': pressing adds nothing to hover, so a tap gives no feedback (DESIGN.md, pressed)');
149
150
  }
151
+ // The hand says "this does something" before the click; the arrow over a button reads as plain text.
152
+ const cursor = looks.hover?.cursor;
153
+ if (c.clickable && cursor && cursor !== 'pointer')
154
+ add('state-cursor', who + ': the pointer shows "' + cursor + '" over it, not the hand, so it doesn\'t look clickable (stitch2 tokens writes the rule; a component overrides it)');
150
155
  for (const state of ['hover', 'focus', 'pressed'])
151
156
  if ((looks[state]?.contrast ?? 21) < 1 && (looks.rest?.contrast ?? 21) >= 1) {
152
157
  add('state-contrast', who + ': low text contrast when ' + (state === 'focus' ? 'focused' : state === 'hover' ? 'hovered' : 'pressed'));
package/dist/tokens.js CHANGED
@@ -11,7 +11,7 @@ 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
13
  import { readDesign } from './design-md.js';
14
- import { writeExports } from './exports.js';
14
+ import { POINTER, writeExports } from './exports.js';
15
15
  import { ROOT } from './screens.js';
16
16
  const SCHEMES = {
17
17
  fidelity: SchemeFidelity,
@@ -98,6 +98,7 @@ writeFileSync(join(ROOT, 'tokens.css'), [
98
98
  : []),
99
99
  '/* Typography levels: one class sets family, size, line height, weight, tracking and figures. */',
100
100
  typeCss.join('\n'),
101
+ POINTER,
101
102
  '',
102
103
  ].join('\n\n'));
103
104
  const colors = Object.fromEntries(Object.keys(dark).map((k) => [k, `var(--${lab.prefix}-${k})`]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.19.3",
3
+ "version": "0.20.1",
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",
@@ -35,14 +35,20 @@ Every interactive component has its states, defined once in the component and de
35
35
  | Focus (keyboard) | a ring, never removed without a replacement |
36
36
  | Selected / current | shown by the accent or a raised surface; hover and press may stay as they are |
37
37
  | Disabled | faint, no hover or press change, the reason nearby |
38
+ | Cursor (pointer only) | the hand over everything that acts when clicked (buttons, links, chips, rows that open something, switches, selects); not-allowed over a disabled control; the text cursor only in fields |
39
+
40
+ The cursor comes from one rule, not from each component: `stitch2 tokens` writes it into tokens.css and every
41
+ `css` and `tailwind4` export, in `@layer base`, because Tailwind v4's preflight gives buttons the arrow. A
42
+ clickable element that is not a button or a link gets `role="button"` (and a keyboard handler), which also gives
43
+ it the hand. Override the cursor only with a reason (`cursor-grab` on a drag handle).
38
44
 
39
45
  The catalog shows each component in each of its **data states** (selected, done, active, disabled, error, with
40
46
  and without an icon). Hover, focus and pressed are not drawn by hand: `stitch2 check` on the catalog forces them on
41
47
  every control and writes the **States** sheet (canvas sidebar), each control at rest, hovered, focused and
42
48
  pressed side by side. It reports a control that gives no feedback (**state-feedback**, **state-hover**,
43
49
  **state-pressed**: pressing adds nothing to hover, so a tap shows nothing) and text that loses contrast in a state
44
- (**state-contrast**). Fix them in the component, once. Selected and current controls and text fields are exempt
45
- from the feedback checks.
50
+ (**state-contrast**), and a clickable control the pointer doesn't show as clickable (**state-cursor**). Fix them
51
+ in the component, once. Selected and current controls and text fields are exempt from the feedback checks.
46
52
 
47
53
  ## Components from the app
48
54