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

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 (111) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/design/define-call.ts +43 -0
  3. package/design/define-part.ts +28 -8
  4. package/design/embed-page.ts +37 -4
  5. package/design/icon-sheet.ts +151 -0
  6. package/design/mark-part.ts +79 -2
  7. package/design/motion-runtime.ts +212 -5
  8. package/design/spring.ts +53 -0
  9. package/design/token-sheet.ts +87 -0
  10. package/design/web/frame.ts +139 -24
  11. package/package.json +1 -1
  12. package/src/lib/design/calls-tsconfig.js +1 -2
  13. package/src/lib/design/calls-tsconfig.js.map +1 -1
  14. package/src/lib/design/design-command.js +34 -4
  15. package/src/lib/design/design-command.js.map +1 -1
  16. package/src/lib/design/force-interactions.d.ts +2 -1
  17. package/src/lib/design/force-interactions.js +7 -4
  18. package/src/lib/design/force-interactions.js.map +1 -1
  19. package/src/lib/design/mcp-command.d.ts +60 -0
  20. package/src/lib/design/mcp-command.js +327 -0
  21. package/src/lib/design/mcp-command.js.map +1 -0
  22. package/src/lib/design/motion-command.d.ts +71 -0
  23. package/src/lib/design/motion-command.js +314 -0
  24. package/src/lib/design/motion-command.js.map +1 -0
  25. package/src/lib/design/motion-endpoint.d.ts +61 -0
  26. package/src/lib/design/motion-endpoint.js +133 -0
  27. package/src/lib/design/motion-endpoint.js.map +1 -0
  28. package/src/lib/design/motion-flow.d.ts +5 -0
  29. package/src/lib/design/motion-flow.js +29 -0
  30. package/src/lib/design/motion-flow.js.map +1 -0
  31. package/src/lib/design/motion-shot.d.ts +43 -0
  32. package/src/lib/design/motion-shot.js +97 -0
  33. package/src/lib/design/motion-shot.js.map +1 -0
  34. package/src/lib/design/ready-shots.d.ts +7 -0
  35. package/src/lib/design/ready-shots.js +22 -1
  36. package/src/lib/design/ready-shots.js.map +1 -1
  37. package/src/lib/design/render-packages.d.ts +9 -0
  38. package/src/lib/design/render-packages.js +13 -3
  39. package/src/lib/design/render-packages.js.map +1 -1
  40. package/src/lib/design/render.js +3 -3
  41. package/src/lib/design/render.js.map +1 -1
  42. package/src/lib/design/serve.js +34 -3
  43. package/src/lib/design/serve.js.map +1 -1
  44. package/src/lib/design/shot-command.d.ts +8 -2
  45. package/src/lib/design/shot-command.js +12 -8
  46. package/src/lib/design/shot-command.js.map +1 -1
  47. package/src/lib/design/shot.d.ts +16 -0
  48. package/src/lib/design/shot.js +68 -4
  49. package/src/lib/design/shot.js.map +1 -1
  50. package/src/lib/design/sketch-command.js +78 -16
  51. package/src/lib/design/sketch-command.js.map +1 -1
  52. package/src/lib/design/studio-api.d.ts +19 -0
  53. package/src/lib/design/studio-api.js +52 -1
  54. package/src/lib/design/studio-api.js.map +1 -1
  55. package/src/lib/design/system/complete-command.d.ts +1 -1
  56. package/src/lib/design/system/complete-command.js +76 -6
  57. package/src/lib/design/system/complete-command.js.map +1 -1
  58. package/src/lib/design/system/complete.d.ts +107 -9
  59. package/src/lib/design/system/complete.js +592 -141
  60. package/src/lib/design/system/complete.js.map +1 -1
  61. package/src/lib/design/system/exceptions.d.ts +59 -0
  62. package/src/lib/design/system/exceptions.js +132 -0
  63. package/src/lib/design/system/exceptions.js.map +1 -0
  64. package/src/lib/design/system/flows/flow.d.ts +3 -0
  65. package/src/lib/design/system/flows/flow.js +5 -3
  66. package/src/lib/design/system/flows/flow.js.map +1 -1
  67. package/src/lib/design/system/handoff.d.ts +11 -0
  68. package/src/lib/design/system/handoff.js +30 -0
  69. package/src/lib/design/system/handoff.js.map +1 -0
  70. package/src/lib/design/system/inventory-approval.d.ts +41 -0
  71. package/src/lib/design/system/inventory-approval.js +71 -0
  72. package/src/lib/design/system/inventory-approval.js.map +1 -0
  73. package/src/lib/design/system/inventory.d.ts +65 -0
  74. package/src/lib/design/system/inventory.js +168 -0
  75. package/src/lib/design/system/inventory.js.map +1 -0
  76. package/src/lib/design/system/list-system.js +9 -2
  77. package/src/lib/design/system/list-system.js.map +1 -1
  78. package/src/lib/design/system/render-check.js +19 -1
  79. package/src/lib/design/system/render-check.js.map +1 -1
  80. package/src/lib/design/system/resolve-product.d.ts +2 -3
  81. package/src/lib/design/system/resolve-product.js +4 -6
  82. package/src/lib/design/system/resolve-product.js.map +1 -1
  83. package/src/lib/design/system/rules/stale-replace.js +41 -2
  84. package/src/lib/design/system/rules/stale-replace.js.map +1 -1
  85. package/src/lib/design/system/rules/token-selectors.js +1 -1
  86. package/src/lib/design/system/rules/token-selectors.js.map +1 -1
  87. package/src/lib/design/system/sketch/host-edit.d.ts +4 -0
  88. package/src/lib/design/system/sketch/host-edit.js +68 -0
  89. package/src/lib/design/system/sketch/host-edit.js.map +1 -0
  90. package/src/lib/design/system/sketch/merge.d.ts +3 -2
  91. package/src/lib/design/system/sketch/merge.js +47 -12
  92. package/src/lib/design/system/sketch/merge.js.map +1 -1
  93. package/src/lib/design/system/sketch/merged-parts.d.ts +10 -0
  94. package/src/lib/design/system/sketch/merged-parts.js +24 -3
  95. package/src/lib/design/system/sketch/merged-parts.js.map +1 -1
  96. package/src/lib/design/system/sketch/no-visible-change.d.ts +6 -0
  97. package/src/lib/design/system/sketch/no-visible-change.js +25 -1
  98. package/src/lib/design/system/sketch/no-visible-change.js.map +1 -1
  99. package/src/lib/design/system/sketch/overlay.js +11 -3
  100. package/src/lib/design/system/sketch/overlay.js.map +1 -1
  101. package/src/lib/design/system/sketch/sketch.d.ts +32 -0
  102. package/src/lib/design/system/sketch/sketch.js +73 -18
  103. package/src/lib/design/system/sketch/sketch.js.map +1 -1
  104. package/src/lib/design/system/sketch/unused-closure.d.ts +12 -0
  105. package/src/lib/design/system/sketch/unused-closure.js +185 -0
  106. package/src/lib/design/system/sketch/unused-closure.js.map +1 -0
  107. package/src/lib/design/system/system-modules.js +6 -9
  108. package/src/lib/design/system/system-modules.js.map +1 -1
  109. package/src/lib/design/system/system-tsconfig.d.ts +2 -1
  110. package/src/lib/design/system/system-tsconfig.js +9 -5
  111. package/src/lib/design/system/system-tsconfig.js.map +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @ethlete/cli
