@ethlete/cli 3.0.0-next.23 → 3.0.0-next.24

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.
Files changed (140) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/design/contrast.ts +49 -0
  3. package/design/define-call.ts +5 -8
  4. package/design/define-part.ts +63 -1
  5. package/design/morph.ts +67 -0
  6. package/design/motion-runtime.ts +200 -0
  7. package/design/web/env.d.ts +6 -2
  8. package/design/web/frame.ts +39 -1
  9. package/package.json +2 -1
  10. package/src/lib/design/changed-parts.d.ts +4 -4
  11. package/src/lib/design/changed-parts.js +10 -6
  12. package/src/lib/design/changed-parts.js.map +1 -1
  13. package/src/lib/design/contact-sheet.d.ts +26 -13
  14. package/src/lib/design/contact-sheet.js +122 -40
  15. package/src/lib/design/contact-sheet.js.map +1 -1
  16. package/src/lib/design/design-command.js +20 -2
  17. package/src/lib/design/design-command.js.map +1 -1
  18. package/src/lib/design/generated-stylesheets.d.ts +15 -0
  19. package/src/lib/design/generated-stylesheets.js +52 -0
  20. package/src/lib/design/generated-stylesheets.js.map +1 -0
  21. package/src/lib/design/inbox-command.d.ts +1 -0
  22. package/src/lib/design/inbox-command.js +15 -0
  23. package/src/lib/design/inbox-command.js.map +1 -1
  24. package/src/lib/design/merge-compare.d.ts +10 -0
  25. package/src/lib/design/merge-compare.js +17 -4
  26. package/src/lib/design/merge-compare.js.map +1 -1
  27. package/src/lib/design/paths.d.ts +11 -0
  28. package/src/lib/design/paths.js.map +1 -1
  29. package/src/lib/design/serve.js +74 -18
  30. package/src/lib/design/serve.js.map +1 -1
  31. package/src/lib/design/shot-command.d.ts +4 -4
  32. package/src/lib/design/shot-command.js +48 -40
  33. package/src/lib/design/shot-command.js.map +1 -1
  34. package/src/lib/design/shot.d.ts +14 -4
  35. package/src/lib/design/shot.js +71 -50
  36. package/src/lib/design/shot.js.map +1 -1
  37. package/src/lib/design/sketch-command.js +55 -24
  38. package/src/lib/design/sketch-command.js.map +1 -1
  39. package/src/lib/design/sketch-stylesheets.d.ts +7 -0
  40. package/src/lib/design/sketch-stylesheets.js +63 -0
  41. package/src/lib/design/sketch-stylesheets.js.map +1 -0
  42. package/src/lib/design/studio-api.d.ts +9 -6
  43. package/src/lib/design/studio-api.js +50 -16
  44. package/src/lib/design/studio-api.js.map +1 -1
  45. package/src/lib/design/system/check-system.js +8 -0
  46. package/src/lib/design/system/check-system.js.map +1 -1
  47. package/src/lib/design/system/comments/comment.d.ts +17 -0
  48. package/src/lib/design/system/comments/comment.js +35 -1
  49. package/src/lib/design/system/comments/comment.js.map +1 -1
  50. package/src/lib/design/system/comments/complete.js +4 -2
  51. package/src/lib/design/system/comments/complete.js.map +1 -1
  52. package/src/lib/design/system/complete-command.d.ts +9 -0
  53. package/src/lib/design/system/complete-command.js +64 -0
  54. package/src/lib/design/system/complete-command.js.map +1 -0
  55. package/src/lib/design/system/complete.d.ts +73 -0
  56. package/src/lib/design/system/complete.js +655 -0
  57. package/src/lib/design/system/complete.js.map +1 -0
  58. package/src/lib/design/system/contrast-check.d.ts +26 -0
  59. package/src/lib/design/system/contrast-check.js +153 -0
  60. package/src/lib/design/system/contrast-check.js.map +1 -0
  61. package/src/lib/design/system/imports.d.ts +28 -3
  62. package/src/lib/design/system/imports.js +84 -7
  63. package/src/lib/design/system/imports.js.map +1 -1
  64. package/src/lib/design/system/list-system.d.ts +11 -0
  65. package/src/lib/design/system/list-system.js +17 -1
  66. package/src/lib/design/system/list-system.js.map +1 -1
  67. package/src/lib/design/system/product-stylesheets.d.ts +3 -0
  68. package/src/lib/design/system/product-stylesheets.js +40 -0
  69. package/src/lib/design/system/product-stylesheets.js.map +1 -0
  70. package/src/lib/design/system/render-check.js +11 -3
  71. package/src/lib/design/system/render-check.js.map +1 -1
  72. package/src/lib/design/system/rules/canonical-class.d.ts +9 -0
  73. package/src/lib/design/system/rules/canonical-class.js +132 -0
  74. package/src/lib/design/system/rules/canonical-class.js.map +1 -0
  75. package/src/lib/design/system/rules/inline-svg.d.ts +7 -0
  76. package/src/lib/design/system/rules/inline-svg.js +28 -0
  77. package/src/lib/design/system/rules/inline-svg.js.map +1 -0
  78. package/src/lib/design/system/rules/motion.d.ts +4 -0
  79. package/src/lib/design/system/rules/motion.js +46 -2
  80. package/src/lib/design/system/rules/motion.js.map +1 -1
  81. package/src/lib/design/system/rules/utility-css.d.ts +11 -0
  82. package/src/lib/design/system/rules/utility-css.js +290 -0
  83. package/src/lib/design/system/rules/utility-css.js.map +1 -0
  84. package/src/lib/design/system/sketch/baseline.d.ts +3 -2
  85. package/src/lib/design/system/sketch/baseline.js +10 -4
  86. package/src/lib/design/system/sketch/baseline.js.map +1 -1
  87. package/src/lib/design/system/sketch/merge-preview.d.ts +4 -2
  88. package/src/lib/design/system/sketch/merge-preview.js +20 -3
  89. package/src/lib/design/system/sketch/merge-preview.js.map +1 -1
  90. package/src/lib/design/system/sketch/merge-shared.d.ts +42 -6
  91. package/src/lib/design/system/sketch/merge-shared.js +122 -24
  92. package/src/lib/design/system/sketch/merge-shared.js.map +1 -1
  93. package/src/lib/design/system/sketch/merge.d.ts +3 -3
  94. package/src/lib/design/system/sketch/merge.js +54 -29
  95. package/src/lib/design/system/sketch/merge.js.map +1 -1
  96. package/src/lib/design/system/sketch/merged-parts.d.ts +7 -0
  97. package/src/lib/design/system/sketch/merged-parts.js +13 -1
  98. package/src/lib/design/system/sketch/merged-parts.js.map +1 -1
  99. package/src/lib/design/system/sketch/no-visible-change.d.ts +4 -3
  100. package/src/lib/design/system/sketch/no-visible-change.js +50 -19
  101. package/src/lib/design/system/sketch/no-visible-change.js.map +1 -1
  102. package/src/lib/design/system/sketch/overlay.js +7 -2
  103. package/src/lib/design/system/sketch/overlay.js.map +1 -1
  104. package/src/lib/design/system/sketch/part-definition.d.ts +12 -0
  105. package/src/lib/design/system/sketch/part-definition.js +40 -1
  106. package/src/lib/design/system/sketch/part-definition.js.map +1 -1
  107. package/src/lib/design/system/sketch/sketch.d.ts +8 -3
  108. package/src/lib/design/system/sketch/sketch.js +76 -34
  109. package/src/lib/design/system/sketch/sketch.js.map +1 -1
  110. package/src/lib/design/system/sketch/unchanged-states.d.ts +10 -4
  111. package/src/lib/design/system/sketch/unchanged-states.js +37 -23
  112. package/src/lib/design/system/sketch/unchanged-states.js.map +1 -1
  113. package/src/lib/design/system/system-modules.d.ts +5 -1
  114. package/src/lib/design/system/system-modules.js +26 -8
  115. package/src/lib/design/system/system-modules.js.map +1 -1
  116. package/src/lib/design/system/system-tsconfig.js +2 -1
  117. package/src/lib/design/system/system-tsconfig.js.map +1 -1
  118. package/src/lib/design/thumb.d.ts +6 -2
  119. package/src/lib/design/thumb.js +36 -15
  120. package/src/lib/design/thumb.js.map +1 -1
  121. package/src/lib/design/variant-diff.d.ts +12 -10
  122. package/src/lib/design/variant-diff.js +14 -13
  123. package/src/lib/design/variant-diff.js.map +1 -1
  124. package/src/lib/design/warm-pages.d.ts +20 -0
  125. package/src/lib/design/warm-pages.js +78 -0
  126. package/src/lib/design/warm-pages.js.map +1 -0
  127. package/src/lib/update/commits.d.ts +9 -0
  128. package/src/lib/update/commits.js +15 -1
  129. package/src/lib/update/commits.js.map +1 -1
  130. package/src/lib/update/migration-manifest.d.ts +6 -0
  131. package/src/lib/update/migration-manifest.js +7 -0
  132. package/src/lib/update/migration-manifest.js.map +1 -1
  133. package/src/lib/update/migrations-command.js +1 -0
  134. package/src/lib/update/migrations-command.js.map +1 -1
  135. package/src/lib/update/run-migrations.d.ts +22 -0
  136. package/src/lib/update/run-migrations.js +60 -1
  137. package/src/lib/update/run-migrations.js.map +1 -1
  138. package/src/lib/update/update-command.d.ts +10 -0
  139. package/src/lib/update/update-command.js +28 -2
  140. package/src/lib/update/update-command.js.map +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # @ethlete/cli
