@codepress/design-kit 0.9.0 → 0.10.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
@@ -351,7 +351,7 @@ fresh browser context, signed in from `auth.storageState` (a Playwright
351
351
  storage state file) or from the state the `auth.setup` module's default
352
352
  export returns (`async ({ browser, baseUrl, config }) => state`).
353
353
 
354
- - `pages[]`: `{name, title?, path, ready, sourceFile?, slots, layoutSlots?}`.
354
+ - `pages[]`: `{name, title?, path, ready, sourceFile?, slots, layoutSlots?, widths?}`. `widths` names which of `pages.widths` a page is captured at, for a view the app draws at some sizes only; it defaults to all of them.
355
355
  `ready` is a Playwright selector that shows once the page has its content.
356
356
  `slots` names the regions a mockup replaces: the first selector that matches
357
357
  a visible, non-empty element gets `data-kit-slot="<name>"`, and `main` is
@@ -558,11 +558,62 @@ the frame, the snippet ids and how many `data-kit-new` elements the page has.
558
558
  The record stays in the page: `kit.mjs` makes no network call. Scripts, event handlers,
559
559
  `<link>`s and SVG animation are removed, and URLs pass an allowlist: only
560
560
  relative references, fragments and `data:` survive, read the way the browser
561
- reads them. `--theme dark` applies the manifest's theme. Elements marked
562
- `data-kit-new` get a visible outline with `--show-new`. An unknown frame, slot
561
+ reads them. The one exception is your own code: an inline `<script>` (no
562
+ `src`) in a `--slot` fragment file is kept in place, with only its `type` and
563
+ `id`. Nothing executable from the kit's captured markup survives, a fragment's
564
+ `<script src>` is removed, and `on…=` handlers are removed everywhere, your
565
+ fragments included. `--theme dark` applies the manifest's theme. Elements marked
566
+ `data-kit-new` get a visible outline with `--show-new`, and behaviour triggers
567
+ (below) a blue one. An unknown frame, slot
563
568
  or snippet id exits 2, naming near matches. `--kit <dir>` (or
564
569
  `DESIGN_KIT_DIR`) points at a kit other than the tool's own folder.
565
570
 
571
+ ### Clickable mockups
572
+
573
+ A composed page can switch tabs, open and close menus and swap between a
574
+ component's captured states on click or tap. Mark it up with these attributes
575
+ in your slot fragment, and `compose` inlines a small behaviour script once, at
576
+ the end of the body (no network, no storage; it works in a
577
+ `sandbox="allow-scripts"` frame). A page that uses none of them composes
578
+ exactly as before.
579
+
580
+ | Attribute | What a click does |
581
+ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
582
+ | `data-kit-toggle="<id>"` on a trigger | Shows or hides the element with that id and sets `aria-expanded`. A click outside it, or Esc, closes what a toggle opened. |
583
+ | `data-kit-tabs` on a container | Its `[data-kit-tab="<name>"]` triggers show the matching `[data-kit-panel="<name>"]` and hide the others, with `aria-selected`. The first tab starts selected unless one has `aria-selected="true"`. |
584
+ | `data-kit-state="<group>:<name>"` on each state | Nothing by itself: the group starts in the state of its first element without `hidden`. |
585
+ | `data-kit-set="<group>:<name>"` on a trigger | Shows every element of the group in that state and hides the rest of the group. |
586
+
587
+ To make captured stories interactive, compose each state as its own snippet
588
+ and put the attributes on the `<kit-snippet>`: they (and `hidden`) are copied
589
+ onto every top-level element the snippet expands to, a story's portal
590
+ included.
591
+
592
+ ```html
593
+ <kit-snippet
594
+ id="story:primitives-dropdownmenu--closed@1280"
595
+ data-kit-state="menu:closed"
596
+ data-kit-set="menu:open"
597
+ ></kit-snippet>
598
+ <kit-snippet
599
+ id="story:primitives-dropdownmenu--open@1280"
600
+ data-kit-state="menu:open"
601
+ data-kit-set="menu:closed"
602
+ hidden
603
+ ></kit-snippet>
604
+ ```
605
+
606
+ A click anywhere on the closed story shows the open one, and a click on the
607
+ open one goes back. Links and buttons inside a trigger only switch the state;
608
+ they do not navigate or submit. A trigger that already has `data-state`
609
+ (Radix-style markup) gets `open`/`closed` or `active`/`inactive` there too, so
610
+ its own styling follows, and a trigger that is not a button or link gets
611
+ `tabindex="0"` so Enter and Space work. Elements the behaviours hide are
612
+ hidden even when their own CSS sets a `display`. Native interactivity needs
613
+ nothing: `<details>`/`<summary>`, `popover` with `popovertarget`, form
614
+ controls and `aria-*` all survive compose. Anything else is a few lines of
615
+ your own `<script>` in the fragment.
616
+
566
617
  ## Programmatic use