2
2
 
3
+ ## 3.0.0-next.25
4
+
5
+ ### Minor Changes
6
+
7
+ - `et design mcp` serves the design tools to an MCP client over stdio: parts, check, shot, motion_shots, the sketch commands and the inbox. `shot` and `motion_shots` return the sheet image; `motion_shots` also returns its JSON.
8
+ - The design server answers `GET /__design/motion` with the keyframes and tracks of one motion mode, and each keyframe as a PNG with `frame=<ms>`. The draw is kept until the part changes.
9
+ - `embedPage` takes `mark` and `css`, both applied inside the embedded page: every element matching `mark` gets an accent outline, and `css` is added as a stylesheet there.
10
+ - `et design check` warns with `EMBED-SCALE` when an element around an `embedPage` frame with a `scale` scales it again, so the page shrinks twice.
11
+ - `et design complete` accepts a `field` control kind for text fields: it needs a drawn hover and focus-visible, but no active.
12
+ - The shot sheet draws an empty "not drawn" cell for each state combo a grid row leaves out, and `sketch check` lists those cells on the grid line.
13
+ - `et design motion flow <flow> <line>` draws a flow line with `"transition": "view"` as a stepped View Transition from the `from` page to the `to` page, shot in the preset viewport. Lines are named by `id` or `from→to`.
14
+ - `et design motion shots` clips each shot to the part and every animated element over all keyframes, so an overshoot is not cut off, and names a track's element by `data-et-key` where it has one.
15
+ - Add `et design motion shots <part> <motion>`: it draws a motion at its keyframes in full and reduced motion into one sheet and writes its tracks as JSON.
16
+ - `spring({ damping, frequency })` from `@design-system` returns a `linear()` easing and its duration for a motion token. A motion's `interrupt` now switches back to its `from` state at that time in `motion shots`.
17
+ - `design sketch` merges a sketch of a base part into the base. `sketch new --override` opens a product override; `--into-base` moves an existing override into the base. `sketch ready` refuses a variant that does not load.
18
+ - Add `design sketch hold <sketch> --until <text>` and `design sketch release <sketch>`: a held sketch goes back to in progress, leaves the merge queue, and `sketch ready` refuses with the `--until` text until it is released.
19
+ - A sketch may carry an edit to a host of its target in a higher layer when the edit only changes the host's imports, so a rename across layers merges in one sketch.
20
+ - `sketch ready` refuses a live variant whose files, or the `shared/` files they import, keep an unused import, parameter or CSS class.
21
+ - Add `tokenSheet` to `@design-system`: it draws a foundations part as a sheet of its CSS custom properties, labelled with the values the drawing resolves, so every product draws its foundations the same way.
22
+ - `design complete` checks the `button`, `select`, `menu`, `row` and `tooltip` control kinds and a declared `disabled` look, and reads a base part's review from its base's `merged.json`.
23
+ - `design complete` fails a page as `INVENTORY-UNAPPROVED` until its control inventory is approved; the design server answers `/__design/inventory` and `/__design/inventory-approve`, which writes `inventory.json` and refuses pages with undeclared or unclassified controls.
24
+ - `design complete` gives each control inside an embedded part's `data-et-part` root to that part, fails the host while that part is incomplete, and fails a host CSS rule restyling it as `CONTROL-OVERRIDDEN`.
25
+ - `et design mcp` gains the tools `sketch_variant`, `sketch_round`, `sketch_list`, `sketch_check`, `sketch_preview`, `sketch_rebase` and `comment_reply`.
26
+
27
+ ### Patch Changes
28
+
29
+ - `design complete` accepts an option named by `aria-activedescendant` as focus-visible evidence when it looks different from a plain option in its surface, and its gap says which evidence is missing.
30
+ - `design complete --request <part>.<control>.<requirement> --reason` writes a pending exception that still fails. Studio's approve endpoint records by, at and the part hash; then the gap passes until the part changes.
31
+ - `design complete <layer>/<name> --variant <key>` checks a variant of the part's open sketch, so a sketch proves it closes its gaps before it is merged.
32
+ - `design complete`: a `data-et-control-part` descendant of a `data-et-control` takes the forced interactions of its control, and its `disabled` or `aria-disabled="true"` counts as the control's disabled mark.
33
+ - `et design complete <part> --handoff` gives a design complete part the `handoff` status; `et design complete --handoff` re-checks every `handoff` part and exits 1 when one regressed, for CI.
34
+ - `design` renders no longer fail with "exports is not defined": the CLI build again ships the frame runtime as source only, with no CommonJS copies next to it.
35
+ - `STALE-REPLACE` no longer warns on a `.replace` of markup a package draws, such as `button(options)` from `@ethlete/components`, which the rule cannot read.
36
+ - `tokenSheet` styles now carry static fallbacks for every custom property, so design products without the Studio tokens draw a sane sheet.
37
+ - `tokenSheet` takes `scope`, classes on the sheet root where values are read, so theme-scoped tokens resolve, and `note`, markup drawn under the title.
38
+ - A design frame retries a failed part import twice after a short back-off, so a part file that changes while its grid loads no longer leaves "Failed to fetch dynamically imported module" in the frame.
39
+
3
40
  ## 3.0.0-next.24