2
2
 
3
+ ## 3.0.0-next.24
4
+
5
+ ### Minor Changes
6
+
7
+ - A sketch's changed area gives one box per cluster of changed pixels; `et design shot --crop changed` writes one sheet per cluster, and Studio shows one enlarged crop per cluster.
8
+ - `et design check` now fails with `CANONICAL-CLASS` when a Tailwind class in a design part has a shorter canonical form, for example `w-[420px]` that can be written as `w-2xl`.
9
+ - `et design sketch merge` takes `--variant <key> --allowed-by <plan ref>`: it merges a sketch with no pick when that variant is its only live one, and logs the plan in `accepted.json`.
10
+ - `et update` adds the packages a codemod requires, such as `ts-morph`, as dev dependencies before it runs the codemod, in a commit of its own. A migration declares them in its new `requires` field.
11
+ - A design product can list stylesheets a command generates from a part (`generated`). A frame of a sketch variant that changes that part runs the command against the variant's part and loads its output.
12
+ - `et design check` now fails a literal duration, delay or easing in a transition or animation outside `foundations/`, with `HARD-CODED-MOTION`.
13
+ - `et design check` now fails an inline `<svg>` outside `foundations/`, with `INLINE-SVG`. A computed drawing passes with `data-et-drawing`.
14
+ - `et design sketch merge` and `sketch discard` now resolve the addressed comments of the sketch, and a merge those of its parts without a sketch. New `et design comment resolve-ended` resolves the addressed comments of ended sketches.
15
+ - `et design complete <layer>/<name>` fails while a part's declared `controls` lack drawn, reviewed evidence; it checks the `disclosure` kind first.
16
+ - `et design complete` now discovers drawn controls: an unmarked native, ARIA or SDK control fails as `CONTROL-UNDECLARED`, a pointer or `:hover` element outside a control as `CONTROL-UNCLASSIFIED`, and `CONTROLS-MISSING` is gone.
17
+ - `et design complete` now fails a control whose part draws its interactions as flat states instead of an `interactionRows` grid.
18
+ - A page control in `definePart({ controls })` names the part that draws it in `part`; `et design complete` takes its interactions from that part and fails while that part is not design complete.
19
+ - `et design complete` now requires a state that forces `active` on a control with `pointer` input, next to the state that forces `hover`.
20
+ - `et design complete` now fails a control whose forced interaction changes a colour, background, border or shadow without a transition.
21
+ - `et design check` fails with `CONTRAST` when drawn text is below WCAG 2 AA against its composited background. Parts declare token `pairs` to compute and `exceptions` with a reason; `contrastRatioOf` draws a ratio table.
22
+ - A part's rules now run in its design frame. A switch morphs the existing DOM, so CSS transitions run, then plays the motion the rule names, with a controlled clock, seeded random and `from`-state measurements.
23
+ - A design part can declare `motions` between its states, and a rule can name one. `et design check` fails on a motion between unknown states, a rule naming an undeclared motion, and `setTimeout` in motion code.
24
+ - `et design check` now fails with `UTILITY-CSS` when a design part's CSS holds a declaration that a Tailwind utility of the product draws, for example `display: flex`.
25
+ - `et design`: a product's `shared/` folder holds helper modules that are no part; any part imports them as `@shared/<path>`, and editing one marks each importing part changed.
26
+ - The design server lists per sketch variant the states it adds and drops and its changed part note. Studio says on the canvas when a variant changes no pixel, and a click shows the old and new note.
27
+ - `et design sketch new` takes several parts: one sketch, one variant folder holding them all, one `ready`, one merge; `ready` and `preview` name every part a `shared/` edit reaches; Studio shows every target part.
28
+
29
+ ### Patch Changes
30
+
31
+ - `et update --continue` no longer commits what you changed after the stopped run, such as a package you installed by hand, into its own commits.
32
+ - `et design check` no longer reports contrast for the label of a disabled control: a `label[for]`, a wrapping label, or an `aria-labelledby` target.
33
+ - The design server's Studio event stream now also sends `calls` and `sketches` when a call or sketch file changes, and a `parts` event for a design system change that redraws no frame.
34
+ - The design server draws up to four pictures at once instead of one, so cold shots, sketch lists and merge views render about three times faster. High-priority drawings still start before waiting low ones.
35
+ - `et design` draws with partial raster off, so two draws of a page with embedded frames give the same picture under load, and `sketch ready` gives the same answer on a re-run.
36
+ - The design auto-merge check now skips rejected variants, so only live variants can block a merge that is safe.
37
+ - `et design` now fails with the frame URL when a frame or an embedded frame never draws, instead of saving a blank picture. `sketch ready` draws a changed state once more before it counts as a change.
38
+ - Drawn design frames no longer show a real hover on a control at the frame's top-left corner, so `design shot` and `design complete` see only the interactions a state forces.
39
+ - `et design` `CANONICAL-CLASS` and `UTILITY-CSS` now check the files of a `shared/` folder that a part imports, also in a sketch, so `sketch check` and `sketch ready` catch them before a merge.
40
+ - Design screenshots now show the end of every finite CSS animation and transition, so an element that fades in with `@starting-style` is drawn fully visible.
41
+ - The design server draws each shot on a warm browser page per viewport and scale instead of opening a new browser context, so a warm shot is about 12 % faster and a batch uses about 20 % less CPU.
42
+ - The `et design` server now keeps the pixel compare of each sketch's unchanged states until a product file changes, so the sketch list loads in a fraction of the time after the first call.
43
+ - `et design` redraws only the affected parts, not every part, when a file that a product stylesheet scans as a Tailwind `@source` changes.
44
+ - `et design inbox done` now writes the note into the sketch variant for a comment on a part that exists only in the sketch, instead of failing with "is no part of".
45
+ - A multi-target sketch merge gives each target only the `shared/` files it imports, directly or transitively; an unimported file returns to the target that held it. `sketch new` refuses targets whose `shared/` files clash.
46
+ - `et design sketch ready` now also draws the parts that show the sketched part before it merges on its own. When one of them changes, the sketch stays ready and the output names the part and state.
47
+ - A sketch with several targets shows the diff marks and the pages that use any target part. Studio names the parts a `shared/` edit reaches. The sketch-diff endpoint takes `part=`.
48
+ - `et design` draws a sketch variant with its own part stylesheet where the product stylesheet `@import`s the merged one, so `sketch ready` no longer auto-merges a token value change as "no visible change".
49
+ - A cold sketch shows its first frame sooner: Studio lists the sketches without the slow unchanged-state comparison first (`?unchanged=no`), asks for shots and diffs at once, then lists them in full.
50
+
3
51
  ## 3.0.0-next.23