567
618
 
568
619
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Capture your Storybook and real app pages as a static design kit, and compose self-contained mockups from it",
5
5
  "type": "module",
6
6
  "bin": {
@@ -280,6 +280,12 @@
280
280
  "description": "The page's source module, shown by `kit show` and searched by `kit find`."
281
281
  },
282
282
  "slots": { "$ref": "#/$defs/slots" },
283
+ "widths": {
284
+ "type": "array",
285
+ "minItems": 1,
286
+ "items": { "type": "integer", "minimum": 1 },
287
+ "description": "Only these of `pages.widths`, for a page the app draws at some sizes only (a laptop-only rail tab). Default: every width."
288
+ },
283
289
  "layoutSlots": {
284
290
  "type": "object",
285
291
  "description": "Slots for a layout the detector reports, replacing `slots` there.",
@@ -543,6 +543,7 @@ export async function capturePages(config, options = {}) {
543
543
  for (const viewport of settings.widths) {
544
544
  for (const page of pages) {
545
545
  if (session.interrupted) return 130;
546
+ if (page.widths && !page.widths.includes(viewport.width)) continue;
546
547
  const id = kitRouteId(page.name, viewport.width);
547
548
  const context = await session.browser.newContext({
548
549
  baseURL: baseUrl,
package/src/kit.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  * `--kit <dir>` (or DESIGN_KIT_DIR) points at a kit; the default is the
13
13
  * folder this file sits in. Format: the @codepress/design-kit README.
14
14
  */
15
+ import { randomBytes } from "node:crypto";
15
16
  import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
16
17
  import { basename, dirname, extname, join, resolve, sep } from "node:path";
17
18
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -185,11 +186,34 @@ function parseAttributes(source) {
185
186
  const pattern = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
186
187
  let match;
187
188
  while ((match = pattern.exec(source))) {
188
- attrs[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? "";
189
+ const name = match[1].toLowerCase();
190
+ // The browser keeps the first of two same-named attributes, so the
191
+ // sanitizer must check that one: `<img src="https://…" src="a.png">`
192
+ // loads the remote one.
193
+ if (!Object.hasOwn(attrs, name))
194
+ attrs[name] = match[2] ?? match[3] ?? match[4] ?? "";
189
195
  }
190
196
  return attrs;
191
197
  }
192
198
 
199
+ /**
200
+ * Where the comment opening at `lt` ends, as the browser reads it: `<!-->`
201
+ * and `<!--->` are empty comments, and `--!>` closes a comment as well as
202
+ * `-->`. Reading a comment as longer than the browser does would let markup
203
+ * after its real end pass the sanitizer as comment text.
204
+ */
205
+ function commentEnd(html, lt) {
206
+ if (html.startsWith(">", lt + 4)) return lt + 5;
207
+ if (html.startsWith("->", lt + 4)) return lt + 6;
208
+ const ends = [
209
+ [html.indexOf("-->", lt + 4), 3],
210
+ [html.indexOf("--!>", lt + 4), 4],
211
+ ].filter(([at]) => at >= 0);
212
+ if (!ends.length) return html.length;
213
+ const [at, length] = ends.reduce((a, b) => (b[0] < a[0] ? b : a));
214
+ return at + length;
215
+ }
216
+
193
217
  /** Tokens with offsets: open / close / text / comment. */
194
218
  export function tokenize(html) {
195
219
  return [...scan(html)];
@@ -207,10 +231,8 @@ function* scan(html) {
207
231
  }
208
232
  if (lt > i) yield { type: "text", start: i, end: lt };
209
233
  if (html.startsWith("<!--", lt)) {
210
- const close = html.indexOf("-->", lt + 4);
211
- const end = close < 0 ? html.length : close + 3;
212
- yield { type: "comment", start: lt, end };
213
- i = end;
234
+ yield { type: "comment", start: lt, end: commentEnd(html, lt) };
235
+ i = commentEnd(html, lt);
214
236
  continue;
215
237
  }
216
238
  const closing = html[lt + 1] === "/";
@@ -339,6 +361,8 @@ const DROPPED = new Set(["script", "link", "base", "meta", "set", "animate"]);
339
361
  /** Dropped with everything inside them: scripts, and SVG animation, which can
340
362
  * set an href to a remote URL after load. */
341
363
  const DROPPED_WITH_CONTENT = new Set(["script", "set", "animate"]);
364
+ /** Elements this scanner reads as raw text that foreign content parses as markup. */
365
+ const RAW_TEXT = new Set(["style", "title", "textarea"]);
342
366
 
343
367
  /**
344
368
  * Named character references the decoder knows: every HTML name for an ASCII
@@ -528,10 +552,23 @@ export function sanitizeMarkup(html) {
528
552
  for (let t = 0; t < tokens.length; t += 1) {
529
553
  const token = tokens[t];
530
554
  if (token.type !== "open") continue;
531
- if (DROPPED.has(token.name)) {
555
+ // Inside <svg> or <math> the browser parses a <style>, <title> or
556
+ // <textarea>'s content as markup (`<svg><style><img onerror=…>`), where
557
+ // this scanner reads raw text. One whose text holds anything tag-like is
558
+ // dropped with its content, wherever it sits.
559
+ const text = tokens[t + 1];
560
+ const markupInRaw =
561
+ RAW_TEXT.has(token.name) &&
562
+ !token.selfClosing &&
563
+ text?.type === "text" &&
564
+ /<[a-z!/?]/i.test(html.slice(text.start, text.end));
565
+ if (DROPPED.has(token.name) || markupInRaw) {
532
566
  out += html.slice(i, token.start);
533
567
  i = token.end;
534
- if (DROPPED_WITH_CONTENT.has(token.name) && !token.selfClosing) {
568
+ if (
569
+ (DROPPED_WITH_CONTENT.has(token.name) || markupInRaw) &&
570
+ !token.selfClosing
571
+ ) {
535
572
  const close = tokens.findIndex(
536
573
  (n, k) => k > t && n.type === "close" && n.name === token.name,
537
574
  );
@@ -594,7 +631,6 @@ export function sanitizeMarkup(html) {
594
631
  out += `${html.slice(i, token.start)}<${token.name}${attrs}${token.selfClosing && !VOID.has(token.name) ? " /" : ""}>`;
595
632
  i = token.end;
596
633
  }
597
- const text = tokens[t + 1];
598
634
  if (token.name === "style" && text?.type === "text") {
599
635
  out +=
600
636
  html.slice(i, text.start) +
@@ -709,6 +745,254 @@ export function inlineAssets(kit, css, cssDir = join(kit.dir, "css")) {
709
745
  );
710
746
  }
711
747
 
748
+ // ── behaviours (clickable mockups) ──────────────────────────────────────────
749
+
750
+ /** Attributes that make compose inline the behaviour script. */
751
+ const BEHAVIOUR_ATTRS = [
752
+ "data-kit-toggle",
753
+ "data-kit-tabs",
754
+ "data-kit-tab",
755
+ "data-kit-panel",
756
+ "data-kit-state",
757
+ "data-kit-set",
758
+ ];
759
+
760
+ /**
761
+ * Hides what the behaviours hide even when the element's own CSS sets a
762
+ * display (a captured story root with `display:flex` would ignore `hidden`).
763
+ * The script marks toggle targets with `data-kit-target`.
764
+ */
765
+ const BEHAVIOURS_CSS =
766
+ "\n[data-kit-panel][hidden],[data-kit-state][hidden],[data-kit-target][hidden]{display:none !important}";
767
+
768
+ /** --show-new outlines behaviour triggers too, in a second colour. */
769
+ const SHOW_TRIGGERS_CSS =
770
+ "\n[data-kit-toggle],[data-kit-tab],[data-kit-set]{outline:2px dashed #0090ff !important;outline-offset:2px}";
771
+
772
+ /**
773
+ * The behaviour layer compose inlines once, at the end of the body, when the
774
+ * page uses one of BEHAVIOUR_ATTRS. Plain DOM, no network and no storage, so
775
+ * it runs in a `sandbox="allow-scripts"` frame with an opaque origin.
776
+ *
777
+ * - `data-kit-toggle="<id>"`: a click shows or hides the element with that
778
+ * id and sets `aria-expanded` on its triggers; a click outside it, or Esc,
779
+ * closes what a toggle opened.
780
+ * - `data-kit-tabs` holds `[data-kit-tab="<name>"]` triggers and
781
+ * `[data-kit-panel="<name>"]` panels; a click shows its panel, hides the
782
+ * others and sets `aria-selected`.
783
+ * - `data-kit-set="<group>:<name>"` shows every `[data-kit-state]` of the
784
+ * group in that state and hides the rest of the group.
785
+ *
786
+ * A trigger that already has `data-state` (Radix-style markup) gets
787
+ * open/closed or active/inactive there as well, so its captured styling
788
+ * follows. Triggers that are not focusable get `tabindex="0"`, and Enter or
789
+ * Space clicks them.
790
+ */
791
+ const BEHAVIOURS_JS = `(function () {
792
+ "use strict";
793
+ if (window.__designKitBehaviours) return;
794
+ window.__designKitBehaviours = true;
795
+ var doc = document;
796
+ var TRIGGERS = "[data-kit-toggle],[data-kit-tab],[data-kit-set]";
797
+ var NATIVE = /^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY)$/;
798
+ var opened = [];
799
+ function list(selector, root) {
800
+ return Array.prototype.slice.call((root || doc).querySelectorAll(selector));
801
+ }
802
+ function show(el, on) {
803
+ if (on) el.removeAttribute("hidden");
804
+ else el.setAttribute("hidden", "");
805
+ }
806
+ function pair(value) {
807
+ var at = (value || "").indexOf(":");
808
+ return at > 0 ? [value.slice(0, at), value.slice(at + 1)] : null;
809
+ }
810
+ function mirror(el, value) {
811
+ if (el.hasAttribute("data-state")) el.setAttribute("data-state", value);
812
+ }
813
+ function focusable(el) {
814
+ if (NATIVE.test(el.tagName) || el.hasAttribute("tabindex")) return;
815
+ el.setAttribute("tabindex", "0");
816
+ if (!el.hasAttribute("role")) {
817
+ el.setAttribute("role", el.hasAttribute("data-kit-tab") ? "tab" : "button");
818
+ }
819
+ }
820
+
821
+ function triggersOf(id) {
822
+ return list("[data-kit-toggle]").filter(function (t) {
823
+ return t.getAttribute("data-kit-toggle") === id;
824
+ });
825
+ }
826
+ function setOpen(target, open) {
827
+ show(target, open);
828
+ triggersOf(target.id).forEach(function (t) {
829
+ t.setAttribute("aria-expanded", open ? "true" : "false");
830
+ mirror(t, open ? "open" : "closed");
831
+ });
832
+ var at = opened.indexOf(target);
833
+ if (open && at < 0) opened.push(target);
834
+ if (!open && at >= 0) opened.splice(at, 1);
835
+ }
836
+ function closeOpened(clicked) {
837
+ opened.slice().forEach(function (target) {
838
+ var inside =
839
+ clicked &&
840
+ (target.contains(clicked) ||
841
+ triggersOf(target.id).some(function (t) {
842
+ return t.contains(clicked);
843
+ }));
844
+ if (!inside) setOpen(target, false);
845
+ });
846
+ }
847
+
848
+ function own(box, selector) {
849
+ return list(selector, box).filter(function (el) {
850
+ return el.closest("[data-kit-tabs]") === box;
851
+ });
852
+ }
853
+ function selectTab(box, name) {
854
+ own(box, "[data-kit-tab]").forEach(function (t) {
855
+ var on = t.getAttribute("data-kit-tab") === name;
856
+ t.setAttribute("aria-selected", on ? "true" : "false");
857
+ mirror(t, on ? "active" : "inactive");
858
+ });
859
+ own(box, "[data-kit-panel]").forEach(function (p) {
860
+ var on = p.getAttribute("data-kit-panel") === name;
861
+ show(p, on);
862
+ mirror(p, on ? "active" : "inactive");
863
+ });
864
+ }
865
+
866
+ function setState(group, name) {
867
+ list("[data-kit-state]").forEach(function (el) {
868
+ var state = pair(el.getAttribute("data-kit-state"));
869
+ if (state && state[0] === group) show(el, state[1] === name);
870
+ });
871
+ }
872
+
873
+ list("[data-kit-toggle]").forEach(function (t) {
874
+ focusable(t);
875
+ var target = doc.getElementById(t.getAttribute("data-kit-toggle"));
876
+ if (!target) return;
877
+ target.setAttribute("data-kit-target", "");
878
+ t.setAttribute("aria-controls", target.id);
879
+ t.setAttribute("aria-expanded", target.hasAttribute("hidden") ? "false" : "true");
880
+ });
881
+ list("[data-kit-tabs]").forEach(function (box) {
882
+ var tabs = own(box, "[data-kit-tab]");
883
+ if (!tabs.length) return;
884
+ tabs.forEach(focusable);
885
+ var current = tabs.filter(function (t) {
886
+ return t.getAttribute("aria-selected") === "true" || t.getAttribute("data-state") === "active";
887
+ })[0] || tabs[0];
888
+ selectTab(box, current.getAttribute("data-kit-tab"));
889
+ });
890
+ // Each group starts in the state of its first visible element.
891
+ var groups = Object.create(null);
892
+ list("[data-kit-state]").forEach(function (el) {
893
+ var state = pair(el.getAttribute("data-kit-state"));
894
+ if (state && !(state[0] in groups) && !el.hasAttribute("hidden")) groups[state[0]] = state[1];
895
+ });
896
+ Object.keys(groups).forEach(function (group) {
897
+ setState(group, groups[group]);
898
+ });
899
+ list("[data-kit-set]").forEach(focusable);
900
+
901
+ doc.addEventListener("click", function (event) {
902
+ var el = event.target;
903
+ if (!el || !el.closest) return;
904
+ closeOpened(el);
905
+ var trigger = el.closest(TRIGGERS);
906
+ if (!trigger) return;
907
+ // A link or button in a trigger (a captured story's own links) would
908
+ // navigate the mockup away or submit; the click only switches state.
909
+ var control = el.closest("a,button");
910
+ if (control && trigger.contains(control)) event.preventDefault();
911
+ var state = pair(trigger.getAttribute("data-kit-set"));
912
+ if (state) setState(state[0], state[1]);
913
+ var box = trigger.hasAttribute("data-kit-tab") && trigger.closest("[data-kit-tabs]");
914
+ if (box) selectTab(box, trigger.getAttribute("data-kit-tab"));
915
+ if (trigger.hasAttribute("data-kit-toggle")) {
916
+ var target = doc.getElementById(trigger.getAttribute("data-kit-toggle"));
917
+ if (target) setOpen(target, target.hasAttribute("hidden"));
918
+ }
919
+ });
920
+ doc.addEventListener("keydown", function (event) {
921
+ var el = event.target;
922
+ if (event.key === "Escape" && opened.length) {
923
+ var back = triggersOf(opened[opened.length - 1].id)[0];
924
+ closeOpened(null);
925
+ if (back && back.focus) back.focus();
926
+ return;
927
+ }
928
+ if (
929
+ (event.key === "Enter" || event.key === " ") &&
930
+ el && el.matches && el.matches(TRIGGERS) && !NATIVE.test(el.tagName)
931
+ ) {
932
+ event.preventDefault();
933
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
934
+ }
935
+ });
936
+ })();`;
937
+
938
+ /**
939
+ * The author's own inline scripts in a slot fragment, held out of compose:
940
+ * each becomes a comment placeholder carrying `key` (random per compose, so
941
+ * kit markup cannot name one) before snippets expand and the sanitizer runs,
942
+ * and comes back after. A script with `src`, `href` or `xlink:href` stays in
943
+ * place for the sanitizer to drop, as does an unterminated one and an earlier
944
+ * compose's behaviour script (compose inlines a fresh one). Only `type` and
945
+ * `id` are kept on the tag.
946
+ */
947
+ function holdAuthorScripts(content, key, held) {
948
+ if (!/<script/i.test(content)) return content;
949
+ const tokens = tokenize(content);
950
+ let out = "";
951
+ let i = 0;
952
+ for (let t = 0; t < tokens.length; t += 1) {
953
+ const token = tokens[t];
954
+ if (token.type !== "open" || token.name !== "script" || token.selfClosing)
955
+ continue;
956
+ let close = t + 1;
957
+ if (tokens[close]?.type === "text") close += 1;
958
+ if (tokens[close]?.type !== "close" || tokens[close].name !== "script")
959
+ continue;
960
+ t = close;
961
+ const attrs = token.attrs;
962
+ if (
963
+ ["src", "href", "xlink:href", "data-kit-behaviours"].some((name) =>
964
+ Object.hasOwn(attrs, name),
965
+ )
966
+ )
967
+ continue;
968
+ const kept = ["type", "id"]
969
+ .filter((name) => Object.hasOwn(attrs, name))
970
+ .map((name) => ` ${name}="${escapeAttr(decodeEntities(attrs[name]))}"`)
971
+ .join("");
972
+ out += `${content.slice(i, token.start)}<!--kit-author-script ${key} ${held.length}-->`;
973
+ held.push(
974
+ `<script${kept}>${content.slice(token.end, tokens[close].start)}</script>`,
975
+ );
976
+ i = tokens[close].end;
977
+ }
978
+ return out + content.slice(i);
979
+ }
980
+
981
+ /** Each held script back in its placeholder's place, once. */
982
+ function restoreAuthorScripts(html, key, held) {
983
+ if (!held.length) return html;
984
+ const placed = new Set();
985
+ return html.replace(
986
+ new RegExp(`<!--kit-author-script ${key} (\\d+)-->`, "g"),
987
+ (_, n) => {
988
+ const index = Number(n);
989
+ if (placed.has(index) || index >= held.length) return "";
990
+ placed.add(index);
991
+ return held[index];
992
+ },
993
+ );
994
+ }
995
+
712
996
  // ── compose ─────────────────────────────────────────────────────────────────
713
997
 
714
998
  /**
@@ -717,23 +1001,37 @@ export function inlineAssets(kit, css, cssDir = join(kit.dir, "css")) {
717
1001
  * nested `<kit-snippet>` at the top is left alone: its own expansion stamps
718
1002
  * the elements that replace it.
719
1003
  */
720
- function stampSnippetRoots(html, id) {
721
- const roots = elements(html).filter(
722
- (e) =>
723
- e.depth === 0 &&
724
- e.name !== "kit-snippet" &&
725
- !("data-kit-snippet" in e.attrs),
726
- );
1004
+ function stampSnippetRoots(html, id, carried = "") {
727
1005
  let out = "";
728
1006
  let i = 0;
729
- for (const root of roots) {
1007
+ for (const root of elements(html)) {
1008
+ if (root.depth !== 0) continue;
1009
+ const stamp =
1010
+ root.name !== "kit-snippet" && !("data-kit-snippet" in root.attrs)
1011
+ ? ` data-kit-snippet="${escapeAttr(id)}"`
1012
+ : "";
1013
+ if (!stamp && !carried) continue;
730
1014
  const at = root.start + 1 + root.name.length;
731
- out += `${html.slice(i, at)} data-kit-snippet="${escapeAttr(id)}"`;
1015
+ out += `${html.slice(i, at)}${stamp}${carried}`;
732
1016
  i = at;
733
1017
  }
734
1018
  return out + html.slice(i);
735
1019
  }
736
1020
 
1021
+ /**
1022
+ * The behaviour attributes a `<kit-snippet>` passes on to the top-level
1023
+ * elements of the markup it expands to, so a captured story can be one state
1024
+ * of a group (`data-kit-state`), a trigger, a tab or a panel. They go first
1025
+ * in the tag, so they win over any the captured element already has.
1026
+ */
1027
+ const CARRIED_ATTRS = ["hidden", ...BEHAVIOUR_ATTRS];
1028
+
1029
+ function carriedAttributes(attrs) {
1030
+ return CARRIED_ATTRS.filter((name) => Object.hasOwn(attrs, name))
1031
+ .map((name) => ` ${name}="${escapeAttr(decodeEntities(attrs[name]))}"`)
1032
+ .join("");
1033
+ }
1034
+
737
1035
  function expandSnippets(kit, html, used, budget, depth = 0, ids = []) {
738
1036
  const found = elements(html).filter((e) => e.name === "kit-snippet");
739
1037
  if (!found.length) return html;
@@ -763,7 +1061,7 @@ function expandSnippets(kit, html, used, budget, depth = 0, ids = []) {
763
1061
  html.slice(i, element.start) +
764
1062
  expandSnippets(
765
1063
  kit,
766
- stampSnippetRoots(markup, row.id),
1064
+ stampSnippetRoots(markup, row.id, carriedAttributes(element.attrs)),
767
1065
  used,
768
1066
  budget,
769
1067
  depth + 1,
@@ -887,7 +1185,10 @@ function attributes(captured, extraClass, theme, choice, target, fixed) {
887
1185
  /**
888
1186
  * One self-contained page: the frame's markup with slots filled and
889
1187
  * snippets expanded, its CSS (and that of any other source a snippet came
890
- * from) inlined with fonts as data URIs, and no scripts or remote URLs.
1188
+ * from) inlined with fonts as data URIs, and no remote URLs. The only
1189
+ * scripts are the inline ones the slot fragments brought and, when the page
1190
+ * uses a behaviour attribute, the behaviour layer; nothing executable from
1191
+ * the kit's own markup survives.
891
1192
  */
892
1193
  export function compose(
893
1194
  kit,
@@ -904,9 +1205,19 @@ export function compose(
904
1205
  throw new KitError(`unknown theme "${theme}". Use light or dark.`);
905
1206
  }
906
1207
  const used = new Set([rowCssFile(kit, row)]);
907
- const filled = fillSlots(kit, row, readSnippet(kit, row), slots);
1208
+ // The author's inline scripts sit out the sanitizer; everything else in
1209
+ // the fragments (handlers, remote scripts, snippets) goes through it.
1210
+ const scriptKey = randomBytes(16).toString("hex");
1211
+ const heldScripts = [];
1212
+ const authored = Array.isArray(slots)
1213
+ ? slots.map((slot) => ({
1214
+ ...slot,
1215
+ content: holdAuthorScripts(slot.content, scriptKey, heldScripts),
1216
+ }))
1217
+ : slots;
1218
+ const filled = fillSlots(kit, row, readSnippet(kit, row), authored);
908
1219
  const snippetIds = [];
909
- const body = sanitizeMarkup(
1220
+ const sanitized = sanitizeMarkup(
910
1221
  expandSnippets(
911
1222
  kit,
912
1223
  filled,
@@ -916,6 +1227,11 @@ export function compose(
916
1227
  snippetIds,
917
1228
  ),
918
1229
  );
1230
+ const bodyElements = elements(sanitized);
1231
+ const behaves = bodyElements.some((e) =>
1232
+ BEHAVIOUR_ATTRS.some((name) => Object.hasOwn(e.attrs, name)),
1233
+ );
1234
+ const body = restoreAuthorScripts(sanitized, scriptKey, heldScripts);
919
1235
 
920
1236
  // Stories' sheet first, then the route sheets, as before page sources.
921
1237
  const storiesCss = cssFile(kit, "stories");
@@ -931,8 +1247,14 @@ export function compose(
931
1247
  return inlineAssets(kit, readFileSync(file, "utf8"), dirname(file));
932
1248
  })
933
1249
  .join("\n");
934
- const showNewCss = showNew
935
- ? "\n[data-kit-new]{outline:2px dashed #e5484d !important;outline-offset:2px}"
1250
+ const showNewCss =
1251
+ (showNew
1252
+ ? "\n[data-kit-new]{outline:2px dashed #e5484d !important;outline-offset:2px}"
1253
+ : "") +
1254
+ (behaves ? BEHAVIOURS_CSS : "") +
1255
+ (showNew && behaves ? SHOW_TRIGGERS_CSS : "");
1256
+ const behaviourScript = behaves
1257
+ ? `<script data-kit-behaviours>\n${BEHAVIOURS_JS}\n</script>\n`
936
1258
  : "";
937
1259
 
938
1260
  const switcher = kitTheme(kit.manifest);
@@ -956,7 +1278,7 @@ export function compose(
956
1278
  kit_sha: sha,
957
1279
  frame,
958
1280
  snippets: snippetIds,
959
- new_parts: elements(body).filter((e) => "data-kit-new" in e.attrs).length,
1281
+ new_parts: bodyElements.filter((e) => "data-kit-new" in e.attrs).length,
960
1282
  };
961
1283
  const page = `<!doctype html>
962
1284
  <html${htmlAttrs}>
@@ -971,7 +1293,7 @@ ${sanitizeCss(css)}${showNewCss}
971
1293
  </head>
972
1294
  <body${bodyAttrs}>
973
1295
  ${body}
974
- </body>
1296
+ ${behaviourScript}</body>
975
1297
  </html>
976
1298
  `;
977
1299
  return page;