4
41
 
5
42
  ### Minor Changes
@@ -245,6 +245,49 @@ export const placeOverlays = (root: HTMLElement) => {
245
245
  }
246
246
  };
247
247
 
248
+ /**
249
+ * Grows `root` to cover every absolutely or fixed positioned element inside it, such as an open
250
+ * overlay pane, so a frame measured or shot from `root` shows them. Skips a fixed element on all four
251
+ * edges of the viewport, such as a backdrop. Grows sideways too with `sideways`. Safe to run again.
252
+ */
253
+ export const fitOutOfFlow = (root: HTMLElement, options: { sideways: boolean }) => {
254
+ const view = root.ownerDocument.defaultView;
255
+
256
+ if (!view) return;
257
+
258
+ const box = root.getBoundingClientRect();
259
+ const style = view.getComputedStyle(root);
260
+ const fillsViewport = (style: CSSStyleDeclaration) =>
261
+ style.position === 'fixed' &&
262
+ [style.top, style.right, style.bottom, style.left].every((edge) => parseFloat(edge) === 0);
263
+ const outOfFlow = [...root.querySelectorAll('*')]
264
+ .map((element) => ({ element, style: view.getComputedStyle(element) }))
265
+ .filter(({ style }) => ['absolute', 'fixed'].includes(style.position) && !fillsViewport(style))
266
+ .map(({ element }) => element)
267
+ .map((element) => element.getBoundingClientRect())
268
+ .filter((rect) => rect.width > 0 && rect.height > 0);
269
+ const extentOf = ({ side, start, paddings }: { side: 'bottom' | 'right'; start: number; paddings: string[] }) => {
270
+ const extent = Math.max(0, ...outOfFlow.map((rect) => Math.ceil(rect[side] - start)));
271
+ const [before, after] = paddings.map((padding) => parseFloat(padding) || 0) as [number, number];
272
+
273
+ if (extent === 0) return '';
274
+
275
+ return `${style.boxSizing === 'border-box' ? extent + after : extent - before}px`;
276
+ };
277
+
278
+ root.style.minHeight = extentOf({
279
+ side: 'bottom',
280
+ start: box.top,
281
+ paddings: [style.paddingTop, style.paddingBottom],
282
+ });
283
+ if (options.sideways)
284
+ root.style.minWidth = extentOf({
285
+ side: 'right',
286
+ start: box.left,
287
+ paddings: [style.paddingLeft, style.paddingRight],
288
+ });
289
+ };
290
+
248
291
  /** Types a drawing where it is written, so a mistake reads in its own file and not in `call.ts`. */