4
52
 
5
53
  ### Minor Changes
@@ -0,0 +1,49 @@
1
+ type Rgba = [number, number, number, number];
2
+
3
+ const rgbaIn = (probe: HTMLElement, value: string): Rgba => {
4
+ probe.style.color = `color-mix(in srgb, ${value} 100%, transparent)`;
5
+
6
+ const computed = getComputedStyle(probe).color;
7
+ const numbers = [...computed.matchAll(/-?[\d.]+(?:e-?\d+)?/g)].map((match) => parseFloat(match[0]));
8
+ const scale = computed.startsWith('color(') ? 1 : 255;
9
+ const [r = 0, g = 0, b = 0] = numbers.map((channel) => Math.min(1, Math.max(0, channel / scale)));
10
+
11
+ return [r, g, b, numbers[3] ?? 1];
12
+ };
13
+
14
+ const over = (top: Rgba, under: Rgba): Rgba => [
15
+ top[0] * top[3] + under[0] * (1 - top[3]),
16
+ top[1] * top[3] + under[1] * (1 - top[3]),
17
+ top[2] * top[3] + under[2] * (1 - top[3]),
18
+ 1,
19
+ ];
20
+
21
+ const luminanceOf = ([r, g, b]: Rgba) => {
22
+ const linear = (channel: number) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
23
+
24
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
25
+ };
26
+
27
+ /**
28
+ * The WCAG 2 contrast ratio of the colour `text` on the colour `on`, both CSS colours resolved inside `within`
29
+ * (`document.body` by default), so `var(--token)` works. A translucent `on` is laid over the background of
30
+ * `document.body`. For a ratio table in a drawing's `setup`: `contrastRatioOf({ text: 'var(--text)', on: 'var(--surface)', within: root })`.
31
+ */
32
+ export const contrastRatioOf = (options: { text: string; on: string; within?: Element }) => {
33
+ const probe = document.createElement('i');
34
+
35
+ probe.style.display = 'none';
36
+ (options.within ?? document.body).append(probe);
37
+
38
+ try {
39
+ const page = over(rgbaIn(probe, getComputedStyle(document.body).backgroundColor), [1, 1, 1, 1]);
40
+ const behind = over(rgbaIn(probe, options.on), page);
41
+ const [light, dark] = [luminanceOf(over(rgbaIn(probe, options.text), behind)), luminanceOf(behind)].sort(
42
+ (a, b) => b - a,
43
+ );
44
+
45
+ return ((light ?? 0) + 0.05) / ((dark ?? 0) + 0.05);
46
+ } finally {
47
+ probe.remove();
48
+ }
49
+ };
@@ -1,3 +1,5 @@
1
+ import { morph } from './morph';
2
+
1
3
  /**
2
4
  * A value inside a template. An array joins with nothing between, and `null`, `undefined` and
3
5
  * `false` write nothing, so `${rows.map(row)}` and `${isOn && html`…`}` both read as they look.
@@ -81,7 +83,7 @@ export type StateRule<TState extends string> = { click: string; go: TState };
81
83
 
82
84
  /**
83
85
  * Swaps the content of the drawing's state host for `states[rule.go]` when a click matches a rule.
84
- * The first matching rule wins. The swap runs in a View Transition where the browser has one.
86
+ * The first matching rule wins. The swap morphs the existing DOM, so CSS transitions run.
85
87
  */