249
292
  export const drawing = (drawn: Drawing): Drawing => drawn;
250
293
 
@@ -6,10 +6,16 @@ export { css, drawing, html, ng } from './define-call';
6
6
  export type { Drawing } from './define-call';
7
7
  export { embedPage } from './embed-page';
8
8
  export type { EmbedPageOptions } from './embed-page';
9
+ export { iconSheet } from './icon-sheet';
10
+ export type { IconSheetIcon, IconSheetOptions } from './icon-sheet';
9
11
  export { interactionRows } from './interaction-rows';
10
12
  export type { InteractionRowsOptions } from './interaction-rows';
13
+ export { spring } from './spring';
14
+ export type { Spring } from './spring';
11
15
  export { svgAsset } from './svg-asset';
12
16
  export type { SvgAssetOptions } from './svg-asset';
17
+ export { tokenSheet } from './token-sheet';
18
+ export type { TokenSheetOptions } from './token-sheet';
13
19
 
14
20
  /** An interaction Studio can force on a part's root element. Rest is always drawn and never listed. */
15
21
  export type Interaction = 'hover' | 'focus-visible' | 'active' | 'disabled';
@@ -83,21 +89,35 @@ export type StateGrid<TState extends string = string, TColumn extends string = s
83
89
  rows: Record<string, Partial<Record<TColumn, TState>>>;
84
90
  };
85
91
 
86
- /** How a control is operated: `pointer` requires a drawn hover, `keyboard` a drawn focus-visible. */
92
+ /** How a control is operated: `pointer` requires a drawn hover and active (a `field` needs no active), `keyboard` a drawn focus-visible. */
87
93
  export type ControlInput = 'pointer' | 'keyboard';
88
94
 
89
95
  /** A component or element part that draws a control and its interactions. */
90
96
  export type ControlPart = `components/${string}` | `elements/${string}`;
91
97
 
92
98
  /**
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`.
99
+ * What a control of each `kind` needs besides the interactions of its `input`. A surface is the element marked
100
+ * `data-et-surface="<id>.<surface>"`, drawn visible and filled in the state its `surfaces` entry names.
101
+ * - `disclosure`: a state that draws it collapsed, and `surfaces.body`.
102
+ * - `button`: `active`.
103
+ * - `field`: a text field (`<input>` or `<textarea>`); nothing beyond its inputs.
104
+ * - `select`, `menu`: `surfaces.open`, and options in it (`role="option"`, `role="menuitem"` or `data-et-option`)
105
+ * drawn hovered, focus-visible, and selected with a look of their own.
106
+ * - `row`: drawn selected (`aria-selected`, `aria-checked`, `aria-pressed` or `aria-current`) with a look of its own,
107
+ * and hovered while selected.
108
+ * - `tooltip`: `surfaces.tooltip`, the visible tooltip next to the control in this part.
109
+ *
110
+ * `disabled: true` also requires a state that draws it disabled with a look of its own. With `part`, the
111
+ * interactions, selected and disabled looks and option states come from that part, which must be design complete
112
+ * itself; the surfaces stay with this part. A page control needs `part`. With `input`, a state of this part draws
113
+ * each requirement, e.g. with `forceInteraction`.
97
114
  */
98
- export type Control<TState extends string = string> = { kind: 'disclosure'; surfaces: { body: TState } } & (
99
- { part: ControlPart; input?: never } | { input: ControlInput[]; part?: never }
100
- );
115
+ export type Control<TState extends string = string> = (
116
+ | { kind: 'disclosure'; surfaces: { body: TState } }
117
+ | { kind: 'button' | 'row' | 'field'; surfaces?: never }
118
+ | { kind: 'select' | 'menu'; surfaces: { open: TState } }
119
+ | { kind: 'tooltip'; surfaces: { tooltip: TState } }
120
+ ) & { disabled?: boolean } & ({ part: ControlPart; input?: never } | { input: ControlInput[]; part?: never });
101
121
 
102
122
  /** A rule of the render check: content larger than its frame, cut off, wrapped onto a second line, or drawn at zero size. */
103
123
  export type RenderCheck = 'OVERFLOW' | 'CLIPPED' | 'WRAP' | 'ZERO-SIZE';
@@ -2,9 +2,20 @@ import { Drawing } from './define-call';
2
2
 
3
3
  /**
4
4
  * How a page embeds another: the `state` to draw, the viewport `width` and `height` it is drawn at (`'auto'` grows
5
- * the frame to the page's content), and the `scale` the frame is shown at.
5
+ * the frame to the page's content), and the `scale` the frame is shown at. Inside the embedded page, `mark` rings
6
+ * every element matching its selector (each gets `data-et-embed-marked`), and `css` is added as a stylesheet.
6
7
  */
7
- export type EmbedPageOptions = { state?: string; width?: number; height?: number | 'auto'; scale?: number };
8
+ export type EmbedPageOptions = {
9
+ state?: string;
10
+ width?: number;
11
+ height?: number | 'auto';
12
+ scale?: number;
13
+ mark?: string;
14
+ css?: string;
15
+ };
16
+
17
+ const MARK_CSS =
18
+ '[data-et-embed-marked] { outline: 0.2rem solid var(--et-theme-color-primary-solid, Highlight); outline-offset: 0.2rem; }';
8
19
 
9
20
  const KEPT = ['product', 'sketch', 'variant', 'surface', 'color'];
10
21
 