86
88
  export const switchStates =
87
89
  <TState extends string>(states: Record<TState, string>, rules: StateRule<TState>[]) =>
@@ -100,13 +102,8 @@ export const switchStates =
100
102
 
101
103
  if (!rule) return;
102
104
 
103
- const swap = () => {
104
- host.innerHTML = states[rule.go];
105
- host.dataset['deStates'] = rule.go;
106
- };
107
-
108
- if (document.startViewTransition) document.startViewTransition(swap);
109
- else swap();
105
+ morph(host, states[rule.go]);
106
+ host.dataset['deStates'] = rule.go;
110
107
  });
111
108
  };
112
109
 
@@ -1,6 +1,7 @@
1
1
  import { Drawing } from './define-call';
2
2
  import { forceBody } from './mark-part';
3
3
 
4
+ export { contrastRatioOf } from './contrast';
4
5
  export { css, drawing, html, ng } from './define-call';
5
6
  export type { Drawing } from './define-call';
6
7
  export { embedPage } from './embed-page';
@@ -23,9 +24,13 @@ export type Interaction = 'hover' | 'focus-visible' | 'active' | 'disabled';
23
24
  * component or element is drawn at the size of its content, and a page at every viewport preset.
24
25
  * `interactions` lists the interactions each state is also drawn in; without it only rest is drawn.
25
26
  * `grid` draws the states it names as one table in rest, see `StateGrid`.
27
+ * `controls` names each control the part draws, keyed by the id its markup carries in `data-et-control`; `design
28
+ * complete` requires the states and surfaces its `kind` and `input` need, see `Control`.
29
+ * `motions` names each motion between two of its states, see `Motion`; a rule in `rules` can name one.
26
30
  * `checks.skip` names the render checks of `design check` the part breaks on purpose, such as
27
31
  * `CLIPPED` for a part that cuts its content. To skip a check for one element only, put
28
32
  * `data-et-skip="CLIPPED"` on it; the check then ignores that element and everything inside it.
33
+ * `contrast` feeds the contrast check of `design check`, see `PartContrast`.
29
34
  */
30
35
  export type Part<TProps, TState extends string = string> = {
31
36
  note: string;
@@ -35,10 +40,37 @@ export type Part<TProps, TState extends string = string> = {
35
40
  widths?: number[];
36
41
  interactions?: Interaction[];
37
42
  grid?: StateGrid<TState>;
43
+ controls?: Record<string, Control<TState>>;
38
44
  checks?: { skip?: RenderCheck[] };
45
+ contrast?: PartContrast;
46
+ motions?: Record<string, Motion<TState>>;
47
+ rules?: PartRule<TState>[];
39
48
  render: (props: TProps, options?: RenderOptions) => DrawnPart | Promise<DrawnPart>;
40
49
  };
41
50
 
51
+ /** What `play` of a motion may use, so Studio controls time: `at` schedules `fn` at `ms` into the motion on the tool's clock, `random` is seeded, `rect` measures a selector in the `from` state. */
52
+ export type MotionContext = {
53
+ at: (ms: number, fn: () => void) => void;
54
+ random: () => number;
55
+ rect: (selector: string) => DOMRect;
56
+ };
57
+
58
+ /**
59
+ * A motion from the state `from` to the state `to`. `at` adds shot times in ms, `interrupt` starts the reverse motion
60
+ * at that time, `viewTransition` uses a View Transition, and `play` runs after the morph for what CSS cannot express.
61
+ */
62
+ export type Motion<TState extends string = string> = {
63
+ from: TState;
64
+ to: TState;
65
+ at?: number[];
66
+ interrupt?: number;
67
+ viewTransition?: boolean;
68
+ play?: (ctx: MotionContext) => void;
69
+ };
70
+
71
+ /** A rule of a part: `on` an event on a selector, the part switches `to` a state, with the `motion` it names. */
72
+ export type PartRule<TState extends string = string> = { on: string; to: TState; motion?: string };
73
+
42
74
  /** A drawing of a part: its `styles` hold the part's stylesheet and the ones `render` returned, so a host can add them up. */
43
75
  export type DrawnPart = Drawing & { styles: string };
44
76
 
@@ -51,17 +83,47 @@ export type StateGrid<TState extends string = string, TColumn extends string = s
51
83
  rows: Record<string, Partial<Record<TColumn, TState>>>;
52
84
  };
53
85
 
86
+ /** How a control is operated: `pointer` requires a drawn hover, `keyboard` a drawn focus-visible. */
87
+ export type ControlInput = 'pointer' | 'keyboard';
88
+
89
+ /** A component or element part that draws a control and its interactions. */
90
+ export type ControlPart = `components/${string}` | `elements/${string}`;
91
+
92
+ /**
93
+ * A control `design complete` checks. A `disclosure` needs a state that draws it collapsed and the state its
94
+ * `surfaces.body` names, which draws the element marked `data-et-surface="<id>.body"` visible and filled.
95
+ * With `part`, the interactions come from that part, which must be design complete itself; a page control needs it.
96
+ * With `input`, a state of this part draws each required interaction, e.g. with `forceInteraction`.
97
+ */
98
+ export type Control<TState extends string = string> = { kind: 'disclosure'; surfaces: { body: TState } } & (
99
+ { part: ControlPart; input?: never } | { input: ControlInput[]; part?: never }
100
+ );
101
+
54
102
  /** A rule of the render check: content larger than its frame, cut off, wrapped onto a second line, or drawn at zero size. */
55
103
  export type RenderCheck = 'OVERFLOW' | 'CLIPPED' | 'WRAP' | 'ZERO-SIZE';
56
104
 
105
+ /**
106
+ * The contrast check measures every text of every state against its drawn background, to WCAG 2 AA: 4.5:1, or
107
+ * 3:1 for large text; disabled text is exempt. `pairs` names token pairs it also computes, each `text` token on
108
+ * each `on` token of an entry, for example `{ text: ['--text', '--text-muted'], on: ['--surface'] }`.
109
+ * `exceptions` exempts the text inside each `selector`, with the `reason` it may stay below the limit.
110
+ */
111
+ export type PartContrast = {
112
+ pairs?: { text: string[]; on: string[] }[];
113
+ exceptions?: { selector: string; reason: string }[];
114
+ };
115
+
57
116
  /** How a composing part draws another one: `interaction` forces it on the root, as a frame does. */
58
117
  export type RenderOptions = { interaction?: Interaction };
59
118
 
60
119
  type PartSource<TProps, TState extends string, TColumn extends string> = Omit<
61
120
  Part<TProps, TState>,
62
- 'states' | 'grid' | 'render'
121
+ 'states' | 'grid' | 'controls' | 'motions' | 'rules' | 'render'
63
122
  > & {
123
+ motions?: Record<string, Motion<NoInfer<TState>>>;
124
+ rules?: PartRule<NoInfer<TState>>[];
64
125
  states: Record<TState, NoInfer<TProps>>;
126
+ controls?: Record<string, Control<NoInfer<TState>>>;
65
127
  grid?: { columns: readonly TColumn[]; rows: Record<string, Partial<Record<NoInfer<TColumn>, NoInfer<TState>>>> };
66
128
  render: (props: TProps) => Drawing | Promise<Drawing>;
67
129
  };