@@ -33,11 +44,12 @@ const attribute = (text: string) => text.replace(/&/g, '&amp;').replace(/"/g, '&
33
44
  * Put the answer's `body` into the page's markup; it needs no styles or setup.
34
45
  */
35
46
  export const embedPage = (page: `pages/${string}`, options: EmbedPageOptions = {}): Drawing => {
36
- const { state = 'default', width = 1280, height = 800, scale = 1 } = options;
47
+ const { state = 'default', width = 1280, height = 800, scale = 1, mark, css } = options;
37
48
  const search = embedSearchOf({ page, state, current: globalThis.location?.search ?? '' });
38
49
  const auto = height === 'auto';
39
50
  const frameHeight = auto ? 0 : height;
40
- const frame = `<iframe data-et-embed="${attribute(page)}" data-et-embed-scale="${scale}"${auto ? ' data-et-embed-height="auto"' : ''} title="${attribute(page)}" src="/frame.html?${attribute(search)}" style="display:block;border:0;width:${width}px;height:${frameHeight}px;transform:scale(${scale});transform-origin:0 0"></iframe>`;
51
+ const inside = `${mark ? ` data-et-embed-mark="${attribute(mark)}"` : ''}${css ? ` data-et-embed-css="${attribute(css)}"` : ''}`;
52
+ const frame = `<iframe data-et-embed="${attribute(page)}" data-et-embed-scale="${scale}"${auto ? ' data-et-embed-height="auto"' : ''}${inside} title="${attribute(page)}" src="/frame.html?${attribute(search)}" style="display:block;border:0;width:${width}px;height:${frameHeight}px;transform:scale(${scale});transform-origin:0 0"></iframe>`;
41
53
 
42
54
  return {
43
55
  body:
@@ -68,3 +80,24 @@ export const fitEmbed = (embed: Element | null, height: number) => {
68
80
 
69
81
  frame.dataset['etEmbedDrawn'] = '';
70
82
  };
83
+
84
+ /**
85
+ * Called by an embedded frame after it draws and on each change of its content: adds the `css` of its `embedPage`
86
+ * once and marks every element in `root` that matches its `mark`. Does nothing in a frame no page embeds.
87
+ */
88
+ export const applyEmbed = (embed: Element | null, root: HTMLElement) => {
89
+ if (!(embed as HTMLElement | null)?.hasAttribute('data-et-embed')) return;
90
+
91
+ const { etEmbedMark: mark, etEmbedCss: css } = (embed as HTMLElement).dataset;
92
+ const document = root.ownerDocument;
93
+
94
+ if ((mark || css) && !document.querySelector('style[data-et-embed-css]')) {
95
+ const style = document.createElement('style');
96
+
97
+ style.dataset['etEmbedCss'] = '';
98
+ style.textContent = `${mark ? MARK_CSS : ''}\n${css ?? ''}`;
99
+ document.head.append(style);
100
+ }
101
+
102
+ if (mark) for (const element of root.querySelectorAll(mark)) element.setAttribute('data-et-embed-marked', '');
103
+ };
@@ -0,0 +1,151 @@
1
+ import { Drawing, html } from './define-call';
2
+ import { svgAsset } from './svg-asset';
3
+
4
+ export type IconSheetIcon = {
5
+ name: string;
6
+ svg: string;
7
+ grid?: number;
8
+ padding?: number;
9
+ };
10
+
11
+ export type IconSheetOptions = {
12
+ view: 'catalogue' | 'construction';
13
+ title: string;
14
+ note?: string;
15
+ icons: IconSheetIcon[];
16
+ sizes?: number[];
17
+ size?: number;
18
+ columns?: number;
19
+ };
20
+
21
+ const SHEET_STYLES = `
22
+ .ics-root { display: flex; flex-direction: column; gap: var(--space-4, 1.6rem); padding: var(--space-6, 2.4rem); background: var(--surface, oklch(0.17 0 0)); color: var(--text, oklch(0.94 0 0)); font-family: var(--font-ui, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); font-size: var(--text-md, 1.3rem); line-height: var(--text-md-line, 1.8rem); }
23
+ .ics-root svg { display: block; }
24
+ .ics-title { margin: 0; font-size: var(--text-xl, 1.7rem); line-height: var(--text-xl-line, 2.2rem); font-weight: var(--weight-strong, 600); }
25
+ .ics-note { margin: 0; max-width: 64rem; color: var(--text-muted, oklch(0.72 0 0)); }
26
+ .ics-group { margin: 0; font-size: var(--text-lg, 1.5rem); line-height: var(--text-lg-line, 2rem); font-weight: var(--weight-strong, 600); }
27
+ .ics-muted { color: var(--text-muted, oklch(0.72 0 0)); }
28
+ .ics-name { justify-self: start; font-family: var(--font-code, ui-monospace, "SF Mono", Menlo, Consolas, monospace); color: var(--text-muted, oklch(0.72 0 0)); }
29
+ .ics-catalogue { display: grid; align-items: center; justify-items: center; column-gap: var(--space-4, 1.6rem); row-gap: var(--space-2, 0.8rem); }
30
+ .ics-size { font-size: var(--text-sm, 1.15rem); }
31
+ .ics-cell { display: flex; }
32
+ .ics-construction { display: grid; gap: var(--space-5, 2rem); }
33
+ .ics-item { display: flex; flex-direction: column; align-items: center; gap: var(--space-2, 0.8rem); }
34
+ .ics-frame { position: relative; display: flex; border: 1px solid var(--surface-border, oklch(0.34 0 0)); background: var(--surface, oklch(0.17 0 0)); }
35
+ .ics-icon { position: relative; }
36
+ .ics-guides { position: absolute; inset: 0; width: 100%; height: 100%; color: var(--accent, oklch(0.7 0.15 250)); pointer-events: none; }
37
+ .ics-guides path, .ics-guides rect { vector-effect: non-scaling-stroke; stroke-width: 1px; }
38
+ .ics-pixels { opacity: 0.18; }
39
+ .ics-key { opacity: 0.7; stroke-dasharray: 4 3; }
40
+ `;
41
+
42
+ const VIEW_BOX = /\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/u;
43
+
44
+ const gridOf = (icon: IconSheetIcon) => {
45
+ if (icon.grid) return icon.grid;
46
+ const [, width, height] = VIEW_BOX.exec(icon.svg) ?? [];
47
+ if (!width || !height) throw new Error(`iconSheet: "${icon.name}" has no viewBox, so it needs a grid`);
48
+
49
+ return Math.max(Number(width), Number(height));
50
+ };
51
+
52
+ const groupsOf = (icons: IconSheetIcon[]) => {
53
+ const groups = new Map<number, IconSheetIcon[]>();
54
+ for (const icon of icons) groups.set(gridOf(icon), [...(groups.get(gridOf(icon)) ?? []), icon]);
55
+
56
+ return [...groups];
57
+ };
58
+
59
+ const gridLabel = (grid: number) => (grid <= 48 ? `${grid} px grid` : `${grid}-unit grid`);
60
+
61
+ const guides = (grid: number, { cls, paths }: { cls: string; paths: string }) =>
62
+ `<svg class="ics-guides ${cls}" viewBox="0 0 ${grid} ${grid}" fill="none" stroke="currentColor" aria-hidden="true" data-grid="${grid}">${paths}</svg>`;
63
+
64
+ const pixelLines = (grid: number) => {
65
+ const step = grid > 48 ? grid / 16 : 1;
66
+ const lines = Array.from({ length: Math.round(grid / step) - 1 }, (_, index) => (index + 1) * step);
67
+
68
+ return guides(grid, {
69
+ cls: 'ics-pixels',
70
+ paths: lines.map((at) => `<path d="M${at} 0V${grid}M0 ${at}H${grid}"/>`).join(''),
71
+ });
72
+ };
73
+
74
+ const keyLines = (grid: number, padding: number) =>
75
+ guides(grid, {
76
+ cls: 'ics-key',
77
+ paths: `<path d="M${grid / 2} 0V${grid}M0 ${grid / 2}H${grid}"/><rect x="${padding}" y="${padding}" width="${grid - 2 * padding}" height="${grid - 2 * padding}"/>`,
78
+ });
79
+
80
+ const drawIcon = (icon: IconSheetIcon, { size, cls }: { size: number; cls?: string }) =>
81
+ svgAsset(icon.svg, { size, class: cls, attrs: { 'aria-hidden': 'true' } });
82
+
83
+ const catalogue = (icons: IconSheetIcon[], sizes: number[]) =>
84
+ html`<div
85
+ class="ics-catalogue"
86
+ style="grid-template-columns: max-content repeat(${sizes.length}, minmax(var(--space-7, 3.2rem), max-content))"
87
+ >
88
+ <span></span>
89
+ ${sizes.map((size) => html`<span class="ics-size ics-muted">${size} px</span>`).join('')}
90
+ ${icons
91
+ .map(
92
+ (icon) =>
93
+ html`<span class="ics-name">${icon.name}</span>${sizes
94
+ .map((size) => html`<span class="ics-cell">${drawIcon(icon, { size })}</span>`)
95
+ .join('')}`,
96
+ )
97
+ .join('')}
98
+ </div>`;
99
+
100
+ const construction = (
101
+ icons: IconSheetIcon[],
102
+ { grid, size, columns }: { grid: number; size: number; columns: number },
103
+ ) =>
104
+ html`<div class="ics-construction" style="grid-template-columns: repeat(${columns}, max-content)">
105
+ ${icons
106
+ .map(
107
+ (icon) =>
108
+ html`<div class="ics-item">
109
+ <span class="ics-frame"
110
+ >${pixelLines(grid)}${drawIcon(icon, { size, cls: 'ics-icon' })}${keyLines(grid, icon.padding ?? grid / 8)}</span
111
+ >
112
+ <span class="ics-name">${icon.name}</span>
113
+ </div>`,
114
+ )
115
+ .join('')}
116
+ </div>`;
117
+
118
+ /**
119
+ * Draws a foundation sheet of icons. `catalogue` shows every icon at each of `sizes` with its name; `construction` shows
120
+ * each icon `size` px large on its grid, with the grid lines, the centre lines and the padding box. An icon's grid is its
121
+ * `grid`, else the larger side of its viewBox; its padding is `padding` grid units, `grid / 8` by default.
122
+ * Icons on different grids are drawn in one group per grid.
123
+ */
124
+ export const iconSheet = ({
125
+ view,
126
+ title,
127
+ note,
128
+ icons,
129
+ sizes = [16, 24, 32],
130
+ size = 96,
131
+ columns = 5,
132
+ }: IconSheetOptions): Drawing => {
133
+ const groups = groupsOf(icons);
134
+ const labelled = groups.length > 1;
135
+
136
+ return {
137
+ body: html`<div class="ics-root">
138
+ <h2 class="ics-title">${title}</h2>
139
+ ${note && html`<p class="ics-note">${note}</p>`}
140
+ ${groups
141
+ .map(
142
+ ([grid, members]) =>
143
+ html`${labelled && html`<h3 class="ics-group">${gridLabel(grid)}</h3>`}${
144
+ view === 'catalogue' ? catalogue(members, sizes) : construction(members, { grid, size, columns })
145
+ }`,
146
+ )
147
+ .join('')}
148
+ </div>`,
149
+ styles: SHEET_STYLES,
150
+ };
151
+ };
@@ -5,6 +5,11 @@ const SKIPPED = /^(?:\s+|<!--[\s\S]*?-->|<(style|script)\b[\s\S]*?<\/\1\s*>|<lin
5
5
  const TAG = /^<[a-zA-Z][\w:-]*/;
6
6
  const ATTRIBUTES = /^(?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*/;
7
7
  const MARK = /(\sdata-et-part=")([^"]*)"/;
8
+ const SLOT = /(\sdata-et-slot=")([^"]*)"/;
9
+ const TAG_AT = /<[a-zA-Z][\w:-]*/y;
10
+ const ATTRIBUTES_AT = new RegExp(ATTRIBUTES.source.slice(1), 'y');
11
+ const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title']);
12
+ const MAX_PROP_DEPTH = 4;
8
13
 
9
14
  const attributesAtOf = (body: string) => {
10
15
  let at = 0;
@@ -43,10 +48,82 @@ export const forceBody = (body: string, interaction: Interaction) => {
43
48
  return `${body.slice(0, attributesAt)}${forced}${body.slice(attributesAt)}`;
44
49
  };
45
50
 
51
+ /** Adds `key` to `data-et-slot` on every element of `markup`: the markup a host passes into the part `key` through its props. */
52
+ export const markSlot = (markup: string, key: string) => {
53
+ let marked = '';
54
+ let at = 0;
55
+
56
+ for (let open = markup.indexOf('<'); open >= 0; open = markup.indexOf('<', at)) {
57
+ if (markup.startsWith('<!--', open)) {
58
+ const close = markup.indexOf('-->', open + 4);
59
+ const end = close < 0 ? markup.length : close + 3;
60
+
61
+ marked += markup.slice(at, end);
62
+ at = end;
63
+ continue;
64
+ }
65
+
66
+ TAG_AT.lastIndex = open;
67
+ const tag = TAG_AT.exec(markup);
68
+
69
+ if (!tag) {
70
+ marked += markup.slice(at, open + 1);
71
+ at = open + 1;
72
+ continue;
73
+ }
74
+
75
+ const attributesAt = open + tag[0].length;
76
+
77
+ ATTRIBUTES_AT.lastIndex = attributesAt;
78
+ const attributes = ATTRIBUTES_AT.exec(markup)?.[0] ?? '';
79
+ const slot = SLOT.exec(attributes);
80
+
81
+ if (slot) {
82
+ const valueEnd = attributesAt + slot.index + (slot[1]?.length ?? 0) + (slot[2]?.length ?? 0);
83
+
84
+ marked += `${markup.slice(at, valueEnd)} ${key}`;
85
+ at = valueEnd;
86
+ } else {
87
+ marked += `${markup.slice(at, attributesAt)} data-et-slot="${key}"`;
88
+ at = attributesAt;
89
+ }
90
+
91
+ const name = tag[0].slice(1).toLowerCase();
92
+
93
+ if (RAW_TEXT.has(name)) {
94
+ const close = markup.toLowerCase().indexOf(`</${name}`, attributesAt);
95
+ const end = close < 0 ? markup.length : close;
96
+
97
+ marked += markup.slice(at, end);
98
+ at = end;
99
+ }
100
+ }
101
+
102
+ return marked + markup.slice(at);
103
+ };
104
+
105
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
106
+ !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype;
107
+
108
+ const slotProps = (value: unknown, { key, depth = 0 }: { key: string; depth?: number }): unknown => {
109
+ const inner = { key, depth: depth + 1 };
110
+
111
+ if (typeof value === 'string') return /<[a-zA-Z]/.test(value) ? markSlot(value, key) : value;
112
+ if (depth >= MAX_PROP_DEPTH) return value;
113
+ if (Array.isArray(value)) return value.map((item) => slotProps(item, inner));
114
+ if (isPlainObject(value))
115
+ return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, slotProps(item, inner)]));
116
+
117
+ return value;
118
+ };
119
+
46
120
  const markDrawing = <TDrawing extends Drawing>(drawn: TDrawing, key: string): TDrawing =>
47
121
  typeof drawn?.body === 'string' ? { ...drawn, body: markBody(drawn.body, key) } : drawn;
48
122
 
49
- /** The part with a `render` that marks the first element of each drawing as the root of the part `key`. */
123
+ /**
124
+ * The part with a `render` that marks the first element of each drawing as the root of the part `key`, and every
125
+ * element of the markup its props pass in as slotted into `key`.
126
+ */
50
127
  export const markPart = <TProps, TState extends string>(
51
128
  part: Part<TProps, TState>,
52
129
  key: string,
@@ -58,7 +135,7 @@ export const markPart = <TProps, TState extends string>(
58
135
  return {
59
136
  ...part,
60
137
  render: (props: TProps, options?: RenderOptions) => {
61
- const drawn = render.call(part, props, options);
138
+ const drawn = render.call(part, slotProps(props, { key }) as TProps, options);
62
139
 
63
140
  return drawn instanceof Promise ? drawn.then((done) => markDrawing(done, key)) : markDrawing(drawn, key);
64
141
  },