@@ -0,0 +1,67 @@
1
+ const keyOf = (node: Node) =>
2
+ node instanceof Element
3
+ ? `${node.nodeName}#${node.id}#${node.getAttribute('data-et-key') ?? ''}`
4
+ : `${node.nodeType}`;
5
+
6
+ const morphAttributes = (from: Element, to: Element) => {
7
+ for (const { name } of [...from.attributes]) if (!to.hasAttribute(name)) from.removeAttribute(name);
8
+
9
+ for (const { name, value } of [...to.attributes])
10
+ if (from.getAttribute(name) !== value) from.setAttribute(name, value);
11
+ };
12
+
13
+ const morphNode = (from: Node, to: Node) => {
14
+ if (from instanceof Element && to instanceof Element) {
15
+ morphAttributes(from, to);
16
+ morphChildren(
17
+ from instanceof HTMLTemplateElement ? from.content : from,
18
+ to instanceof HTMLTemplateElement ? to.content : to,
19
+ );
20
+ } else if (from.nodeValue !== to.nodeValue) {
21
+ from.nodeValue = to.nodeValue;
22
+ }
23
+ };
24
+
25
+ const morphChildren = (from: Node, to: Node) => {
26
+ let cursor = from.firstChild;
27
+
28
+ for (const next of [...to.childNodes]) {
29
+ const key = keyOf(next);
30
+ let match: ChildNode | null = cursor;
31
+
32
+ while (match && keyOf(match) !== key) match = match.nextSibling;
33
+
34
+ if (match) {
35
+ while (cursor && cursor !== match) {
36
+ const removed: ChildNode = cursor;
37
+
38
+ cursor = cursor.nextSibling;
39
+ removed.remove();
40
+ }
41
+
42
+ morphNode(match, next);
43
+ cursor = match.nextSibling;
44
+ } else {
45
+ from.insertBefore(next, cursor);
46
+ }
47
+ }
48
+
49
+ while (cursor) {
50
+ const removed: ChildNode = cursor;
51
+
52
+ cursor = cursor.nextSibling;
53
+ removed.remove();
54
+ }
55
+ };
56
+
57
+ /**
58
+ * Turns the children of `root` into `markup` by changing only what differs: attributes, text, and the nodes that
59
+ * appear or go. A node is kept when its tag, `id` and `data-et-key` match one at the same place, so its CSS
60
+ * transitions run and a node that appears gets `@starting-style`.
61
+ */
62
+ export const morph = (root: Element, markup: string) => {
63
+ const parsed = root.ownerDocument.createElement('template');
64
+
65
+ parsed.innerHTML = markup;
66
+ morphChildren(root, parsed.content);
67
+ };
@@ -0,0 +1,200 @@
1
+ import { Motion, MotionContext, PartRule } from './define-part';
2
+ import { morph } from './morph';
3
+
4
+ /** The time of a motion in ms from its start: `at` runs `fn` once `now` reaches `ms`, `advance` moves time on. */
5
+ export type MotionClock = {
6
+ now: () => number;
7
+ at: (ms: number, fn: () => void) => void;
8
+ advance: (ms: number) => void;
9
+ };
10
+
11
+ /** A clock that only moves when `advance` is called, so a caller can pause and step a motion. */
12
+ export const manualClock = (): MotionClock => {
13
+ let time = 0;
14
+ let queue: { ms: number; fn: () => void }[] = [];
15
+
16
+ return {
17
+ now: () => time,
18
+ at: (ms, fn) => {
19
+ queue.push({ ms, fn });
20
+ },
21
+ advance: (ms) => {
22
+ const target = time + ms;
23
+
24
+ for (;;) {
25
+ const due = queue.reduce<{ ms: number; fn: () => void } | null>(
26
+ (first, task) => (task.ms <= target && (!first || task.ms < first.ms) ? task : first),
27
+ null,
28
+ );
29
+
30
+ if (!due) break;
31
+
32
+ queue = queue.filter((task) => task !== due);
33
+ time = Math.max(time, due.ms);
34
+ due.fn();
35
+ }
36
+
37
+ time = target;
38
+ },
39
+ };
40
+ };
41
+
42
+ /** A clock that follows real time while it has work, one step per animation frame. */
43
+ export const realTimeClock = (
44
+ frame: (step: () => void) => void = (step) => requestAnimationFrame(step),
45
+ wall: () => number = () => performance.now(),
46
+ ): MotionClock => {
47
+ const clock = manualClock();
48
+ let waiting = 0;
49
+ let last: number | null = null;
50
+
51
+ const tick = () => {
52
+ const now = wall();
53
+
54
+ clock.advance(now - (last ?? now));
55
+ last = now;
56
+
57
+ if (waiting > 0) frame(tick);
58
+ else last = null;
59
+ };
60
+
61
+ return {
62
+ ...clock,
63
+ at: (ms, fn) => {
64
+ waiting++;
65
+ clock.at(ms, () => {
66
+ waiting--;
67
+ fn();
68
+ });
69
+
70
+ if (last === null) {
71
+ last = wall();
72
+ frame(tick);
73
+ }
74
+ },
75
+ };
76
+ };
77
+
78
+ /** The same sequence in every run: mulberry32 from a fixed seed. */
79
+ export const seededRandom = (seed = 1) => {
80
+ let state = seed >>> 0;
81
+
82
+ return () => {
83
+ state = (state + 0x6d2b79f5) >>> 0;
84
+
85
+ let value = Math.imul(state ^ (state >>> 15), 1 | state);
86
+
87
+ value ^= value + Math.imul(value ^ (value >>> 7), 61 | value);
88
+
89
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
90
+ };
91
+ };
92
+
93
+ const measure = (root: Element) => {
94
+ const rects = [...root.querySelectorAll('*')].map((element) => element.getBoundingClientRect());
95
+ const copy = root.cloneNode(true) as Element;
96
+ const copies = [...copy.querySelectorAll('*')];
97
+
98
+ return (selector: string) => {
99
+ const found = copy.querySelector(selector);
100
+ const rect = found ? rects[copies.indexOf(found)] : undefined;
101
+
102
+ if (!rect) throw new Error(`design-explore: ctx.rect("${selector}") matches nothing in the from state`);
103
+
104
+ return rect;
105
+ };
106
+ };
107
+
108
+ /** What a switch of `root` to `markup` does: `motion` names its `play` and `viewTransition`, `clock` the time `ctx.at` runs on. */
109
+ export type StateSwitch = { root: Element; markup: string; motion?: Motion; clock?: MotionClock };
110
+
111
+ /**
112
+ * Morphs `root` to `markup`, inside a View Transition when the motion asks for one and the browser has it, then runs
113
+ * the motion's `play`. `ctx.rect` measures in the DOM before the morph. Resolves once `play` has run.
114
+ */
115
+ export const switchState = ({ root, markup, motion, clock }: StateSwitch): Promise<void> => {
116
+ const rect = motion?.play ? measure(root) : null;
117
+ const apply = () => morph(root, markup);
118
+ const play = () => {
119
+ if (!motion?.play || !rect) return;
120
+
121
+ const time = clock ?? realTimeClock();
122
+ const ctx: MotionContext = { at: (ms, fn) => time.at(ms, fn), random: seededRandom(), rect };
123
+
124
+ motion.play(ctx);
125
+ };
126
+ const document = root.ownerDocument;
127
+
128
+ if (motion?.viewTransition && typeof document.startViewTransition === 'function')
129
+ return document.startViewTransition(apply).updateCallbackDone.then(play);
130
+
131
+ apply();
132
+ play();
133
+
134
+ return Promise.resolve();
135
+ };
136
+
137
+ /**
138
+ * How `runRules` reaches the part: the state drawn first, its `rules` and `motions`, and the markup of a state. `clock`
139
+ * makes the clock of each switch, `switched` hears each finished switch and `failed` each error.
140
+ */
141
+ export type PartRules = {
142
+ state: string;
143
+ rules: PartRule[];
144
+ motions?: Record<string, Motion>;
145
+ render: (state: string) => string | Promise<string>;
146
+ clock?: () => MotionClock;
147
+ switched?: (state: string) => void;
148
+ failed?: (error: unknown) => void;
149
+ };
150
+
151
+ /**
152
+ * Runs the rules of a part in `root`: an event that matches a rule's `on`, such as `click .open`, morphs the part to the
153
+ * rule's state with the motion it names. Switches run one after another. Returns a function that stops the rules.
154
+ */
155
+ export const runRules = (root: HTMLElement, part: PartRules) => {
156
+ let current = part.state;
157
+ let queue = Promise.resolve();
158
+ const types = [...new Set(part.rules.map((rule) => rule.on.trim().split(/\s+/u)[0] ?? ''))].filter(Boolean);
159
+
160
+ const go = (rule: PartRule) => {
161
+ queue = queue
162
+ .then(() => (rule.to === current ? null : part.render(rule.to)))
163
+ .then((markup) => {
164
+ if (markup === null) return;
165
+
166
+ current = rule.to;
167
+
168
+ return switchState({
169
+ root,
170
+ markup,
171
+ motion: rule.motion ? part.motions?.[rule.motion] : undefined,
172
+ clock: part.clock?.(),
173
+ }).then(() => part.switched?.(rule.to));
174
+ })
175
+ .catch((error: unknown) => (part.failed ?? console.error)(error));
176
+ };
177
+
178
+ const listener = (event: Event) => {
179
+ const target = event.target instanceof Element ? event.target : null;
180
+ const rule = part.rules.find((entry) => {
181
+ const [type = '', ...rest] = entry.on.trim().split(/\s+/u);
182
+ const selector = rest.join(' ');
183
+
184
+ if (type !== event.type) return false;
185
+ if (!selector) return true;
186
+
187
+ const matched = target?.closest(selector);
188
+
189
+ return matched ? root.contains(matched) : false;
190
+ });
191
+
192
+ if (rule) go(rule);
193
+ };
194
+
195
+ for (const type of types) root.addEventListener(type, listener);
196
+
197
+ return () => {
198
+ for (const type of types) root.removeEventListener(type, listener);
199
+ };
200
+ };
@@ -9,8 +9,12 @@ declare module 'virtual:design-explore/env' {
9
9
  export const loadEnv: (project: string) => Promise<unknown>;
10
10
  /** The theme names the config declares for the project. The first of each list is the default. */
11
11
  export const themesOf: (project: string) => { surface: string[]; color: string[] };
12
- /** Loads the stylesheet a product draws under, which its base parts draw under too. */
13
- export const loadProductEnv: (product: string) => Promise<unknown>;
12
+ /** Loads the stylesheet a product, or one variant of one of its sketches, draws under, which its base parts draw under too. */
13
+ export const loadProductEnv: (options: {
14
+ product: string;
15
+ sketch: string | null;
16
+ variant: string;
17
+ }) => Promise<unknown>;
14
18
  }
15
19
 
16
20
  declare module 'virtual:design-explore/system' {
@@ -1,5 +1,7 @@
1
1
  import { CallVariant, Drawing, mountDrawing, placeOverlays } from '../define-call';
2
+ import { Part } from '../define-part';
2
3
  import { fitEmbed } from '../embed-page';
4
+ import { runRules } from '../motion-runtime';
3
5
  import { calls } from 'virtual:design-explore';
4
6
  import { loadEnv, loadProductEnv } from 'virtual:design-explore/env';
5
7
  import { parts, sketchParts, sketchTokens, tokens } from 'virtual:design-explore/system';
@@ -77,6 +79,8 @@ const showSetupError = (message: string) => {
77
79
 
78
80
  const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error));
79
81
 
82
+ let drawnPart: { part: Part<unknown>; states: Record<string, unknown> } | null = null;
83
+
80
84
  const drawCall = async (): Promise<Drawing | null> => {
81
85
  const call = calls[slug];
82
86
  if (!call) return fail(`design-explore: no call "${slug}"`);
@@ -110,6 +114,8 @@ const drawPart = async (name: string): Promise<Drawing | null> => {
110
114
 
111
115
  if (!(key in holder.states)) return fail(`design-explore: part "${partKey}" has no state "${key}"`);
112
116
 
117
+ drawnPart = { part: part as Part<unknown>, states: holder.states };
118
+
113
119
  return await part.render(holder.states[key]);
114
120
  } catch (error) {
115
121
  return fail(`design-explore: state "${key}" of "${partKey}" failed\n${messageOf(error)}`);
@@ -162,10 +168,40 @@ const report = () => {
162
168
  );
163
169
  };
164
170
 
171
+ const runPartRules = ({ part, states }: NonNullable<typeof drawnPart>, styles: string) => {
172
+ const sheets = new Set([styles]);
173
+
174
+ runRules(root, {
175
+ state: key,
176
+ rules: part.rules ?? [],
177
+ motions: part.motions,
178
+ render: (state) =>
179
+ Promise.resolve(part.render(states[state])).then((drawn) =>
180
+ sheets.has(drawn.styles)
181
+ ? drawn.body
182
+ : forced(drawn.styles).then((css) => {
183
+ const style = document.createElement('style');
184
+
185
+ sheets.add(drawn.styles);
186
+ style.textContent = css;
187
+ document.head.append(style);
188
+
189
+ return drawn.body;
190
+ }),
191
+ ),
192
+ switched: () => {
193
+ const partRoot = partRootOf(root);
194
+
195
+ if (interaction && partRoot) force(partRoot, interaction);
196
+ },
197
+ failed: (error) => showSetupError(`design-explore: switch of part "${partKey}" failed\n${messageOf(error)}`),
198
+ });
199
+ };
200
+
165
201
  // macOS WebKit can loop in module evaluation when a frame with top-level await is removed.
166
202
  const drawFrame = async () => {
167
203
  parent.postMessage({ type: 'design-explore:loading' }, '*');
168
- await (product ? loadProductEnv(product) : loadEnv(slug.split('/')[0] ?? ''));
204
+ await (product ? loadProductEnv({ product, sketch, variant: sketchVariant }) : loadEnv(slug.split('/')[0] ?? ''));
169
205
  const tokenStyles = product ? await tokensOf(product) : null;
170
206
  const loaded = await (product ? drawPart(product) : drawCall());
171
207
 
@@ -188,6 +224,8 @@ const drawFrame = async () => {
188
224
 
189
225
  if (error)
190
226
  showSetupError(`design-explore: setup of ${product ? 'state' : 'variant'} "${key}" failed\n${messageOf(error)}`);
227
+
228
+ if (drawnPart?.part.rules?.length) runPartRules(drawnPart, loaded.styles ?? '');
191
229
  }
192
230
 
193
231
  const place = () => placeOverlays(root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/cli",
3
- "version": "3.0.0-next.23",
3
+ "version": "3.0.0-next.24",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ethlete-io/ethdk",
@@ -14,6 +14,7 @@
14
14
  "et": "./src/index.js"
15
15
  },
16
16
  "dependencies": {
17
+ "@tailwindcss/node": "4.3.3",
17
18
  "@tailwindcss/postcss": "4.3.3",
18
19
  "postcss": "8.5.32",
19
20
  "postcss-selector-parser": "7.1.7",
@@ -34,10 +34,10 @@ export declare const changedPartsOf: (options: {
34
34
  dependents: string[];
35
35
  }) => PartsChange;
36
36
  /**
37
- * The frames a file that came or went redraws: every part of the product whose part folder holds it,
38
- * since an `@system/...` import anywhere in the product may now resolve to another file, or the parts
39
- * of the variant whose folder holds it. A file in no part folder redraws no part, only the frames
40
- * that show an error.
37
+ * The frames a file that came or went redraws: every part of the product whose part folder or `shared/`
38
+ * holds it, since an `@system/...` or `@shared/...` import anywhere in the product may now resolve to
39
+ * another file, or the parts of the variant whose folder holds it. A file in no part folder redraws no
40
+ * part, only the frames that show an error.
41
41
  */
42
42
  export declare const addedPartsOf: (options: {
43
43
  designRoot: string;