@jxsuite/runtime 3.0.0 → 4.0.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/src/runtime.ts CHANGED
@@ -17,15 +17,36 @@ import {
17
17
  computed,
18
18
  effect,
19
19
  effectScope,
20
+ isReactive,
20
21
  isRef,
21
22
  onEffectCleanup,
23
+ onScopeDispose,
24
+ pauseTracking,
22
25
  reactive,
23
26
  ref,
27
+ resetTracking,
28
+ shallowReactive,
24
29
  toRaw,
25
30
  } from "@vue/reactivity";
31
+ import {
32
+ buildStyleRules,
33
+ cssPropertyName,
34
+ hashCss,
35
+ isNestedSelectorKey,
36
+ transposeCanvasOverlaySelector,
37
+ } from "./css.ts";
26
38
  import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
27
39
  import { readPath } from "./pointer.ts";
28
- import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
40
+ import type {
41
+ DynamicClass,
42
+ JxContext,
43
+ JxEventHandler,
44
+ JxMount,
45
+ JxMountOptions,
46
+ JxPath,
47
+ JxRenderOptions,
48
+ JxScope,
49
+ } from "./types.ts";
29
50
  import {
30
51
  bodyReturnsValue,
31
52
  hasStructuredBody,
@@ -58,7 +79,7 @@ import type {
58
79
  JxServerFnDef,
59
80
  JxStyle,
60
81
  } from "@jxsuite/schema/types";
61
- import type { Ref } from "@vue/reactivity";
82
+ import type { EffectScope, Ref } from "@vue/reactivity";
62
83
 
63
84
  // ─── Public API ───────────────────────────────────────────────────────────────
64
85
 
@@ -109,11 +130,76 @@ export async function Jx(
109
130
  ? new URL(source, location.href).href
110
131
  : location.href;
111
132
  const doc = await resolve(source);
133
+
134
+ /* The module-level fallback is still written here, on purpose: a component defined LATER with
135
+ `@--name` blocks and no own `$media` reads it (setRootMedia). `mount()` never writes it — a
136
+ mount's `$media` travels in its context, which is the whole point of having one. */
137
+ if (doc.$media) {
138
+ _rootMedia = doc.$media;
139
+ }
140
+
141
+ const mounted = await mount(doc, target, {
142
+ base,
143
+ ...(options?.onNodeCreated ? { onNodeCreated: options.onNodeCreated } : {}),
144
+ ...(options?.signal ? { signal: options.signal } : {}),
145
+ });
146
+ return mounted.scope;
147
+ }
148
+
149
+ /** Host-scope keys the runtime owns: `$media`, `$map`, the `$`-locals and `#`-private entries. */
150
+ const HOST_SCOPE_RESERVED = /^[$#]/;
151
+
152
+ function assertHostScope(scope: Record<string, unknown>): void {
153
+ for (const key of Object.keys(scope)) {
154
+ if (HOST_SCOPE_RESERVED.test(key)) {
155
+ throw new TypeError(
156
+ `Jx mount: host scope key "${key}" is reserved — names beginning with "$" or "#" are ` +
157
+ `runtime vocabulary`,
158
+ );
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Mount a document into a host-owned node and hand back the live scope with a disposer.
165
+ *
166
+ * This is the embedding contract (specs/embedding.md): the host supplies its own reactive records
167
+ * and functions as `options.scope`, a per-mount base, `$media` and document resolver, and gets a
168
+ * root it can remove and a `dispose()` that stops every effect the render created. Nothing here
169
+ * writes a module global, so a shell can hold many roots with different needs at once.
170
+ *
171
+ * @param {JxDocument} doc - The document to render (already an object; use {@link resolve} for a
172
+ * URL)
173
+ * @param {ParentNode} target - Where the rendered root is appended
174
+ * @param {JxMountOptions} [options]
175
+ * @returns {Promise<JxMount>}
176
+ * @docs extending/embedding/runtime-host
177
+ */
178
+ export async function mount(
179
+ doc: JxDocument,
180
+ target: ParentNode,
181
+ options: JxMountOptions = {},
182
+ ): Promise<JxMount> {
183
+ const hostScope = options.scope ?? {};
184
+ assertHostScope(hostScope);
185
+ const base = options.base ? new URL(options.base, location.href).href : location.href;
186
+ const ctx: JxContext = {
187
+ base,
188
+ media: options.media ?? doc.$media ?? null,
189
+ root: null,
190
+ ...(options.resolver ? { resolver: options.resolver } : {}),
191
+ ...(options.skipServerFunctions === undefined
192
+ ? {}
193
+ : { skipServerFunctions: options.skipServerFunctions }),
194
+ ...(options.skipAutoRequests === undefined
195
+ ? {}
196
+ : { skipAutoRequests: options.skipAutoRequests }),
197
+ };
112
198
  checkSchemaVersion(doc.$schema);
113
199
 
114
200
  // Register custom elements declared in $elements (depth-first)
115
201
  if (doc.$elements) {
116
- await registerElements(doc.$elements, base);
202
+ await registerElements(doc.$elements, base, ctx);
117
203
  }
118
204
 
119
205
  // Inject <head> elements declared in $head (link, meta, script, etc.)
@@ -121,26 +207,86 @@ export async function Jx(
121
207
  injectHead(doc.$head, base);
122
208
  }
123
209
 
124
- if (doc.$media) {
125
- _rootMedia = doc.$media;
126
- }
210
+ const scope = await buildScope(doc, hostScope, base, ctx);
211
+
212
+ const elements = new Set<string>();
213
+ const renderOptions: JxRenderOptions = {
214
+ _ctx: ctx,
215
+ onNodeCreated: (el, path, def, state) => {
216
+ if (el instanceof Element && el.tagName.includes("-")) {
217
+ elements.add(el.tagName.toLowerCase());
218
+ }
219
+ options.onNodeCreated?.(el, path, def, state);
220
+ },
221
+ ...(options.onNodeMoved ? { onNodeMoved: options.onNodeMoved } : {}),
222
+ };
223
+ const { result: root, stop } = runScoped(() => renderNode(doc, scope, renderOptions));
224
+ ctx.root = root;
127
225
 
128
- const state = await buildScope(doc, {}, base);
129
- target.append(renderNode(doc, state, options));
130
- if (typeof state.onMount === "function") {
131
- (state.onMount as (s: JxScope) => unknown)(state);
226
+ let mounted = false;
227
+ let disposed = false;
228
+ const dispose = (): void => {
229
+ if (disposed) {
230
+ return;
231
+ }
232
+ disposed = true;
233
+ options.signal?.removeEventListener("abort", dispose);
234
+ stop();
235
+ if (mounted && typeof scope.onUnmount === "function") {
236
+ (scope.onUnmount as (s: JxScope) => unknown)(scope);
237
+ }
238
+ root.remove();
239
+ };
240
+
241
+ if (options.signal?.aborted) {
242
+ // Aborted before it could be attached: tear the effects down and attach nothing.
243
+ dispose();
244
+ } else {
245
+ options.signal?.addEventListener("abort", dispose, { once: true });
246
+ target.append(root);
247
+ mounted = true;
248
+ if (typeof scope.onMount === "function") {
249
+ (scope.onMount as (s: JxScope) => unknown)(scope);
250
+ }
132
251
  }
133
- return state;
252
+ return { scope, root, elements, dispose };
134
253
  }
135
254
 
136
255
  // ─── Step 1: Resolve ──────────────────────────────────────────────────────────
137
256
 
138
257
  const _resolveCache = new Map<string, Promise<JxDocument>>();
139
258
 
140
- export async function resolve(source: string | JxDocument): Promise<JxDocument> {
259
+ /**
260
+ * Seed the resolve cache with a document a host already holds, under the URL its references use.
261
+ *
262
+ * A bundled document — one imported as JSON rather than fetched — still names its dependencies by
263
+ * URL: an `$elements` entry, a `$switch` case. Preloading those under the same URLs lets every such
264
+ * reference resolve with no network, and lets `defineElement("<url>")` dedupe on the source it
265
+ * would otherwise have fetched. The key is stored as given and, when it parses as a URL, in its
266
+ * absolute form too, because the two lookup paths spell it differently.
267
+ *
268
+ * @param {string} url - The URL the document is known by
269
+ * @param {JxDocument} doc
270
+ */
271
+ export function preloadDocument(url: string, doc: JxDocument): void {
272
+ const settled = Promise.resolve(doc);
273
+ _resolveCache.set(url, settled);
274
+ try {
275
+ _resolveCache.set(new URL(url, location.href).href, settled);
276
+ } catch {
277
+ // Not a URL at all — the raw key still serves the lookup that uses it.
278
+ }
279
+ }
280
+
281
+ export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
141
282
  if (typeof source !== "string") {
142
283
  return source;
143
284
  }
285
+ // A mount's resolver answers for THIS mount and its hit never enters the shared cache.
286
+ const served = ctx?.resolver?.(source);
287
+ if (served) {
288
+ return served;
289
+ }
144
290
  if (_resolveCache.has(source)) {
145
291
  return _resolveCache.get(source)!;
146
292
  }
@@ -328,11 +474,124 @@ export function setCanvasDelinkAnchors(on: boolean) {
328
474
  _canvasDelinkAnchors = on;
329
475
  }
330
476
 
477
+ /**
478
+ * Studio-canvas popover de-linking — `setCanvasDelinkAnchors`'s rule applied to `popover`.
479
+ *
480
+ * An OPEN popover is in the top layer, and CSS Position 4 §3.1 gives a top-layer element the
481
+ * VIEWPORT as its containing block whatever its ancestors say. In the editor canvas that viewport
482
+ * is a fiction: the frame is sized to its own content height, so a drawer pinned with `inset: 0`
483
+ * lands halfway down a 6000px page, and a panel taller than a short component frame is clipped by
484
+ * the `overflow: hidden` the canvas document needs. Worse, a top-layer box contributes nothing to
485
+ * any ancestor's scrollable overflow, so the artboard can never grow to fit one.
486
+ *
487
+ * When this is on, the runtime stamps `popover` on `data-jx-popover` for nodes the studio can
488
+ * ADDRESS — those carrying a stamped `data-jx-path`. That drops every `[popover]` UA rule at once,
489
+ * and the one that matters most is `position: fixed`: with it gone the panel lays out in NORMAL
490
+ * FLOW at its document position, which is what makes it contribute to `#jx-canvas-root`'s
491
+ * scrollHeight so the host can grow the artboard to fit it. Leaving the top layer is necessary but
492
+ * not sufficient — a fixed box contributes nothing to an ancestor's overflow either.
493
+ *
494
+ * It is worth saying what does NOT rescue this, because it is the intuitive answer and it is wrong:
495
+ * `container-type: size` on the canvas's query container does NOT make it a containing block for
496
+ * fixed descendants. Measured in Chrome 151 — `getComputedStyle(container).contain` is `none`, and
497
+ * a `position: fixed` child inside it measures the WINDOW, not the container. So a panel that stays
498
+ * fixed stays laid out against the frame's own viewport, which in an editable mode is the
499
+ * document's full height. `iframe-render.ts` therefore FORCES `position` on the open panel rather
500
+ * than relying on any containment.
501
+ *
502
+ * The studio re-supplies the one UA rule that matters — `display: none` while closed — inside a
503
+ * cascade LAYER, so author declarations still beat it exactly as they beat the real UA rule on the
504
+ * shipped page. That is deliberate: a popover whose base rule sets `display` is broken in
505
+ * production, and the canvas has to show that rather than paper over it.
506
+ *
507
+ * The gate on `data-jx-path` is what keeps this out of documents the studio does not own: a popover
508
+ * rendered inside a component's own template is never stamped, so it keeps its native behaviour.
509
+ *
510
+ * It also drives {@link transposeCanvasPopoverSelector}. The two are one transform — an attribute
511
+ * renamed without its selectors transposed is a popover that can never be styled open.
512
+ *
513
+ * @docs framework/concepts/overlays
514
+ */
515
+ let _canvasDelinkPopovers = false;
516
+ export function setCanvasDelinkPopovers(on: boolean) {
517
+ _canvasDelinkPopovers = on;
518
+ }
519
+
520
+ /**
521
+ * Studio-canvas de-linking of invoker commands, `inert` and a dialog's `open` — the dialog half of
522
+ * {@link setCanvasDelinkPopovers}.
523
+ *
524
+ * A `<button command="show-modal" commandfor="…">` is the platform's own invoker: clicking it calls
525
+ * `showModal()` on its target, which puts the dialog in the top layer and makes the rest of the
526
+ * page INERT — the two things an editable canvas can least afford, since the first is the geometry
527
+ * the popover comment above describes and the second makes every other element unclickable. So on a
528
+ * stamped node `commandfor` is renamed to `data-jx-commandfor`: a button with `command` and no
529
+ * target does nothing, and the studio's frame reports the click instead, so the host's single
530
+ * writer of open state can answer it. `popovertarget` is left alone on purpose — its target has no
531
+ * `popover` attribute on the canvas, so the platform already does nothing with it.
532
+ *
533
+ * `inert` becomes `data-jx-inert` for the same reason: an author's inert region is a region the
534
+ * editor could not select into. And a `<dialog open>` becomes `<dialog data-jx-open>`, so the UA's
535
+ * `dialog:not([open]) { display: none }` keeps every dialog closed until the canvas opens ONE with
536
+ * `data-jx-dialog-open`, exactly as it opens one popover. `<details open>` is untouched: its `open`
537
+ * is content, not an overlay state.
538
+ *
539
+ * Gated on `data-jx-path` like the popover rewrite, and driving
540
+ * {@link transposeCanvasOverlaySelector} with it: an attribute renamed without its selectors
541
+ * transposed is a dialog that can never be styled open, or a region that can never be styled inert.
542
+ * Both renames travel with their selectors there — `[open]` on a dialog's own rules, `[inert]`
543
+ * wherever it appears.
544
+ *
545
+ * @docs framework/concepts/overlays
546
+ */
547
+ let _canvasDelinkCommands = false;
548
+ export function setCanvasDelinkCommands(on: boolean) {
549
+ _canvasDelinkCommands = on;
550
+ }
551
+
552
+ /**
553
+ * Raised while a defined element renders its own children, so the canvas de-link reaches them.
554
+ *
555
+ * The de-link rules gate on `data-jx-path`, which a document node carries and an element's INTERNAL
556
+ * node never can: the internals belong to the definition, not to the page being edited, so the
557
+ * studio's stamper never sees them. Without this a kit element that declares `popover` on a panel
558
+ * inside itself opens a genuine top-layer popover inside an editable canvas, while Studio's single
559
+ * writer of open state learns nothing about it.
560
+ *
561
+ * A module-scoped flag is sound here because `canvasAttrName` is called once per attribute OUTSIDE
562
+ * the effect that binds it, and a definition renders its children synchronously — so the flag is
563
+ * never read across a suspension point.
564
+ */
565
+ let _canvasInsideStampedHost = false;
566
+
567
+ /** Whether the node being rendered is inside a stamped host, and so part of the edited page. */
568
+ function canvasStamped(el: HTMLElement): boolean {
569
+ return _canvasInsideStampedHost || el.dataset.jxPath !== undefined;
570
+ }
571
+
331
572
  /** The attribute name to stamp `key` on `el` under — `href` → `data-jx-href` on de-linked anchors. */
332
573
  function canvasAttrName(el: HTMLElement, key: string): string {
333
574
  if (_canvasDelinkAnchors && key === "href" && (el.tagName === "A" || el.tagName === "AREA")) {
334
575
  return "data-jx-href";
335
576
  }
577
+ /* `dataset.jxPath` is already stamped here: `onNodeCreated` fires before `applyAttributes` in
578
+ `renderNode`, and the studio's stamper writes the attribute synchronously inside it. That
579
+ ordering is an unwritten contract between two packages, so `runtime-canvas.test.ts` asserts it
580
+ directly rather than trusting it. */
581
+ if (_canvasDelinkPopovers && key === "popover" && canvasStamped(el)) {
582
+ return "data-jx-popover";
583
+ }
584
+ if (_canvasDelinkCommands && canvasStamped(el)) {
585
+ if (key === "commandfor") {
586
+ return "data-jx-commandfor";
587
+ }
588
+ if (key === "inert") {
589
+ return "data-jx-inert";
590
+ }
591
+ if (key === "open" && el.tagName === "DIALOG") {
592
+ return "data-jx-open";
593
+ }
594
+ }
336
595
  return key;
337
596
  }
338
597
 
@@ -504,22 +763,41 @@ function boundPropKey(key: string, val: unknown, state: JxScope): string | null
504
763
  * @param {JxDocument} doc
505
764
  * @param {JxScope} [parentScope] Default is `{}`
506
765
  * @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
766
+ * @param {JxContext} [ctx] The mount context; absent on the legacy direct path
507
767
  * @returns {Promise<JxScope>} Reactive proxy (state)
508
768
  */
509
769
  export async function buildScope(
510
770
  doc: JxDocument,
511
771
  parentScope: JxScope = {},
512
772
  base: string = location.href,
773
+ ctx?: JxContext,
513
774
  ) {
775
+ // A body run without an event dispatches from the mount's root, read when it fires.
776
+ const dispatchRoot = (): EventTarget | null => ctx?.root ?? null;
514
777
  const raw: JxScope = {};
778
+ const defs = doc.state ?? {};
515
779
 
516
- // Merge parent scope properties
517
- for (const [key, val] of Object.entries(parentScope)) {
518
- raw[key] = val;
780
+ /* Merge parent scope properties. A REACTIVE parent stays live: its members are read through
781
+ accessors rather than copied, so a host that hands in a reactive record as the scope sees the
782
+ document follow every field of it, not a snapshot of the primitives it held at mount time. A
783
+ member the document declares itself is copied instead, so the document's own entry can still
784
+ overwrite it — the document wins (embedding.md §3). */
785
+ const linked = isReactive(parentScope);
786
+ for (const key of Object.keys(parentScope)) {
787
+ if (linked && !(key in defs)) {
788
+ Object.defineProperty(raw, key, {
789
+ configurable: true,
790
+ enumerable: true,
791
+ get: () => parentScope[key],
792
+ set: (value: unknown) => {
793
+ parentScope[key] = value;
794
+ },
795
+ });
796
+ } else {
797
+ raw[key] = parentScope[key];
798
+ }
519
799
  }
520
800
 
521
- const defs = doc.state ?? {};
522
-
523
801
  // Pass 0: resolve bare $prototype names via import map
524
802
  const imports = doc.imports ?? {};
525
803
  for (const [, def] of Object.entries(defs)) {
@@ -641,11 +919,11 @@ export async function buildScope(
641
919
  ? p.default
642
920
  : argValues[i];
643
921
  }
644
- return runStatements(body, state, null, { args });
922
+ return runStatements(body, state, null, { args, target: dispatchRoot });
645
923
  };
646
924
  } else {
647
925
  const handler: JxEventHandler = (s, event) => {
648
- void runStatements(body, s, event ?? null);
926
+ void runStatements(body, s, event ?? null, { target: dispatchRoot });
649
927
  };
650
928
  state[key] = handler;
651
929
  }
@@ -657,12 +935,12 @@ export async function buildScope(
657
935
  // Fourth pass: other $prototype entries (Request, Set, Map, etc.)
658
936
  for (const [key, def] of Object.entries(defs)) {
659
937
  if (isPrototypeDef(def)) {
660
- state[key] = await resolvePrototype(def, state, key, base);
938
+ state[key] = await resolvePrototype(def, state, key, base, ctx);
661
939
  }
662
940
  }
663
941
 
664
942
  // Fifth pass: timing: "server" entries (dev mode — execute client-side, boundary unenforced)
665
- if (!_serverFnConfig.skip) {
943
+ if (!(ctx?.skipServerFunctions ?? _serverFnConfig.skip)) {
666
944
  for (const [key, def] of Object.entries(defs)) {
667
945
  if (isServerFnDef(def)) {
668
946
  state[key] = await resolveServerFunction(def, state, key, base);
@@ -670,10 +948,11 @@ export async function buildScope(
670
948
  }
671
949
  }
672
950
 
951
+ const rootMedia = ctx?.media ?? _rootMedia;
673
952
  if (doc.$media) {
674
953
  state["$media"] = doc.$media;
675
- } else if (!state["$media"] && Object.keys(_rootMedia).length > 0) {
676
- state["$media"] = _rootMedia;
954
+ } else if (!state["$media"] && Object.keys(rootMedia).length > 0) {
955
+ state["$media"] = rootMedia;
677
956
  }
678
957
 
679
958
  return state;
@@ -795,6 +1074,88 @@ interface ExternalClassInstance {
795
1074
  /** Module cache for $src imports (shared with external class resolution). */
796
1075
  const _moduleCache = new Map<string, ImportedModule>();
797
1076
 
1077
+ /** A module the host will import on first use, keyed as the document spells its `$src`. */
1078
+ type ModuleLoader = () => Promise<Record<string, unknown>>;
1079
+
1080
+ /**
1081
+ * Lazy registrations: a loader until it runs, then the in-flight load, so two entries resolving the
1082
+ * same sidecar in one tick share one import rather than racing two.
1083
+ */
1084
+ const _moduleLoaders = new Map<string, ModuleLoader | Promise<ImportedModule>>();
1085
+
1086
+ /**
1087
+ * Seed the `$src` module cache with a module the host already imported, under the specifier the
1088
+ * document spells. A bundled document cannot have its sidecar fetched by URL — the bundler saw
1089
+ * neither the string nor the file — so the host imports the sidecar itself and registers it here,
1090
+ * and `$src` then resolves exactly as it would have from the network.
1091
+ *
1092
+ * Given a FUNCTION instead of a namespace, the registration is lazy: nothing is imported until a
1093
+ * document names the specifier, and the loader runs once. That is the shape for a host that can
1094
+ * bundle a sidecar as its own chunk but should not pay for it until something renders — Studio's
1095
+ * canvas frame registers every kit behaviour this way, so a page that uses no kit element loads
1096
+ * none of them (embedding.md §6).
1097
+ *
1098
+ * @param {string} specifier - The `$src` value as written in the document
1099
+ * @param {Record<string, unknown> | (() => Promise<Record<string, unknown>>)} mod - The imported
1100
+ * module namespace, or a loader that imports it on first use
1101
+ */
1102
+ export function preloadModule(
1103
+ specifier: string,
1104
+ mod: Record<string, unknown> | ModuleLoader,
1105
+ ): void {
1106
+ if (typeof mod === "function") {
1107
+ _moduleCache.delete(specifier);
1108
+ _moduleLoaders.set(specifier, mod);
1109
+ return;
1110
+ }
1111
+ _moduleLoaders.delete(specifier);
1112
+ _moduleCache.set(specifier, mod as ImportedModule);
1113
+ }
1114
+
1115
+ /**
1116
+ * The module a specifier was seeded with — imported, or lazily registered — or undefined when the
1117
+ * host said nothing about it and the network is the answer.
1118
+ *
1119
+ * SYNCHRONOUS for a namespace the host imported, and that is load-bearing rather than tidy: a cache
1120
+ * hit used to be a `Map#has` on the way through `resolveFunction`, and an `await` there — even of a
1121
+ * value that is not a promise — defers one microtask, which reorders every element's scope
1122
+ * construction against its siblings'. The kit's field naming its slotted control is one thing that
1123
+ * order decides. Only a loader is a promise, because only a loader has to be.
1124
+ *
1125
+ * @param {string} src
1126
+ * @returns {ImportedModule | Promise<ImportedModule> | undefined}
1127
+ */
1128
+ function seededModule(src: string): ImportedModule | Promise<ImportedModule> | undefined {
1129
+ const cached = _moduleCache.get(src);
1130
+ if (cached) {
1131
+ return cached;
1132
+ }
1133
+ const pending = _moduleLoaders.get(src);
1134
+ if (!pending) {
1135
+ return undefined;
1136
+ }
1137
+ if (typeof pending !== "function") {
1138
+ return pending;
1139
+ }
1140
+ /* `Promise.resolve` so a thenable from a host's loader is still a Promise the call sites can
1141
+ tell from a namespace. */
1142
+ const load = Promise.resolve(pending()).then(
1143
+ (mod) => {
1144
+ _moduleCache.set(src, mod as ImportedModule);
1145
+ _moduleLoaders.delete(src);
1146
+ return mod as ImportedModule;
1147
+ },
1148
+ (error: unknown) => {
1149
+ /* A failed load — a chunk the network dropped — puts the loader back, so the next document to
1150
+ name the specifier tries again rather than inheriting this rejection for good. */
1151
+ _moduleLoaders.set(src, pending);
1152
+ throw error;
1153
+ },
1154
+ );
1155
+ _moduleLoaders.set(src, load);
1156
+ return load;
1157
+ }
1158
+
798
1159
  /**
799
1160
  * Resolve a $prototype: "Function" entry into a function or computed.
800
1161
  *
@@ -838,8 +1199,11 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
838
1199
  }
839
1200
  const exportName = def.$export ?? key;
840
1201
  let mod: ImportedModule;
841
- if (_moduleCache.has(src)) {
842
- mod = _moduleCache.get(src)!;
1202
+ const seeded = seededModule(src);
1203
+ if (seeded instanceof Promise) {
1204
+ mod = await seeded;
1205
+ } else if (seeded) {
1206
+ mod = seeded;
843
1207
  } else {
844
1208
  if (base) {
845
1209
  const resolvedSrc = new URL(src, base).href;
@@ -1071,6 +1435,12 @@ export function renderNode(
1071
1435
  }
1072
1436
 
1073
1437
  applyProperties(el, def, localState);
1438
+ /* A custom element's own definition applies its style at connection, through the same interning
1439
+ path — which releases whatever is already on the element. So the call site's style is
1440
+ remembered here and merged there; see {@link callSiteStyles}. */
1441
+ if (def.style && tagName.includes("-")) {
1442
+ callSiteStyles.set(el, def.style);
1443
+ }
1074
1444
  applyStyle(
1075
1445
  el,
1076
1446
  def.style ?? {},
@@ -1129,47 +1499,75 @@ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
1129
1499
  continue;
1130
1500
  } // Scope bindings — handled in renderNode
1131
1501
 
1132
- if (key.startsWith("on")) {
1133
- // Event handler: $ref to a function
1134
- if (isRefObj(val)) {
1135
- const handler = resolveRef(val.$ref, state);
1136
- if (typeof handler === "function") {
1137
- const scope = state;
1138
- const handlerFn = handler as (s: JxScope, e: Event) => unknown;
1139
- el.addEventListener(key.slice(2), (e) => handlerFn(scope, e));
1140
- }
1141
- continue;
1142
- }
1143
- // Event handler: inline $prototype: "Function" with a structured body (spec §20)
1144
- if (hasStructuredBody(val)) {
1145
- const { body } = val;
1146
- const scope = state;
1147
- el.addEventListener(key.slice(2), (e) => {
1148
- void runStatements(body, scope, e);
1149
- });
1150
- continue;
1151
- }
1152
- // Event handler: inline $prototype: "Function"
1153
- if (isFunctionDef(val) && typeof val.body === "string") {
1154
- const params = resolveParamNames(val);
1155
- const fn = new Function(...params, val.body) as (s: JxScope, e: Event) => unknown;
1156
- const scope = state;
1157
- el.addEventListener(key.slice(2), (e) => fn(scope, e));
1158
- continue;
1159
- }
1160
- // Event handler: inline $expression
1161
- if (isExpressionDef(val)) {
1162
- const node = val.$expression;
1163
- const scope = state;
1164
- el.addEventListener(key.slice(2), (e) => evaluateExpression(node, scope, e));
1165
- continue;
1166
- }
1502
+ if (key.startsWith("on") && bindHandler(el, key, val, state)) {
1503
+ continue;
1167
1504
  }
1168
1505
 
1169
1506
  bindProperty(el, key, val, state);
1170
1507
  }
1171
1508
  }
1172
1509
 
1510
+ /**
1511
+ * Attach an `on*` key as an event listener, in any of the four spellings a handler has: a `$ref` to
1512
+ * a function, a structured body (spec §20), a string body, or an `$expression`. Returns false for a
1513
+ * value that is none of those, which the caller then writes as a plain property.
1514
+ *
1515
+ * @param {HTMLElement} el
1516
+ * @param {string} key The `on`-prefixed key
1517
+ * @param {unknown} val
1518
+ * @param {JxScope} state
1519
+ * @returns {boolean} Whether a listener was attached
1520
+ */
1521
+ function bindHandler(el: HTMLElement, key: string, val: unknown, state: JxScope): boolean {
1522
+ const type = key.slice(2);
1523
+ const scope = state;
1524
+ if (isRefObj(val)) {
1525
+ const handler = resolveRef(val.$ref, state);
1526
+ if (typeof handler === "function") {
1527
+ const handlerFn = handler as (s: JxScope, e: Event) => unknown;
1528
+ el.addEventListener(type, (e) => handlerFn(scope, e));
1529
+ }
1530
+ return true;
1531
+ }
1532
+ if (hasStructuredBody(val)) {
1533
+ const { body } = val;
1534
+ el.addEventListener(type, (e) => {
1535
+ void runStatements(body, scope, e);
1536
+ });
1537
+ return true;
1538
+ }
1539
+ if (isFunctionDef(val) && typeof val.body === "string") {
1540
+ const params = resolveParamNames(val);
1541
+ const fn = new Function(...params, val.body) as (s: JxScope, e: Event) => unknown;
1542
+ el.addEventListener(type, (e) => fn(scope, e));
1543
+ return true;
1544
+ }
1545
+ if (isExpressionDef(val)) {
1546
+ const node = val.$expression;
1547
+ el.addEventListener(type, (e) => evaluateExpression(node, scope, e));
1548
+ return true;
1549
+ }
1550
+ return false;
1551
+ }
1552
+
1553
+ /**
1554
+ * A definition's root-level `on*` keys listen on the HOST element, exactly as they would on an
1555
+ * element in a document. Only handler keys are read here: the rest of a definition's root —
1556
+ * `observedAttributes`, `emits`, `description` — describes the element rather than styling an
1557
+ * instance, and is not written onto it.
1558
+ *
1559
+ * @param {HTMLElement} host
1560
+ * @param {JxElement} def
1561
+ * @param {JxScope} state
1562
+ */
1563
+ function bindDefinitionHandlers(host: HTMLElement, def: JxElement, state: JxScope) {
1564
+ for (const [key, val] of Object.entries(def)) {
1565
+ if (key.startsWith("on") && !RESERVED_KEYS.has(key)) {
1566
+ bindHandler(host, key, val, state);
1567
+ }
1568
+ }
1569
+ }
1570
+
1173
1571
  /**
1174
1572
  * @param {HTMLElement} el
1175
1573
  * @param {string} key
@@ -1198,6 +1596,13 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
1198
1596
  node.setAttribute("class", resolved == null ? "" : String(resolved));
1199
1597
  return;
1200
1598
  }
1599
+ /* An equal write is skipped. A binding re-runs whenever anything it read changes, not only
1600
+ when its own result does, and re-setting `value` on a focused control is not free: it can
1601
+ move the caret and collapse the selection. A control that already holds the value has
1602
+ nothing to learn from being told again. */
1603
+ if (target[key] === resolved) {
1604
+ return;
1605
+ }
1201
1606
  target[key] = resolved;
1202
1607
  };
1203
1608
  if (isRefObj(val)) {
@@ -1223,193 +1628,543 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
1223
1628
  write(asAsset(val));
1224
1629
  }
1225
1630
 
1631
+ /*
1632
+ * ─── The Stylesheet Engine ───────────────────────────────────────────────────
1633
+ *
1634
+ * An authored `style` object becomes REAL CSS RULES, delivered through the document's
1635
+ * `adoptedStyleSheets`. Everything below exists to make that true, and the reason it has to be
1636
+ * true is a cascade inversion rather than a missing feature.
1637
+ *
1638
+ * `applyStyle` used to split one style object across two cascade ORIGINS: a top-level scalar went
1639
+ * inline (`el.style.color = …`) while an object-valued key became a per-element `<style>` tag. An
1640
+ * inline declaration beats any non-`!important` rule, so
1641
+ *
1642
+ * { "backgroundColor": "#6e0303", ":hover": { "backgroundColor": "#15164a" } }
1643
+ *
1644
+ * emitted a perfectly correct `:hover` rule that could never win. Nesting was not missing; it was
1645
+ * outranked. The one existing defence, `mediaOverriddenProps`, moved a base property out of inline
1646
+ * when an `@`-block overrode it — one level deep, `@`-keys only, never `:hover` — which is the
1647
+ * shape of the bug rather than a fix for it. Every style assertion in the suite read the `<style>`
1648
+ * tag's text, so the inversion was invisible to all of them.
1649
+ *
1650
+ * With every declaration in a rule, `:hover` versus base is an ordinary equal-specificity pair and
1651
+ * source order decides, exactly as it does on the compiled page.
1652
+ *
1653
+ * **Three sheet roles, because mutation cost scales with sheet size.** Measured in Chrome 152 over
1654
+ * 500 styled elements: 500 `<style>` tags cost 86.0ms to build, one adopted sheet with hash dedup
1655
+ * costs 1.7ms, and 500 separate adopted sheets cost 6.2ms to build but 84.1ms per style recalc
1656
+ * because each one is walked. On the update path (300 value edits, the Style-panel hot path)
1657
+ * mutating a rule inside one big shared sheet costs 295.7ms, because Chrome rebuilds the whole
1658
+ * sheet's RuleSet on any mutation. So:
1659
+ *
1660
+ * - the SHARED sheet holds every static, interned rule set and is grown but never rewritten;
1661
+ * - a reactive declaration is indirected through a custom property, so the rule is static text
1662
+ * and the update writes `el.style.setProperty("--jx-r0-0", v)` — 12.4ms, and independent of
1663
+ * sheet size (the same mechanism that makes `${…}` work inside a nested block at all);
1664
+ * - the SCRATCH sheet takes the elements under live Studio edit and is rewritten with
1665
+ * `replaceSync`, 7.5ms per 100 structural edits against 76.9ms for delete+insert in the big one.
1666
+ *
1667
+ * **The handle stays `data-jx`, and its value becomes a content hash.** Not a generated class:
1668
+ * `applyAttributes` runs after `applyStyle`, so a static `attributes: { class: … }` would wipe a
1669
+ * class handle. `data-jx` is already there, is specificity (0,1,0), and is not author-writable.
1670
+ * Hashing the built rule text rather than calling `Math.random()` is what gives dedup and SSR
1671
+ * stability, and it covers everything that varies per call site by construction — the authored
1672
+ * object, the resolved `$media` map and the canvas transposer state are all inputs to that text.
1673
+ *
1674
+ * **Jx emits no cascade layer of its own.** Third-party CSS arrives through `$head` unlayered, and
1675
+ * an unlayered rule beats a layered one at any specificity — so layering Jx's own rules would hand
1676
+ * every page's `$head` a win it does not have today. It is also what keeps the canvas's
1677
+ * `@layer jx-canvas-ua` UA emulation working now that base declarations are unlayered rules rather
1678
+ * than inline styles: unlayered still beats layered, which is the same direction author origin
1679
+ * beats UA origin on the shipped page.
1680
+ */
1681
+
1682
+ /** A rule set as it was written into a sheet: the texts, plus the live rules when there are any. */
1683
+ interface InsertedRules {
1684
+ texts: readonly string[];
1685
+ rules: CSSRule[];
1686
+ }
1687
+
1688
+ /** A place rules can be written. Two implementations: the CSSOM, and `<style>` text. */
1689
+ interface StyleSink {
1690
+ insert: (texts: readonly string[]) => InsertedRules;
1691
+ remove: (inserted: InsertedRules) => void;
1692
+ /** Rewrite the whole sink. Used only by the scratch sheet. */
1693
+ replaceAll: (texts: readonly string[]) => void;
1694
+ /** Everything written, in sheet order. See {@link documentStyleText}. */
1695
+ text: () => string;
1696
+ detach: () => void;
1697
+ }
1698
+
1226
1699
  /**
1227
- * Apply inline styles and emit a scoped <style> block for nested CSS selectors and @custom-media
1228
- * breakpoint rules.
1700
+ * A sink over a real `CSSStyleSheet` — constructable and adopted, or a `<style>` element's own.
1229
1701
  *
1230
- * @param {HTMLElement} el
1231
- * @param {JxStyle} styleDef
1232
- * @param {Record<string, string>} [mediaQueries] Named breakpoints from root $media. Default is
1233
- * `{}`
1234
- * @param {JxScope} [state] Component scope for template string evaluation. Default is `{}`
1702
+ * @param {CSSStyleSheet} sheet
1703
+ * @param {() => void} detach
1704
+ * @returns {StyleSink}
1235
1705
  */
1236
- export function applyStyle(
1237
- el: HTMLElement,
1238
- styleDef: JxStyle,
1239
- mediaQueries: Record<string, string> = {},
1240
- state: JxScope = {},
1241
- ) {
1242
- const nested: Record<string, JxStyle> = {};
1243
- const media: Record<string, JxStyle> = {};
1244
- const baseDecls: Record<string, string> = {};
1245
-
1246
- // Collect properties overridden by media queries so we can avoid inline styles for them
1247
- const mediaOverriddenProps = new Set<string>();
1248
- for (const [prop, val] of Object.entries(styleDef)) {
1249
- if (prop.startsWith("@") && val && typeof val === "object") {
1250
- for (const k of Object.keys(val)) {
1251
- if (
1252
- !k.startsWith(":") &&
1253
- !k.startsWith(".") &&
1254
- !k.startsWith("&") &&
1255
- !k.startsWith("[") &&
1256
- !k.startsWith("@")
1257
- ) {
1258
- mediaOverriddenProps.add(k);
1706
+ function cssomSink(sheet: CSSStyleSheet, detach: () => void): StyleSink {
1707
+ const entries: InsertedRules[] = [];
1708
+ return {
1709
+ detach,
1710
+ insert(texts) {
1711
+ const rules: CSSRule[] = [];
1712
+ for (const text of texts) {
1713
+ const index = sheet.cssRules.length;
1714
+ try {
1715
+ sheet.insertRule(text, index);
1716
+ } catch {
1717
+ /* One unparseable rule must not take the other twenty with it. A `<style>` element drops
1718
+ exactly the offending rule and keeps going; `insertRule` throws, so this restores the
1719
+ tolerance an author's stylesheet has always had. */
1720
+ continue;
1721
+ }
1722
+ const rule = sheet.cssRules[index];
1723
+ if (rule) {
1724
+ rules.push(rule);
1259
1725
  }
1260
1726
  }
1261
- }
1262
- }
1263
-
1264
- for (const [prop, val] of Object.entries(styleDef)) {
1265
- if (val !== null && typeof val === "object" && !Array.isArray(val)) {
1266
- if (prop.startsWith("@")) {
1267
- media[prop] = val;
1268
- } else {
1269
- nested[prop] = val;
1727
+ const inserted: InsertedRules = { rules, texts };
1728
+ entries.push(inserted);
1729
+ return inserted;
1730
+ },
1731
+ remove(inserted) {
1732
+ const at = entries.indexOf(inserted);
1733
+ if (at !== -1) {
1734
+ entries.splice(at, 1);
1270
1735
  }
1271
- continue;
1272
- }
1273
- if (prop.startsWith("@") || isNestedSelector(prop)) {
1274
- // Scalar under a selector/media key — invalid style shape, drop it.
1275
- continue;
1276
- }
1277
- if (val === undefined) {
1278
- continue;
1279
- }
1280
- const scalar = String(val);
1281
- if (prop.startsWith("--")) {
1282
- if (isTemplateString(val)) {
1283
- effect(() => {
1284
- el.style.setProperty(prop, canvasStyleValue(evaluateTemplate(val, state)));
1285
- });
1286
- } else {
1287
- el.style.setProperty(prop, canvasStyleValue(scalar));
1736
+ if (inserted.rules.length === 0) {
1737
+ return;
1288
1738
  }
1289
- } else if (isTemplateString(val)) {
1290
- effect(() => {
1291
- (el.style as unknown as Record<string, string>)[prop] = canvasStyleValue(
1292
- evaluateTemplate(val, state),
1293
- );
1739
+ /* An index scan of a 302-rule sheet measures 0.04ms, so finding the rules costs nothing next
1740
+ to the RuleSet rebuild the deletion itself forces. Descending, or each deletion shifts the
1741
+ indices still to be removed. */
1742
+ const list = [...sheet.cssRules];
1743
+ const indices = inserted.rules
1744
+ .map((rule) => list.indexOf(rule))
1745
+ .filter((index) => index >= 0)
1746
+ .toSorted((a, b) => b - a);
1747
+ for (const index of indices) {
1748
+ sheet.deleteRule(index);
1749
+ }
1750
+ inserted.rules.length = 0;
1751
+ },
1752
+ replaceAll(texts) {
1753
+ entries.length = 0;
1754
+ entries.push({ rules: [], texts });
1755
+ sheet.replaceSync(texts.join("\n"));
1756
+ },
1757
+ text() {
1758
+ return entries.flatMap((entry) => entry.texts.join("\n")).join("\n");
1759
+ },
1760
+ };
1761
+ }
1762
+
1763
+ /**
1764
+ * The last resort: a `<style>` element whose text is rewritten. No CSSOM required.
1765
+ *
1766
+ * @param {HTMLStyleElement} tag
1767
+ * @returns {StyleSink}
1768
+ */
1769
+ function textSink(tag: HTMLStyleElement): StyleSink {
1770
+ const entries: InsertedRules[] = [];
1771
+ const write = () => {
1772
+ tag.textContent = entries.map((entry) => entry.texts.join("\n")).join("\n");
1773
+ };
1774
+ return {
1775
+ detach() {
1776
+ tag.remove();
1777
+ },
1778
+ insert(texts) {
1779
+ const inserted: InsertedRules = { rules: [], texts };
1780
+ entries.push(inserted);
1781
+ write();
1782
+ return inserted;
1783
+ },
1784
+ remove(inserted) {
1785
+ const index = entries.indexOf(inserted);
1786
+ if (index !== -1) {
1787
+ entries.splice(index, 1);
1788
+ write();
1789
+ }
1790
+ },
1791
+ replaceAll(texts) {
1792
+ entries.length = 0;
1793
+ entries.push({ rules: [], texts });
1794
+ write();
1795
+ },
1796
+ text() {
1797
+ return tag.textContent ?? "";
1798
+ },
1799
+ };
1800
+ }
1801
+
1802
+ /**
1803
+ * Open a sheet on `doc`, preferring a constructable one.
1804
+ *
1805
+ * A constructable stylesheet cannot be adopted into another document, which is why the registry
1806
+ * below is keyed on the document and never cached at module scope: the Studio iframe, the live
1807
+ * preview tab and the parent page each open their own.
1808
+ *
1809
+ * @param {Document} doc
1810
+ * @returns {StyleSink}
1811
+ */
1812
+ function openSink(doc: Document): StyleSink {
1813
+ const view = doc.defaultView as (Window & { CSSStyleSheet?: typeof CSSStyleSheet }) | null;
1814
+ const SheetConstructor = view?.CSSStyleSheet;
1815
+ if (typeof SheetConstructor === "function" && Array.isArray(doc.adoptedStyleSheets)) {
1816
+ try {
1817
+ const sheet = new SheetConstructor();
1818
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
1819
+ return cssomSink(sheet, () => {
1820
+ doc.adoptedStyleSheets = doc.adoptedStyleSheets.filter((s) => s !== sheet);
1294
1821
  });
1295
- } else if (mediaOverriddenProps.has(prop)) {
1296
- // Goes through toCSSText (which resolves and transposes) — don't do either twice here.
1297
- baseDecls[prop] = scalar;
1298
- } else {
1299
- (el.style as unknown as Record<string, string>)[prop] = canvasStyleValue(scalar);
1822
+ } catch {
1823
+ // No constructable stylesheets, or a frozen adoptedStyleSheets. Fall through to a tag.
1300
1824
  }
1301
1825
  }
1826
+ const tag = doc.createElement("style");
1827
+ tag.dataset.jxSheet = "";
1828
+ (doc.head ?? doc.documentElement).append(tag);
1829
+ return tag.sheet ? cssomSink(tag.sheet, () => tag.remove()) : textSink(tag);
1830
+ }
1302
1831
 
1303
- const hasNested = Object.keys(nested).length > 0;
1304
- const hasMedia = Object.keys(media).length > 0;
1305
- const hasBaseDecls = Object.keys(baseDecls).length > 0;
1306
- if (!hasNested && !hasMedia && !hasBaseDecls) {
1832
+ /** One interned rule set: the rules as written, and how many elements point at them. */
1833
+ interface InternEntry {
1834
+ inserted: InsertedRules;
1835
+ refs: number;
1836
+ }
1837
+
1838
+ /** Everything one document's rules are kept in. */
1839
+ interface SheetState {
1840
+ shared: StyleSink;
1841
+ /** Opened on the first live edit, and only then — most documents never have one. */
1842
+ scratch: StyleSink | null;
1843
+ /** Elements living on the scratch sheet, in the order their rules are written. */
1844
+ scratchOwners: Map<HTMLElement, readonly string[]>;
1845
+ /** Scope handle to the rule set it names. */
1846
+ interned: Map<string, InternEntry>;
1847
+ /** Declaration-body at-rule text to the one copy of it. */
1848
+ hoisted: Map<string, InternEntry>;
1849
+ /** Distinguishes `--jx-r<serial>-<n>` variables between elements. */
1850
+ serial: number;
1851
+ }
1852
+
1853
+ /**
1854
+ * The per-document registry. A `WeakMap` rather than a module-level singleton because one process
1855
+ * renders into several documents (canvas iframe, preview tab, parent) and a constructable sheet
1856
+ * belongs to exactly one of them.
1857
+ */
1858
+ const sheetStates = new WeakMap<Document, SheetState>();
1859
+
1860
+ /**
1861
+ * @param {Document} doc
1862
+ * @returns {SheetState}
1863
+ */
1864
+ function sheetStateFor(doc: Document): SheetState {
1865
+ const existing = sheetStates.get(doc);
1866
+ if (existing) {
1867
+ return existing;
1868
+ }
1869
+ const created: SheetState = {
1870
+ hoisted: new Map(),
1871
+ interned: new Map(),
1872
+ scratch: null,
1873
+ scratchOwners: new Map(),
1874
+ serial: 0,
1875
+ shared: openSink(doc),
1876
+ };
1877
+ sheetStates.set(doc, created);
1878
+ return created;
1879
+ }
1880
+
1881
+ /** What one element holds, so that releasing it is exact rather than approximate. */
1882
+ interface ElementStyleEntry {
1883
+ doc: Document;
1884
+ /** The state this entry's handles belong to; a reset replaces it and orphans them. */
1885
+ state: SheetState;
1886
+ uid: string;
1887
+ /** The interning key in the shared sheet, or null when the element is on the scratch sheet. */
1888
+ interned: string | null;
1889
+ /** Hoisted declaration at-rules this element holds a reference to. */
1890
+ hoisted: readonly string[];
1891
+ /** The `--jx-r…` custom properties its effects write inline. */
1892
+ vars: readonly string[];
1893
+ /** Stops those effects. */
1894
+ stop: (() => void) | null;
1895
+ }
1896
+
1897
+ /**
1898
+ * Per-element style bookkeeping. The replacement for the old `elementStyleTags`, which exported a
1899
+ * `<style>` element and let its importers remove it by hand; {@link releaseElementStyles} is the
1900
+ * documented teardown in its place, because a rule set is now refcounted and dropping one by hand
1901
+ * would either leak it or take it away from another element that shares it.
1902
+ */
1903
+ const elementStyles = new WeakMap<HTMLElement, ElementStyleEntry>();
1904
+
1905
+ /** The scope handle `buildStyleRules` writes, before the hash of its own output replaces it. */
1906
+ const SCOPE_TOKEN = "jx-scope";
1907
+
1908
+ /**
1909
+ * Rewrite the scratch sheet from its owners.
1910
+ *
1911
+ * `scratchOwners` is a STRONG map — it has to be, since the sheet's whole text is rebuilt from it —
1912
+ * so an element only leaves it through {@link releaseElementStyles}. That is not a hole: every way
1913
+ * an edited element can go away calls it. `applyStyle` registers it with the enclosing reactive
1914
+ * scope, Studio's `disposeSubtree` calls it per element, and `resetDocumentStyles` drops the map
1915
+ * wholesale. Sweeping on `isConnected` instead would be wrong rather than merely redundant: an
1916
+ * element is routinely styled before it is inserted.
1917
+ *
1918
+ * @param {SheetState} state
1919
+ */
1920
+ function rewriteScratch(state: SheetState) {
1921
+ if (!state.scratch) {
1922
+ return;
1923
+ }
1924
+ state.scratch.replaceAll([...state.scratchOwners.values()].flat());
1925
+ }
1926
+
1927
+ /**
1928
+ * Drop one reference to an interned rule set, removing its rules when the last one goes.
1929
+ *
1930
+ * @param {SheetState} state
1931
+ * @param {Map<string, InternEntry>} table
1932
+ * @param {string} key
1933
+ */
1934
+ function releaseInterned(state: SheetState, table: Map<string, InternEntry>, key: string) {
1935
+ const entry = table.get(key);
1936
+ if (!entry) {
1937
+ return;
1938
+ }
1939
+ entry.refs -= 1;
1940
+ if (entry.refs <= 0) {
1941
+ table.delete(key);
1942
+ state.shared.remove(entry.inserted);
1943
+ }
1944
+ }
1945
+
1946
+ /**
1947
+ * Take one reference to a rule set, inserting it the first time it is asked for.
1948
+ *
1949
+ * @param {SheetState} state
1950
+ * @param {Map<string, InternEntry>} table
1951
+ * @param {string} key
1952
+ * @param {readonly string[]} texts
1953
+ */
1954
+ function retainInterned(
1955
+ state: SheetState,
1956
+ table: Map<string, InternEntry>,
1957
+ key: string,
1958
+ texts: readonly string[],
1959
+ ) {
1960
+ const existing = table.get(key);
1961
+ if (existing) {
1962
+ existing.refs += 1;
1963
+ return;
1964
+ }
1965
+ table.set(key, { inserted: state.shared.insert(texts), refs: 1 });
1966
+ }
1967
+
1968
+ /**
1969
+ * Release everything {@link applyStyle} gave an element: its rules, its scope handle, and the inline
1970
+ * custom properties and effects behind its reactive declarations.
1971
+ *
1972
+ * This is the teardown API `elementStyleTags` used to stand in for, and it is exported because
1973
+ * removing a rule set by hand is no longer possible: sets are shared between elements that style
1974
+ * alike, so the only safe removal is a refcounted one. Calling it on an element that has no styles
1975
+ * is a no-op, which is what makes `applyStyle` safe to call twice.
1976
+ *
1977
+ * Callers rarely need it. `applyStyle` registers it with the enclosing reactive scope, so an
1978
+ * element discarded by a re-rendering mapped array or a disposed Studio subtree releases itself.
1979
+ *
1980
+ * @param {HTMLElement} el
1981
+ * @param {ElementStyleEntry} [only] - Release only if this is still the element's current entry
1982
+ */
1983
+ export function releaseElementStyles(el: HTMLElement, only?: ElementStyleEntry): void {
1984
+ const entry = elementStyles.get(el);
1985
+ if (!entry || (only !== undefined && entry !== only)) {
1986
+ return;
1987
+ }
1988
+ elementStyles.delete(el);
1989
+ entry.stop?.();
1990
+ for (const name of entry.vars) {
1991
+ el.style.removeProperty(name);
1992
+ }
1993
+ delete el.dataset.jx;
1994
+ /* A `resetDocumentStyles` between the two replaces the state, and its sheets are already gone —
1995
+ so the element-local half above always runs and the sheet half is skipped as already done. */
1996
+ if (sheetStates.get(entry.doc) !== entry.state) {
1307
1997
  return;
1308
1998
  }
1999
+ const { state } = entry;
2000
+ if (entry.interned === null) {
2001
+ if (state.scratchOwners.delete(el)) {
2002
+ rewriteScratch(state);
2003
+ }
2004
+ } else {
2005
+ releaseInterned(state, state.interned, entry.interned);
2006
+ }
2007
+ for (const key of entry.hoisted) {
2008
+ releaseInterned(state, state.hoisted, key);
2009
+ }
2010
+ }
2011
+
2012
+ /**
2013
+ * Discard every sheet this runtime opened on a document, and orphan the element entries that point
2014
+ * into them.
2015
+ *
2016
+ * A full re-render replaces the whole tree, so the alternative is a shared sheet that only ever
2017
+ * grows. The element `WeakMap` cannot be enumerated to be cleared, so entries are invalidated
2018
+ * instead: each one records the `SheetState` it was written into, and a release that finds a
2019
+ * different state registered for the document does the element-local half only. That is what stops
2020
+ * a surviving element from later handing `deleteRule` an index into a sheet nobody is reading.
2021
+ *
2022
+ * @param {Document} [doc] - Defaults to the ambient document
2023
+ */
2024
+ export function resetDocumentStyles(doc: Document = document): void {
2025
+ const state = sheetStates.get(doc);
2026
+ if (!state) {
2027
+ return;
2028
+ }
2029
+ sheetStates.delete(doc);
2030
+ for (const el of state.scratchOwners.keys()) {
2031
+ elementStyles.delete(el);
2032
+ delete el.dataset.jx;
2033
+ }
2034
+ state.scratchOwners.clear();
2035
+ state.interned.clear();
2036
+ state.hoisted.clear();
2037
+ state.shared.detach();
2038
+ state.scratch?.detach();
2039
+ }
2040
+
2041
+ /**
2042
+ * The CSS this engine has written into a document, in sheet order.
2043
+ *
2044
+ * It reports what the engine ASKED the document to adopt, not what the document managed to parse,
2045
+ * and the difference is the point: `insertRule` throws on a rule the host cannot parse, and the
2046
+ * sink swallows that so one bad rule cannot take the rest of an element's set with it. The test DOM
2047
+ * rejects `@position-try` outright, so reading it back off `cssRules` there would report a block
2048
+ * that a browser adopts perfectly well as missing.
2049
+ *
2050
+ * It is also the honest answer to "what CSS did my style object become", which is a question the
2051
+ * Studio style inspector asks and, before this, nothing could answer without scraping `<style>`
2052
+ * tags out of the head.
2053
+ *
2054
+ * @param {Document} [doc] - Defaults to the ambient document
2055
+ * @returns {string} One rule per line, shared sheet first
2056
+ * @docs framework/concepts/styling
2057
+ */
2058
+ export function documentStyleText(doc: Document = document): string {
2059
+ const state = sheetStates.get(doc);
2060
+ if (!state) {
2061
+ return "";
2062
+ }
2063
+ return [state.shared.text(), state.scratch?.text() ?? ""].filter(Boolean).join("\n");
2064
+ }
1309
2065
 
1310
- const uid = `jx-${Math.random().toString(36).slice(2, 7)}`;
1311
- el.dataset.jx = uid;
2066
+ /**
2067
+ * Whether a style object declares `display` anywhere in it.
2068
+ *
2069
+ * `defineElement` gives a custom element `display: block` because a custom element defaults to
2070
+ * `inline` and Jx containers behave like `<div>`. It used to test `this.style.display`, which was a
2071
+ * correct test only while the author's own `display` was written inline — the moment it became a
2072
+ * rule the guard stopped seeing it and the default started overriding the author. The test is now
2073
+ * on the source rather than on the element, and it is a deep one on purpose: an inline default
2074
+ * would beat a `:hover` or `@media` `display` just as surely as a base one.
2075
+ *
2076
+ * A `@keyframes` block is the one thing the walk does NOT descend into. A `display` in a keyframe
2077
+ * stop is a point on a timeline, not a declaration on this element — animating `display` is the
2078
+ * ordinary shape of an `allow-discrete` reveal — so counting one as the author's own left the
2079
+ * element with no `display` at all, and a custom element with no `display` is `inline`. The element
2080
+ * laid out wrongly whenever it was still, which is most of the time.
2081
+ *
2082
+ * @param {JxStyle | undefined} style
2083
+ * @returns {boolean}
2084
+ */
2085
+ function declaresDisplay(style: JxStyle | undefined): boolean {
2086
+ /* The BASE block only. A deep walk read a `display` that exists solely inside `&:hover` or an
2087
+ `@media` block as "the author supplied one", so the element stayed `inline` at rest and
2088
+ became a block on hover — a live trap rather than a subtlety. An author who wants the
2089
+ platform's own default writes `display: revert` in the base block, which this sees. */
2090
+ return Boolean(style) && typeof style === "object" && "display" in style;
2091
+ }
1312
2092
 
1313
- let css = "";
1314
- const baseCSS = toCSSText(baseDecls);
1315
- if (baseCSS) {
1316
- css += `[data-jx="${uid}"] { ${baseCSS} }\n`;
1317
- }
2093
+ /**
2094
+ * The `style` object a USAGE SITE wrote on a custom element, kept until the element connects.
2095
+ *
2096
+ * Weak in the element, so an instance that is never connected is collected with its entry.
2097
+ */
2098
+ const callSiteStyles = new WeakMap<HTMLElement, JxStyle>();
1318
2099
 
1319
- function emitNested(scope: string, rules: JxStyle) {
1320
- const props = toCSSText(rules);
1321
- if (props) {
1322
- css += `${scope} { ${props} }\n`;
1323
- }
1324
- for (const [sel, sub] of Object.entries(rules)) {
1325
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
1326
- continue;
1327
- }
1328
- if (sel.startsWith("@")) {
1329
- continue;
1330
- }
1331
- const resolved = sel.startsWith("&")
1332
- ? sel.replace("&", scope)
1333
- : sel.startsWith("[")
1334
- ? `${scope}${sel}`
1335
- : sel.startsWith(":") || sel.startsWith(".")
1336
- ? `${scope}${sel}`
1337
- : `${scope} ${sel}`;
1338
- emitNested(resolved, sub);
1339
- }
1340
- }
1341
-
1342
- for (const [sel, rules] of Object.entries(nested)) {
1343
- const resolved = sel.startsWith("&")
1344
- ? sel.replace("&", `[data-jx="${uid}"]`)
1345
- : sel.startsWith("[")
1346
- ? `[data-jx="${uid}"]${sel}`
1347
- : sel.startsWith(":") || sel.startsWith(".")
1348
- ? `[data-jx="${uid}"]${sel}`
1349
- : `[data-jx="${uid}"] ${sel}`;
1350
- emitNested(resolved, rules);
1351
- }
1352
-
1353
- function emitMediaNested(atRule: string, parentSel: string, obj: JxStyle) {
1354
- for (const [sel, sub] of Object.entries(obj)) {
1355
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
1356
- continue;
1357
- }
1358
- if (sel.startsWith("@")) {
1359
- continue;
1360
- }
1361
- const resolved = sel.startsWith("&")
1362
- ? sel.replace("&", parentSel)
1363
- : sel.startsWith("[")
1364
- ? `${parentSel}${sel}`
1365
- : sel.startsWith(":") || sel.startsWith(".")
1366
- ? `${parentSel}${sel}`
1367
- : `${parentSel} ${sel}`;
1368
- const props = toCSSText(sub);
1369
- if (props) {
1370
- css += `${atRule} { ${resolved} { ${props} } }\n`;
1371
- }
1372
- emitMediaNested(atRule, resolved, sub);
1373
- }
2100
+ /**
2101
+ * Merge two style objects, `over` winning.
2102
+ *
2103
+ * Deep, because a nested selector block is a rule of its own: a call site that declares `&:hover`
2104
+ * must not delete the definition's `&:hover`, only the declarations it repeats inside it. A scalar
2105
+ * always replaces, which is what "the call site wins" means.
2106
+ *
2107
+ * @param base The definition's style.
2108
+ * @param over The usage site's, if any.
2109
+ * @returns {JxStyle} A new object; neither argument is mutated.
2110
+ */
2111
+ function mergeStyle(base: JxStyle, over: JxStyle | undefined): JxStyle {
2112
+ if (!over) {
2113
+ return base;
1374
2114
  }
1375
-
1376
- for (const [key, rules] of Object.entries(media)) {
1377
- if (key === "@--") {
1378
- continue;
1379
- } // Base canvas width, not a real media query
1380
- const query = resolveAtQuery(key, mediaQueries);
1381
- const atRule = query === null ? key : `@media ${query}`;
1382
- const scope = `[data-jx="${uid}"]`;
1383
- const scheme = query === null ? null : pureSchemeOf(query);
1384
- if (scheme) {
1385
- // Dual-emit scheme rules: a media-guarded copy for auto plus a forced-attribute copy.
1386
- // The forced root attribute wins over the OS preference.
1387
- const { auto, forced } = schemeSelectors(scope, scheme);
1388
- css += `${atRule} { ${auto} { ${toCSSText(rules)} } }\n`;
1389
- emitMediaNested(atRule, auto, rules);
1390
- emitNested(forced, rules);
1391
- } else {
1392
- css += `${atRule} { ${scope} { ${toCSSText(rules)} } }\n`;
1393
- emitMediaNested(atRule, scope, rules);
1394
- }
2115
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
2116
+ for (const [key, value] of Object.entries(over as Record<string, unknown>)) {
2117
+ const prior = out[key];
2118
+ out[key] =
2119
+ prior !== null &&
2120
+ typeof prior === "object" &&
2121
+ value !== null &&
2122
+ typeof value === "object" &&
2123
+ !Array.isArray(value)
2124
+ ? mergeStyle(prior as JxStyle, value as JxStyle)
2125
+ : value;
1395
2126
  }
1396
-
1397
- const tag = document.createElement("style");
1398
- tag.textContent = css;
1399
- tag.dataset.jxOwner = uid;
1400
- document.head.append(tag);
1401
- elementStyleTags.set(el, tag);
2127
+ return out as JxStyle;
1402
2128
  }
1403
2129
 
1404
2130
  /**
1405
- * Scoped <style> tags emitted per element by applyStyle (latest wins). Lets callers replace an
1406
- * element's emitted styles instead of accumulating orphaned tags.
2131
+ * Turn an element's `style` object into CSS rules and adopt them.
2132
+ *
2133
+ * Nothing is written inline except the `--jx-r…` custom properties that carry reactive values, so
2134
+ * an authored `:hover`, `@media` or `@supports` block overrides its own base declaration by
2135
+ * ordinary cascade rules — which is what the emitted CSS always said and the inline write always
2136
+ * prevented. See the engine essay above for the sheet roles and the measurements behind them.
2137
+ *
2138
+ * Calling it twice on one element is safe: the first call's rules are released first.
2139
+ *
2140
+ * @param {HTMLElement} el
2141
+ * @param {JxStyle} styleDef
2142
+ * @param {Record<string, string>} [mediaQueries] Named breakpoints from root $media. Default is
2143
+ * `{}`
2144
+ * @param {JxScope} [state] Component scope for template string evaluation. Default is `{}`
2145
+ * @docs framework/concepts/styling
1407
2146
  */
1408
- export const elementStyleTags = new WeakMap<HTMLElement, HTMLStyleElement>();
2147
+ export function applyStyle(
2148
+ el: HTMLElement,
2149
+ styleDef: JxStyle,
2150
+ mediaQueries: Record<string, string> = {},
2151
+ state: JxScope = {},
2152
+ ) {
2153
+ applyStyleInto(el, styleDef, mediaQueries, state, false);
2154
+ }
1409
2155
 
1410
2156
  /**
1411
- * Re-apply an element's style definition in place: removes the previously emitted scoped <style>
1412
- * tag (if any), clears inline styles, and applies the new definition.
2157
+ * Re-apply an element's style definition in place.
2158
+ *
2159
+ * The Studio Style panel calls this per keystroke, so its rules go to the SCRATCH sheet, which is
2160
+ * rewritten whole with `replaceSync` rather than edited rule by rule inside the shared one: 7.5ms
2161
+ * per 100 structural edits against 76.9ms. The previous rule set is released first — without that
2162
+ * this would leak a set into the shared sheet on every keypress, which is the opposite of the
2163
+ * `<style>`-tag leak it replaced.
2164
+ *
2165
+ * Inline styles are NOT cleared wholesale any more. `el.style.cssText = ""` also destroyed an
2166
+ * author's own `attributes: { style: "…" }` and would now take the custom-element `display` default
2167
+ * with it; only the `--jx-r…` properties this engine wrote are removed.
1413
2168
  *
1414
2169
  * @param {HTMLElement} el
1415
2170
  * @param {JxStyle | undefined} styleDef
@@ -1422,14 +2177,144 @@ export function reapplyStyle(
1422
2177
  mediaQueries: Record<string, string> = {},
1423
2178
  state: JxScope = {},
1424
2179
  ) {
1425
- const prev = elementStyleTags.get(el);
1426
- if (prev) {
1427
- prev.remove();
1428
- elementStyleTags.delete(el);
2180
+ applyStyleInto(el, styleDef ?? {}, mediaQueries, state, true);
2181
+ }
2182
+
2183
+ /**
2184
+ * @param {HTMLElement} el
2185
+ * @param {JxStyle} styleDef
2186
+ * @param {Record<string, string>} mediaQueries
2187
+ * @param {JxScope} state
2188
+ * @param {boolean} live - Write to the scratch sheet (the Studio edit path) rather than intern
2189
+ */
2190
+ function applyStyleInto(
2191
+ el: HTMLElement,
2192
+ styleDef: JxStyle,
2193
+ mediaQueries: Record<string, string>,
2194
+ state: JxScope,
2195
+ live: boolean,
2196
+ ) {
2197
+ releaseElementStyles(el);
2198
+ const doc = el.ownerDocument ?? document;
2199
+ const sheetState = sheetStateFor(doc);
2200
+
2201
+ /* One gate for the whole call, so every recursion answers alike. Off in production and in
2202
+ preview, where the selector is written exactly as authored.
2203
+
2204
+ The SAME predicate the attribute rename uses, and it has to be: an attribute renamed without
2205
+ its selectors transposed is a panel that can never be styled open. They disagreed while the
2206
+ rename learned to reach inside a defined element and this did not, so a kit popover rendered
2207
+ within another definition's template would have been de-linked and then left with a
2208
+ `:popover-open` rule that could no longer match. */
2209
+ const transposeSelector =
2210
+ (_canvasDelinkPopovers || _canvasDelinkCommands) && canvasStamped(el)
2211
+ ? (selector: string) =>
2212
+ transposeCanvasOverlaySelector(selector, {
2213
+ dialog: el.tagName === "DIALOG",
2214
+ })
2215
+ : (selector: string) => selector;
2216
+
2217
+ /* Reactive declarations become `var()` reads of a custom property this element sets inline. The
2218
+ variable inherits, which is correct, and the declaration stays in the rule where `:hover` can
2219
+ override it. The serial keeps two elements' variables apart: a descendant rule reads `var()`
2220
+ from the nearest ancestor that set it, and without it a styled child would shadow its parent's
2221
+ `--jx-r0` with its own. It also makes the rule text — and so the scope handle hashed from it —
2222
+ unique per element, which is exactly the sharing a reactive rule set must not have. */
2223
+ let serial = -1;
2224
+ let indirect = 0;
2225
+ const sources: { name: string; value: string | JxRef }[] = [];
2226
+ const rules = buildStyleRules(styleDef, {
2227
+ mediaQueries,
2228
+ resolveValue: (property, value, target) => {
2229
+ /* A reactive value on a CUSTOM PROPERTY, in a rule targeting the element itself, is written
2230
+ inline under the AUTHOR'S OWN NAME and contributes no declaration. That keeps the rule
2231
+ text free of anything per-element, so every row of a repeater interns ONE rule instead of
2232
+ one each: the serial below is in the rule text, the interning handle is a hash of that
2233
+ text, and so no two reactive elements could ever share a rule set. Measured before this:
2234
+ three font rows produced three handles and three rules.
2235
+ The declaration that READS the variable — `font-family: var(--row-face, inherit)` — lives
2236
+ in the owning element's own style, is identical across rows, and interns once.
2237
+ Only a SELF-target rule qualifies. A descendant rule keeps the indirection, because its
2238
+ variable must not be set on the element that carries the rule: a `var()` resolves from the
2239
+ nearest ancestor that set it, and a shared descendant rule would read the wrong one. */
2240
+ if (target === "self" && property.startsWith("--")) {
2241
+ sources.push({ name: property, value });
2242
+ return null;
2243
+ }
2244
+ if (serial < 0) {
2245
+ ({ serial } = sheetState);
2246
+ sheetState.serial += 1;
2247
+ }
2248
+ /* Counted separately from `sources`, so an inline custom property before it cannot shift the
2249
+ name of an indirected one. The name is IN the rule text, and the interning handle is a
2250
+ hash of that text — a number that moved with unrelated declarations would give two
2251
+ otherwise identical styles two rules. */
2252
+ const name = `--jx-r${serial}-${indirect}`;
2253
+ indirect += 1;
2254
+ sources.push({ name, value });
2255
+ return `var(${name})`;
2256
+ },
2257
+ scope: SCOPE_TOKEN,
2258
+ transposeSelector,
2259
+ transposeValue: canvasStyleValue,
2260
+ });
2261
+ /* `sources.length` too, not `rules.length` alone: a style whose ONLY reactive declaration is a
2262
+ custom property on the element itself emits no rule at all, and returning here would install
2263
+ no effect, so the variable would be written once and never track its source again. */
2264
+ if (rules.length === 0 && sources.length === 0) {
2265
+ return;
1429
2266
  }
1430
- el.style.cssText = "";
1431
- delete el.dataset.jx;
1432
- applyStyle(el, styleDef ?? {}, mediaQueries, state);
2267
+
2268
+ /* Declaration-body at-rules declare a document-global NAME — `@position-try --flip`,
2269
+ `@font-face` — so they are written once for the whole document and refcounted, rather than
2270
+ once per element that mentions them. */
2271
+ const scoped = rules.filter((rule) => rule.target !== "unscoped");
2272
+ const unscoped = rules.filter((rule) => rule.target === "unscoped");
2273
+ for (const rule of unscoped) {
2274
+ retainInterned(sheetState, sheetState.hoisted, rule.text, [rule.text]);
2275
+ }
2276
+
2277
+ const uid = `jx-${hashCss(scoped.map((rule) => rule.key).join("|"))}`;
2278
+ const texts = scoped.map((rule) => rule.text.replaceAll(SCOPE_TOKEN, `[data-jx="${uid}"]`));
2279
+ if (texts.length > 0) {
2280
+ el.dataset.jx = uid;
2281
+ if (live) {
2282
+ sheetState.scratch ??= openSink(doc);
2283
+ sheetState.scratchOwners.set(el, texts);
2284
+ rewriteScratch(sheetState);
2285
+ } else {
2286
+ retainInterned(sheetState, sheetState.interned, uid, texts);
2287
+ }
2288
+ }
2289
+
2290
+ const entry: ElementStyleEntry = {
2291
+ doc,
2292
+ hoisted: unscoped.map((rule) => rule.text),
2293
+ interned: texts.length > 0 && !live ? uid : null,
2294
+ state: sheetState,
2295
+ stop: null,
2296
+ uid,
2297
+ vars: sources.map((source) => source.name),
2298
+ };
2299
+ if (sources.length > 0) {
2300
+ const scope = effectScope();
2301
+ scope.run(() => {
2302
+ for (const { name, value } of sources) {
2303
+ effect(() => {
2304
+ const resolved = isRefObj(value)
2305
+ ? resolveRef(value.$ref, state)
2306
+ : evaluateTemplate(value, state);
2307
+ el.style.setProperty(name, canvasStyleValue(String(resolved ?? "")));
2308
+ });
2309
+ }
2310
+ });
2311
+ entry.stop = () => scope.stop();
2312
+ }
2313
+ elementStyles.set(el, entry);
2314
+ /* Ties the rules to the render that made them: a mapped array's discarded generation, or a
2315
+ Studio subtree being replaced, stops its scope and the rule set goes with it. Guarded on the
2316
+ entry so a later `reapplyStyle` is not undone by an older scope disposing after it. */
2317
+ onScopeDispose(() => releaseElementStyles(el, entry), true);
1433
2318
  }
1434
2319
 
1435
2320
  /*
@@ -1444,10 +2329,38 @@ export function reapplyStyle(
1444
2329
  * `contenteditable` means "inherit from the parent" rather than `false`. Every `aria-*` is in this
1445
2330
  * family — ARIA has no presence attributes at all — along with the three HTML attributes below.
1446
2331
  *
2332
+ * **`popover` is enumerated and is deliberately NOT in this set**, which looks like an oversight
2333
+ * and is not. Its keywords are `auto`, `manual` and `hint`; its MISSING-value default is `auto`, so
2334
+ * a bare `popover` and `popover=""` are both auto popovers — which is what the presence branch
2335
+ * below already produces for `true`, correctly. Its INVALID-value default is `manual`, so moving it
2336
+ * here would emit `popover="true"`, which is not a keyword, and every popover written as a boolean
2337
+ * would silently stop light-dismissing and stop answering Escape. The right answer to a boolean
2338
+ * `popover` is not a different emission, it is not writing one: `@jxsuite/schema/overlays` reports
2339
+ * it as `invalid-mode`, and the house spelling is `"auto"`.
2340
+ *
2341
+ * That also corrects the paragraph above for this one attribute: a presence attribute counts any
2342
+ * value as true, but `popover="false"` is a *manual* popover rather than a true-ish one, because it
2343
+ * is enumerated even though it is emitted through the presence branch.
2344
+ *
1447
2345
  * @docs framework/concepts/elements
1448
2346
  */
1449
2347
  const ENUMERATED_ATTRS = new Set(["contenteditable", "draggable", "spellcheck"]);
1450
2348
 
2349
+ /**
2350
+ * The enumerated-attribute names, for a code generator that cannot import this module.
2351
+ *
2352
+ * The compiler's element and client targets emit standalone ES modules that a built site loads
2353
+ * without `@jxsuite/runtime`, so they inline the boolean decision instead of calling it. They
2354
+ * serialize THIS array into that inlined helper, and `compile-element.test.ts` asserts the emitted
2355
+ * literal still equals it — which is what keeps the four writers of the rule one decision rather
2356
+ * than four, in the one place a shared function could not reach.
2357
+ *
2358
+ * @returns {string[]} The names, sorted, so the emitted output is stable.
2359
+ */
2360
+ export function enumeratedAttrNames(): string[] {
2361
+ return [...ENUMERATED_ATTRS].toSorted();
2362
+ }
2363
+
1451
2364
  /**
1452
2365
  * The text an attribute's boolean value must be written with, or `null` when saying it means
1453
2366
  * removing the attribute instead.
@@ -1482,6 +2395,13 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
1482
2395
  /* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
1483
2396
  effect runs — which is exactly why the document walk this replaced could never see it. */
1484
2397
  const write = (resolved: unknown) => {
2398
+ if (resolved === null || resolved === undefined) {
2399
+ /* Nothing is an attribute the element does not have. A binding that resolves to `null`
2400
+ has to take the attribute WITH it, for the same reason a `false` does below — and an
2401
+ `aria-checked=""` or `title=""` left behind is not absence, it is a wrong value. */
2402
+ el.removeAttribute(attr);
2403
+ return;
2404
+ }
1485
2405
  if (typeof resolved === "boolean") {
1486
2406
  const text = booleanAttrValue(attr, resolved);
1487
2407
  /* `removeAttribute`, not `setAttribute(attr, "false")`. A binding that flips back has to
@@ -1495,7 +2415,7 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
1495
2415
  }
1496
2416
  return;
1497
2417
  }
1498
- el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved ?? "")));
2418
+ el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved)));
1499
2419
  };
1500
2420
  if (isRefObj(v)) {
1501
2421
  effect(() => write(resolveRef(v.$ref, state)));
@@ -1512,12 +2432,24 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
1512
2432
  /**
1513
2433
  * Render a mapped array (repeater) wrapper-less: its item instances are inserted directly into
1514
2434
  * `parentEl`, in place, ahead of an anchor comment that marks the array's position among the
1515
- * parent's other children. Re-renders reactively when `items` (or the filter/sort sources) change;
1516
- * each generation's item renders live in their own detached effect scope so nested arrays and
1517
- * template bindings are disposed — not leaked or double-fired — on the next change.
2435
+ * parent's other children.
2436
+ *
2437
+ * Rows are RECONCILED, not rebuilt. Each row is keyed — by the `key` pointer the array declares,
2438
+ * else by its index — and owns a detached effect scope and a reactive `$map` context. When `items`
2439
+ * (or the filter/sort sources, or a key) change, a row whose key survives keeps its node and its
2440
+ * effects: a reorder moves the node, an insertion creates only the new rows, a removal stops only
2441
+ * the removed ones, and a moved row's `$map.index` is updated in place. That is what lets focus,
2442
+ * scroll position and a half-typed value survive a change to the list.
2443
+ *
2444
+ * Reads made while a row is constructed are made with tracking PAUSED. `effectScope.run()` does not
2445
+ * reset the active subscriber, so without this every top-level read a row performs — a `$props`
2446
+ * write, a tag discriminant, a `$`-local — subscribed the LIST effect, and one row's data changing
2447
+ * rebuilt the whole list. The bindings inside a row are effects of their own and re-enable tracking
2448
+ * when they run, so a row still updates itself.
1518
2449
  *
1519
2450
  * `options._path` is the array node's own document path (`[…, "children", i]`, or `[…, "children"]`
1520
- * for a legacy whole-children repeater); item instances render at `[…that…, "map", index]`.
2451
+ * for a legacy whole-children repeater); item instances render at `[…that…, "map", index]`, and a
2452
+ * row that moves reports its new path through `options.onNodeMoved`.
1521
2453
  *
1522
2454
  * @param {HTMLElement} parentEl
1523
2455
  * @param {import("@jxsuite/schema/types").JxMappedArray} arrayDef
@@ -1533,9 +2465,82 @@ function renderMappedArrayInto(
1533
2465
  const path = options?._path ?? [];
1534
2466
  const anchor = document.createComment("jx-array");
1535
2467
  parentEl.append(anchor);
1536
- const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } = arrayDef;
2468
+ const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef, key: keyRef } = arrayDef;
2469
+ const keyPointer = isRefObj(keyRef) ? keyRef.$ref : null;
2470
+
2471
+ let warned = false;
2472
+ const warnOnce = (message: string): void => {
2473
+ if (!warned) {
2474
+ warned = true;
2475
+ console.warn(`Jx $map: ${message}`);
2476
+ }
2477
+ };
1537
2478
 
1538
- effect(() => {
2479
+ /** The rows currently rendered, in DOM order. */
2480
+ let rows: MappedRow[] = [];
2481
+
2482
+ const teardown = (row: MappedRow): void => {
2483
+ row.scope.stop();
2484
+ row.node.remove();
2485
+ };
2486
+
2487
+ const createRow = (key: unknown, item: unknown, index: number): MappedRow => {
2488
+ const map = shallowReactive({ index, item });
2489
+ const child = Object.create(state) as JxScope;
2490
+ child.$map = map;
2491
+ // The flat aliases stay readable by legacy readers, and follow the reactive context.
2492
+ Object.defineProperty(child, "$map/item", { enumerable: true, get: () => map.item });
2493
+ Object.defineProperty(child, "$map/index", { enumerable: true, get: () => map.index });
2494
+ const scope = effectScope(true);
2495
+ const childOpts = options ? { ...options, _path: [...path, "map", index] } : undefined;
2496
+ const node = scope.run(() => renderNode(mapDef!, child, childOpts))!;
2497
+ return { key, map, node, scope };
2498
+ };
2499
+
2500
+ const reconcile = (list: unknown[], keys: unknown[]): void => {
2501
+ const previous = new Map<unknown, MappedRow>();
2502
+ for (const row of rows) {
2503
+ previous.set(row.key, row);
2504
+ }
2505
+ const next: MappedRow[] = [];
2506
+ for (let index = 0; index < list.length; index++) {
2507
+ const key = keys[index];
2508
+ const item = list[index];
2509
+ const existing = previous.get(key);
2510
+ if (existing) {
2511
+ previous.delete(key);
2512
+ if (existing.map.item !== item) {
2513
+ existing.map.item = item;
2514
+ }
2515
+ if (existing.map.index !== index) {
2516
+ existing.map.index = index;
2517
+ options?.onNodeMoved?.(existing.node, [...path, "map", index]);
2518
+ }
2519
+ next.push(existing);
2520
+ } else {
2521
+ next.push(createRow(key, item, index));
2522
+ }
2523
+ }
2524
+ // Whatever was not claimed is gone.
2525
+ for (const row of previous.values()) {
2526
+ teardown(row);
2527
+ }
2528
+ /* Order in one forward pass. The cursor walks the kept nodes in their current order; a row
2529
+ already under the cursor is in place, anything else is inserted before it — a new node, or
2530
+ a kept node moved forward. Only rows that are out of place move, and a move is the one thing
2531
+ that can cost a focused element its focus. */
2532
+ let cursor: ChildNode = rows.find((row) => !previous.has(row.key))?.node ?? anchor;
2533
+ for (const row of next) {
2534
+ if (row.node === cursor) {
2535
+ cursor = cursor.nextSibling ?? anchor;
2536
+ continue;
2537
+ }
2538
+ cursor.before(row.node);
2539
+ }
2540
+ rows = next;
2541
+ };
2542
+
2543
+ const update = (): void => {
1539
2544
  let items: unknown = isRefObj(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
1540
2545
  if (Array.isArray(items) && isRefObj(filterRef)) {
1541
2546
  const fn = resolveRef(filterRef.$ref, state);
@@ -1549,33 +2554,98 @@ function renderMappedArrayInto(
1549
2554
  items = [...(items as unknown[])].toSorted(fn as (a: unknown, b: unknown) => number);
1550
2555
  }
1551
2556
  }
1552
- if (!Array.isArray(items) || !mapDef) {
1553
- return;
2557
+ const list = Array.isArray(items) && mapDef ? (items as unknown[]) : [];
2558
+
2559
+ // Keys are read HERE, tracked, so a key that changes reconciles the list.
2560
+ const keys = list.map((item, index) => mappedRowKey(keyPointer, item, index, warnOnce));
2561
+ const seen = new Set<unknown>();
2562
+ for (let i = 0; i < keys.length; i++) {
2563
+ if (seen.has(keys[i])) {
2564
+ warnOnce(
2565
+ `duplicate key ${String(keys[i])} — every occurrence after the first is rebuilt on each change`,
2566
+ );
2567
+ keys[i] = { duplicate: i };
2568
+ } else {
2569
+ seen.add(keys[i]);
2570
+ }
1554
2571
  }
1555
2572
 
1556
- // Render this generation's items inside a detached scope; the cleanup (run before the next
1557
- // Re-render and when the enclosing render scope stops) tears it down and removes its nodes.
1558
- const scope = effectScope(true);
1559
- const nodes: ChildNode[] = [];
1560
- scope.run(() => {
1561
- for (const [index, item] of (items as unknown[]).entries()) {
1562
- const child = Object.create(state) as JxScope;
1563
- child.$map = { index, item };
1564
- child["$map/item"] = item;
1565
- child["$map/index"] = index;
1566
- const childOpts = options ? { ...options, _path: [...path, "map", index] } : undefined;
1567
- const node = renderNode(mapDef, child, childOpts);
1568
- anchor.before(node);
1569
- nodes.push(node);
1570
- }
1571
- });
1572
- onEffectCleanup(() => {
1573
- scope.stop();
1574
- for (const n of nodes) {
1575
- n.remove();
2573
+ pauseTracking();
2574
+ try {
2575
+ reconcile(list, keys);
2576
+ } finally {
2577
+ resetTracking();
2578
+ }
2579
+ };
2580
+
2581
+ /* The first render is synchronous; every re-render is coalesced into ONE microtask. A reactive
2582
+ array mutated in place — `reverse()`, `sort()`, an index write — triggers once per element it
2583
+ touches, and an effect run between two of those writes sees an array that is half of each
2584
+ state: a row that is transiently absent would be torn down and then rebuilt, which is exactly
2585
+ the identity loss keys exist to prevent. Deferring to a microtask is what a framework scheduler
2586
+ does for the same reason; a row's own bindings stay synchronous. */
2587
+ let queued = false;
2588
+ let stopped = false;
2589
+ const runner = effect(update, {
2590
+ scheduler: () => {
2591
+ if (queued) {
2592
+ return;
1576
2593
  }
1577
- });
2594
+ queued = true;
2595
+ queueMicrotask(() => {
2596
+ queued = false;
2597
+ if (!stopped) {
2598
+ runner();
2599
+ }
2600
+ });
2601
+ },
1578
2602
  });
2603
+
2604
+ onScopeDispose(() => {
2605
+ stopped = true;
2606
+ for (const row of rows) {
2607
+ teardown(row);
2608
+ }
2609
+ rows = [];
2610
+ }, true);
2611
+ }
2612
+
2613
+ /** A rendered row of a mapped array. */
2614
+ interface MappedRow {
2615
+ key: unknown;
2616
+ /** The row's `$map` context; `item` and `index` are written in place when the row is reused. */
2617
+ map: { index: number; item: unknown };
2618
+ node: HTMLElement | Text;
2619
+ scope: EffectScope;
2620
+ }
2621
+
2622
+ /**
2623
+ * The identity a `key` pointer names for one item: the item itself for `$map/item`, a value read
2624
+ * off it for `$map/item/<path>`. Anything else is not a key — an index names a position, not a row
2625
+ * — and a key that evaluates to nothing cannot tell rows apart, so both fall back to the index.
2626
+ */
2627
+ function mappedRowKey(
2628
+ pointer: string | null,
2629
+ item: unknown,
2630
+ index: number,
2631
+ warnOnce: (message: string) => void,
2632
+ ): unknown {
2633
+ if (pointer === null) {
2634
+ return index;
2635
+ }
2636
+ if (pointer === "$map/item") {
2637
+ return item;
2638
+ }
2639
+ if (pointer.startsWith("$map/item/")) {
2640
+ const key = readPath(item, pointer.slice("$map/item/".length));
2641
+ if (key === undefined || key === null) {
2642
+ warnOnce(`key "${pointer}" is empty for some items — those rows fall back to their index`);
2643
+ return index;
2644
+ }
2645
+ return key;
2646
+ }
2647
+ warnOnce(`key "${pointer}" is not a $map/item pointer — rows fall back to their index`);
2648
+ return index;
1579
2649
  }
1580
2650
 
1581
2651
  /**
@@ -1625,40 +2695,65 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
1625
2695
  applyProperties(container, def, state);
1626
2696
  applyStyle(container, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
1627
2697
  applyAttributes(container, def.attributes ?? {}, state);
2698
+ /* Every case renders in a detached scope of its own, stopped before the next case renders and
2699
+ when the container's own scope stops. A generation counter names the case a pending external
2700
+ load belongs to; it moves on EVERY change — an inline case included — so a load that resolves
2701
+ after the discriminant moved on cannot paint over what replaced it. */
1628
2702
  let generation = 0;
2703
+ let live: EffectScope | null = null;
2704
+ /* The key the rendered case was chosen by. A discriminant that re-resolves to the SAME key keeps
2705
+ its case: the subtree, its effects and its state — an open submenu, a half-typed value —
2706
+ survive. Without this a mapped row whose item was replaced by an equal one (a host rebuilding
2707
+ its projection) rebuilt every switch inside it, and the popover the case rendered closed. */
2708
+ let renderedKey: string | null = null;
2709
+ const retire = (): void => {
2710
+ live?.stop();
2711
+ live = null;
2712
+ };
1629
2713
 
1630
2714
  effect(() => {
2715
+ if (!isRefObj(def.$switch)) {
2716
+ retire();
2717
+ container.replaceChildren();
2718
+ generation += 1;
2719
+ return;
2720
+ }
2721
+ const key = String(resolveRef(def.$switch.$ref, state));
2722
+ if (key === renderedKey) {
2723
+ return;
2724
+ }
2725
+ renderedKey = key;
2726
+ retire();
1631
2727
  /* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
1632
2728
  Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
1633
2729
  needs a policy even when the string is empty. Four sinks that were never injecting anything
1634
2730
  is four fewer things a policy has to be permissive about. */
1635
2731
  container.replaceChildren();
1636
- if (!isRefObj(def.$switch)) {
1637
- return;
1638
- }
1639
- const key = resolveRef(def.$switch.$ref, state) as string;
2732
+ generation += 1;
1640
2733
  const caseDef = def.cases?.[key];
1641
2734
  if (!caseDef) {
1642
2735
  return;
1643
2736
  }
2737
+ const gen = generation;
2738
+ const scope = effectScope(true);
2739
+ live = scope;
2740
+ const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
1644
2741
 
1645
2742
  if (isRefObj(caseDef)) {
1646
- // External $ref — fetch and render asynchronously
1647
- generation += 1;
1648
- const gen = generation;
1649
- const { href } = new URL(caseDef.$ref, location.href);
1650
- resolve(href)
2743
+ // External $ref — fetch and render asynchronously, against the mount's base so a bundled
2744
+ // Document need not live at the page URL.
2745
+ const { href } = new URL(caseDef.$ref, options?._ctx?.base ?? location.href);
2746
+ resolve(href, options?._ctx)
1651
2747
  .then(async (doc) => {
1652
2748
  if (gen !== generation) {
1653
2749
  return;
1654
2750
  }
1655
- const childScope = await buildScope(doc, {}, href);
2751
+ const childScope = await buildScope(doc, {}, href, options?._ctx);
1656
2752
  if (gen !== generation) {
1657
2753
  return;
1658
2754
  }
1659
2755
  container.replaceChildren();
1660
- const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
1661
- container.append(renderNode(doc, childScope, childOpts));
2756
+ scope.run(() => container.append(renderNode(doc, childScope, childOpts)));
1662
2757
  })
1663
2758
  .catch((error: unknown) =>
1664
2759
  console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
@@ -1666,9 +2761,17 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
1666
2761
  return;
1667
2762
  }
1668
2763
 
1669
- const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
1670
- container.append(renderNode(caseDef, state, childOpts));
2764
+ // Tracking is paused for the same reason it is for a mapped row: a top-level read the case
2765
+ // Makes while it renders must not subscribe THIS effect, or the case re-renders on every
2766
+ // Change to anything it read. The bindings inside it track for themselves.
2767
+ pauseTracking();
2768
+ try {
2769
+ scope.run(() => container.append(renderNode(caseDef, state, childOpts)));
2770
+ } finally {
2771
+ resetTracking();
2772
+ }
1671
2773
  });
2774
+ onScopeDispose(retire, true);
1672
2775
 
1673
2776
  return container;
1674
2777
  }
@@ -1692,6 +2795,7 @@ export async function resolvePrototype(
1692
2795
  state: JxScope,
1693
2796
  key: string,
1694
2797
  base?: string,
2798
+ ctx?: JxContext,
1695
2799
  ) {
1696
2800
  // ── External class via $src ─────────────────────────────────────────────────
1697
2801
  if (def.$src) {
@@ -1704,7 +2808,7 @@ export async function resolvePrototype(
1704
2808
  const debounceMs = def.debounce ?? 0;
1705
2809
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
1706
2810
 
1707
- if (!def.manual && !_autoRequestConfig.skip) {
2811
+ if (!def.manual && !(ctx?.skipAutoRequests ?? _autoRequestConfig.skip)) {
1708
2812
  effect(() => {
1709
2813
  let url: string | undefined;
1710
2814
  if (isTemplateString(def.url)) {
@@ -1953,8 +3057,11 @@ async function resolveExternalPrototype(
1953
3057
  */
1954
3058
  async function importAndInstantiate(def: JxScope, src: string, exportName: string, base?: string) {
1955
3059
  let mod: ImportedModule;
1956
- if (_moduleCache.has(src)) {
1957
- mod = _moduleCache.get(src)!;
3060
+ const seeded = seededModule(src);
3061
+ if (seeded instanceof Promise) {
3062
+ mod = await seeded;
3063
+ } else if (seeded) {
3064
+ mod = seeded;
1958
3065
  } else {
1959
3066
  try {
1960
3067
  mod = (await import(src)) as ImportedModule;
@@ -2266,8 +3373,11 @@ async function resolveServerFunction(
2266
3373
  const exportName = def.$export;
2267
3374
 
2268
3375
  let mod: ImportedModule;
2269
- if (_moduleCache.has(src)) {
2270
- mod = _moduleCache.get(src)!;
3376
+ const seeded = seededModule(src);
3377
+ if (seeded instanceof Promise) {
3378
+ mod = await seeded;
3379
+ } else if (seeded) {
3380
+ mod = seeded;
2271
3381
  } else {
2272
3382
  try {
2273
3383
  mod = (await import(src)) as ImportedModule;
@@ -2406,8 +3516,11 @@ export function resolveRef(refPath: string, state: JxScope) {
2406
3516
  if (typeof refPath !== "string") {
2407
3517
  return refPath;
2408
3518
  }
2409
- if (refPath.startsWith("$map/")) {
2410
- const parts = refPath.split("/");
3519
+ if (refPath.startsWith("$map/") || refPath.startsWith("#/$map/")) {
3520
+ /* Both spellings. `#/` is the documented JSON Pointer prefix everywhere else in the schema,
3521
+ so a `#/$map/item/x` fell through to `readPath`, returned null, and a `$map` over it
3522
+ rendered ZERO rows with no warning. */
3523
+ const parts = (refPath.startsWith("#/") ? refPath.slice(2) : refPath).split("/");
2411
3524
  const [, key] = parts; // "item" or "index"
2412
3525
  const map = state.$map as Record<string, unknown> | undefined;
2413
3526
  const base = map?.[key!] ?? state[`$map/${key}`];
@@ -2454,14 +3567,6 @@ function isRefObj(v: unknown): v is JxRef {
2454
3567
  return isRefValue(v);
2455
3568
  }
2456
3569
 
2457
- /**
2458
- * @param {string} k
2459
- * @returns {boolean}
2460
- */
2461
- function isNestedSelector(k: string) {
2462
- return k.startsWith(":") || k.startsWith(".") || k.startsWith("&") || k.startsWith("[");
2463
- }
2464
-
2465
3570
  /**
2466
3571
  * Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
2467
3572
  * `/[./]/`, which no other path in the codebase agreed with.
@@ -2474,6 +3579,71 @@ function getPath(obj: unknown, path: string) {
2474
3579
  return readPath(obj, path);
2475
3580
  }
2476
3581
 
3582
+ /**
3583
+ * Write an observed attribute into element state, coerced by the type the state entry already holds
3584
+ * (spec §16.5): a number parses, a boolean is presence (`"false"` counts as absent), and anything
3585
+ * else is the string. Shared by connection and `attributeChangedCallback` so the two cannot
3586
+ * disagree.
3587
+ *
3588
+ * REMOVING an attribute restores the entry's DECLARED DEFAULT rather than writing the absence
3589
+ * through. `attributeChangedCallback` reports a removal as `null`, and writing that through gave a
3590
+ * `type: "string"` entry the value `null` and a `type: "number"` entry `Number(null)`, which is 0 —
3591
+ * so a numeric prop could never express "unset" and a string prop stopped matching its own declared
3592
+ * type. The default is the value the entry had before anyone set the attribute, which is what the
3593
+ * removal is asking to go back to.
3594
+ *
3595
+ * @param defaults The declared defaults by state key; a key absent from it has none.
3596
+ */
3597
+ function absorbAttribute(
3598
+ state: JxScope,
3599
+ name: string,
3600
+ value: string | null,
3601
+ defaults?: ReadonlyMap<string, unknown>,
3602
+ ): void {
3603
+ const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
3604
+ const current = state[camelKey];
3605
+ if (typeof current === "boolean") {
3606
+ // Presence IS the value for a boolean, so a removal is already spelled by the same rule.
3607
+ state[camelKey] = value !== null && value !== "false";
3608
+ return;
3609
+ }
3610
+ if (value === null) {
3611
+ const declared = defaults?.get(camelKey);
3612
+ state[camelKey] = declared === undefined ? (typeof current === "number" ? 0 : "") : declared;
3613
+ return;
3614
+ }
3615
+ state[camelKey] = typeof current === "number" ? Number(value) : value;
3616
+ }
3617
+
3618
+ /**
3619
+ * The declared default of every state entry that has one, by state key.
3620
+ *
3621
+ * @param def The element definition
3622
+ * @returns {Map<string, unknown>} Key → default
3623
+ */
3624
+ function declaredDefaults(def: JxElement): Map<string, unknown> {
3625
+ const out = new Map<string, unknown>();
3626
+ for (const [key, entry] of Object.entries(def.state ?? {})) {
3627
+ if (entry !== null && typeof entry === "object") {
3628
+ const obj = entry as Record<string, unknown>;
3629
+ if ("default" in obj) {
3630
+ out.set(key, obj["default"]);
3631
+ continue;
3632
+ }
3633
+ // A computed entry has no default: its value is produced, and a removal leaves it alone.
3634
+ if (COMPUTED_STATE_KEYS.some((marker) => marker in obj)) {
3635
+ continue;
3636
+ }
3637
+ }
3638
+ // The shorthand `{ "label": "none" }` IS the default, the same way `buildScope` reads it.
3639
+ out.set(key, entry);
3640
+ }
3641
+ return out;
3642
+ }
3643
+
3644
+ /** The markers that make a state entry produced rather than authored. */
3645
+ const COMPUTED_STATE_KEYS = ["$expression", "$prototype", "$ref", "$src"] as const;
3646
+
2477
3647
  /** Keys already reported, so a component rendered in a loop warns once rather than per instance. */
2478
3648
  const _privatePropWarned = new Set<string>();
2479
3649
 
@@ -2559,125 +3729,106 @@ function mergeProps(def: JxElement, parentState: JxScope): JxScope {
2559
3729
  return child;
2560
3730
  }
2561
3731
 
2562
- /**
2563
- * Convert camelCase to kebab-case.
2564
- *
2565
- * @param {string} s
2566
- * @returns {string}
3732
+ /*
3733
+ * The CSS-authoring rules live in `./css.ts`, whose only import is a pair of type guards. They are
3734
+ * re-exported
3735
+ * here because they always were part of this module's surface and moving them must not break a
3736
+ * consumer; a host that cannot afford the DOM runtime imports `@jxsuite/runtime/css` instead.
2567
3737
  */
2568
- export function camelToKebab(s: string) {
2569
- return s.replaceAll(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
2570
- }
3738
+ export {
3739
+ buildStyleRules,
3740
+ camelToKebab,
3741
+ COLOR_SCHEME_ATTR,
3742
+ COLOR_SCHEME_STORAGE_KEY,
3743
+ cssPropertyName,
3744
+ cssRuleText,
3745
+ hashCss,
3746
+ isDeclarationAtRule,
3747
+ isKeyframesAtRule,
3748
+ isNestedSelectorKey,
3749
+ pureSchemeOf,
3750
+ resolveAtQuery,
3751
+ resolveNestedSelector,
3752
+ schemeSelectors,
3753
+ splitSelectorList,
3754
+ transposeCanvasOverlaySelector,
3755
+ transposeCanvasPopoverSelector,
3756
+ } from "./css.ts";
3757
+ export type { CssBuildOptions, CssKeyframeBlock, CssRule, CssRuleTarget } from "./css.ts";
2571
3758
 
2572
3759
  /**
2573
3760
  * Convert a style rules object to a CSS text string (skipping nested selectors).
2574
3761
  *
3762
+ * Kept for consumers; nothing in this repository composes a rule this way any more, because
3763
+ * `buildStyleRules` answers the same question for a whole style object rather than one flat level
3764
+ * of it. It shares that function's property spelling — `cssPropertyName`, not `camelToKebab` — so a
3765
+ * custom property survives it: `--fooBar` and `--foo-bar` are two different properties, and
3766
+ * kebab-casing one renames it out from under the `var()` that reads it.
3767
+ *
2575
3768
  * @param {Record<string, unknown> | object} rules
2576
3769
  * @returns {string}
2577
3770
  */
2578
3771
  export function toCSSText(rules: Record<string, unknown> | object) {
2579
3772
  return Object.entries(rules)
2580
- .filter(([k, v]) => !isNestedSelector(k) && (v === null || typeof v !== "object"))
2581
- .map(([p, v]) => `${camelToKebab(p)}: ${canvasStyleValue(String(v))}`)
3773
+ .filter(([k, v]) => !isNestedSelectorKey(k) && (v === null || typeof v !== "object"))
3774
+ .map(([p, v]) => `${cssPropertyName(p)}: ${canvasStyleValue(String(v))}`)
2582
3775
  .join("; ");
2583
3776
  }
2584
3777
 
2585
- // ─── Style At-Rules ───────────────────────────────────────────────────────────
2586
-
2587
- /**
2588
- * The four CSS media types. A media _type_ is bare — `@media print` — while a media _feature_ is
2589
- * parenthesised, so `@media (print)` reads as a boolean feature named `print`, which does not exist
2590
- * and evaluates false.
2591
- */
2592
- const MEDIA_TYPES = new Set(["all", "print", "screen", "speech"]);
2593
-
2594
- /**
2595
- * The media query an `@`-prefixed style key names, or null when the key is some other at-rule
2596
- * (`@supports`, `@starting-style`) that is emitted verbatim.
2597
- *
2598
- * `@--name` resolves through the project's `$media` map. `@(…)` carries its own parentheses, so
2599
- * they are stripped only when what they wrap is a bare media type: `@(print)` must become `@media
2600
- * print`, while `@(min-width: 40rem)` keeps them.
2601
- *
2602
- * @param {string} atKey
2603
- * @param {Record<string, string>} mediaQueries
2604
- * @returns {string | null}
2605
- * @docs framework/concepts/styling
2606
- */
2607
- export function resolveAtQuery(atKey: string, mediaQueries: Record<string, string>): string | null {
2608
- if (atKey.startsWith("@--")) {
2609
- return mediaQueries[atKey.slice(1)] ?? atKey.slice(1);
2610
- }
2611
- if (atKey.startsWith("@(")) {
2612
- const inner = atKey.slice(2, -1).trim();
2613
- return MEDIA_TYPES.has(inner.toLowerCase()) ? inner : atKey.slice(1);
2614
- }
2615
- return null;
2616
- }
2617
-
2618
- // ─── Color Schemes ────────────────────────────────────────────────────────────
2619
-
2620
- /**
2621
- * Attribute on the root element (`<html>`) that forces a color scheme. Absent = auto (follow the OS
2622
- * `prefers-color-scheme`). Values: "light" | "dark".
2623
- *
2624
- * @docs framework/concepts/color-schemes
2625
- */
2626
- export const COLOR_SCHEME_ATTR = "data-color-scheme";
3778
+ // ─── Custom Element Registration ──────────────────────────────────────────────
2627
3779
 
3780
+ let _rootMedia: Record<string, string> = {};
2628
3781
  /**
2629
- * The localStorage key a site switcher persists the visitor's forced scheme under; read by the
2630
- * compiler-injected pre-paint script. Values: "light" | "dark" (absent = auto).
2631
- *
2632
- * @docs framework/concepts/color-schemes
3782
+ * Every defined element's CURRENT definition, read at connection — which is what lets a definition
3783
+ * be replaced.
2633
3784
  */
2634
- export const COLOR_SCHEME_STORAGE_KEY = "jx-color-scheme";
3785
+ const _elementDefs = new Map<string, { base: string; doc: JxDocument }>();
2635
3786
 
2636
3787
  /**
2637
- * The scheme a _pure_ `prefers-color-scheme` media query targets. Compound queries (any other
2638
- * conditions attached) return null — they are not eligible for forced-scheme dual emission.
3788
+ * The definition an element tag renders from, as most recently defined or redefined.
2639
3789
  *
2640
- * @param {string} query
2641
- * @returns {"light" | "dark" | null}
2642
- * @docs framework/concepts/color-schemes
3790
+ * @param {string} tagName
3791
+ * @returns {{ base: string; doc: JxDocument } | undefined}
2643
3792
  */
2644
- export function pureSchemeOf(query: string): "light" | "dark" | null {
2645
- const m = /^\(\s*prefers-color-scheme\s*:\s*(light|dark)\s*\)$/.exec(query.trim());
2646
- return (m?.[1] as "light" | "dark") ?? null;
3793
+ export function elementDefinition(tagName: string): { base: string; doc: JxDocument } | undefined {
3794
+ return _elementDefs.get(tagName);
2647
3795
  }
2648
3796
 
2649
3797
  /**
2650
- * Selector pair for a scheme-conditional rule: `auto` applies inside the scheme's media query only
2651
- * while no scheme is forced; `forced` applies unconditionally when the root attribute forces the
2652
- * scheme. Guards are wrapped in `:where()` so specificity matches the unguarded selector and source
2653
- * order decides the cascade.
3798
+ * Replace an element's definition (embedding.md §7). `customElements.define` is one-shot, so the
3799
+ * generated class reads its definition through the registry at connection time: an instance
3800
+ * connected after this call renders the new document; one already on the page keeps the definition
3801
+ * it rendered until it is re-mounted. `observedAttributes` is the one thing the platform freezes at
3802
+ * first definition, so a changed list is reported and takes effect only in a fresh realm. A tag not
3803
+ * yet defined is simply defined.
2654
3804
  *
2655
- * @param {string} selector
2656
- * @param {"light" | "dark"} scheme
2657
- * @returns {{ auto: string; forced: string }}
2658
- * @docs framework/concepts/color-schemes
3805
+ * @param {JxDocument} doc
3806
+ * @param {string} [baseUrl]
3807
+ * @returns {Promise<void>}
2659
3808
  */
2660
- export function schemeSelectors(
2661
- selector: string,
2662
- scheme: "light" | "dark",
2663
- ): { auto: string; forced: string } {
2664
- if (selector === ":root" || selector === "html") {
2665
- return {
2666
- auto: `${selector}:where(:not([${COLOR_SCHEME_ATTR}]))`,
2667
- forced: `${selector}:where([${COLOR_SCHEME_ATTR}="${scheme}"])`,
2668
- };
3809
+ export async function redefineElement(doc: JxDocument, baseUrl?: string): Promise<void> {
3810
+ const base = baseUrl ?? location.href;
3811
+ const { tagName } = doc;
3812
+ if (!tagName || !tagName.includes("-")) {
3813
+ throw new Error(`Jx redefineElement: tagName "${tagName}" must contain a hyphen`);
2669
3814
  }
2670
- return {
2671
- auto: `:where(:root:not([${COLOR_SCHEME_ATTR}])) ${selector}`,
2672
- forced: `:where(:root[${COLOR_SCHEME_ATTR}="${scheme}"]) ${selector}`,
2673
- };
3815
+ if (!customElements.get(tagName)) {
3816
+ await defineElement(doc, base);
3817
+ return;
3818
+ }
3819
+ if (doc.$elements) {
3820
+ await registerElements(doc.$elements, base);
3821
+ }
3822
+ const before = _elementDefs.get(tagName)?.doc.observedAttributes ?? [];
3823
+ const after = doc.observedAttributes ?? [];
3824
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
3825
+ console.warn(
3826
+ `Jx redefineElement: <${tagName}> keeps observedAttributes [${before.join(", ")}] as first defined; [${after.join(", ")}] takes effect only in a fresh realm`,
3827
+ );
3828
+ }
3829
+ _elementDefs.set(tagName, { base, doc });
2674
3830
  }
2675
3831
 
2676
- // ─── Custom Element Registration ──────────────────────────────────────────────
2677
-
2678
- let _rootMedia: Record<string, string> = {};
2679
- const _elementDefs = new Map();
2680
-
2681
3832
  /**
2682
3833
  * Seed the module-level root `$media` map used as the fallback for components that declare their
2683
3834
  * own `@--name` style blocks but carry no own `$media` (buildScope ~279-280). `Jx()` sets this from
@@ -2700,7 +3851,11 @@ export function setRootMedia(map: Record<string, string>): void {
2700
3851
  * @param {string} base
2701
3852
  * @returns {Promise<void>}
2702
3853
  */
2703
- async function registerElements(elements: NonNullable<JxDocument["$elements"]>, base: string) {
3854
+ async function registerElements(
3855
+ elements: NonNullable<JxDocument["$elements"]>,
3856
+ base: string,
3857
+ ctx?: JxContext,
3858
+ ) {
2704
3859
  for (const entry of elements) {
2705
3860
  // Bare string: npm package side-effect import (registers custom elements)
2706
3861
  if (typeof entry === "string") {
@@ -2719,7 +3874,7 @@ async function registerElements(elements: NonNullable<JxDocument["$elements"]>,
2719
3874
  continue;
2720
3875
  }
2721
3876
  const { href } = new URL(entry.$ref, base);
2722
- const doc = await resolve(href);
3877
+ const doc = await resolve(href, ctx);
2723
3878
  if (!doc.tagName || !doc.tagName.includes("-")) {
2724
3879
  continue;
2725
3880
  }
@@ -2729,7 +3884,7 @@ async function registerElements(elements: NonNullable<JxDocument["$elements"]>,
2729
3884
 
2730
3885
  // Depth-first: register sub-dependencies first
2731
3886
  if (doc.$elements) {
2732
- await registerElements(doc.$elements, href);
3887
+ await registerElements(doc.$elements, href, ctx);
2733
3888
  }
2734
3889
 
2735
3890
  await defineElement(doc, href);
@@ -2827,8 +3982,11 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2827
3982
 
2828
3983
  _elementDefs.set(tagName, { base, doc: source_ });
2829
3984
 
2830
- const def = source_;
2831
- const observedAttrs = def.observedAttributes ?? [];
3985
+ // The definition as first given. The class reads the CURRENT one at connection (see
3986
+ // `redefineElement`); only `observedAttributes` is frozen here, because the platform freezes it.
3987
+ const initialDef = source_;
3988
+ const initialBase = base;
3989
+ const observedAttrs = initialDef.observedAttributes ?? [];
2832
3990
 
2833
3991
  const ElementClass = class extends HTMLElement {
2834
3992
  _jxInitialized = false;
@@ -2851,10 +4009,26 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2851
4009
  }
2852
4010
  this._jxInitialized = true;
2853
4011
 
2854
- const state = await buildScope(def, {}, base);
4012
+ // The definition as it stands NOW — a redefinition since this class was made is honoured.
4013
+ const live = _elementDefs.get(tagName) ?? { base: initialBase, doc: initialDef };
4014
+ const def = live.doc;
4015
+ const defBase = live.base;
4016
+
4017
+ // The element is its own dispatch root: a body run without an event emits from the host.
4018
+ const state = await buildScope(def, {}, defBase, { base: defBase, media: null, root: this });
2855
4019
 
2856
4020
  // Read properties from the data-jx-props payload the site build writes on a
2857
4021
  // Non-static instance, so an upgrade re-renders with the authored props, not the defaults.
4022
+ /* Observed attributes already on the element are read now. `attributeChangedCallback` fires
4023
+ for an attribute set before connection too — but on an instance with no state yet, so it
4024
+ had nothing to write into, and `<jx-icon name="plus">` rendered with the default. Before
4025
+ the `$props` merges below, so a property a parent set still wins (spec §16.2). */
4026
+ for (const attr of observedAttrs) {
4027
+ if (this.hasAttribute(attr)) {
4028
+ absorbAttribute(state, attr, this.getAttribute(attr));
4029
+ }
4030
+ }
4031
+
2858
4032
  const propsAttr = this.dataset.jxProps;
2859
4033
  if (propsAttr) {
2860
4034
  try {
@@ -2929,16 +4103,22 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2929
4103
  const slottedChildren = [...this.childNodes];
2930
4104
  this.replaceChildren();
2931
4105
 
2932
- // Custom elements default to display:inline — use block so they behave as
2933
- // Containers (matching <div> semantics). The component's own style can
2934
- // Override this if needed.
2935
- if (!this.style.display) {
2936
- this.style.display = "block";
2937
- }
4106
+ /* Custom elements default to `display: inline`; a Jx container behaves like a `<div>`, so
4107
+ one is supplied. It goes in the element's OWN RULE rather than inline: an inline
4108
+ declaration is beaten only by `!important`, so a consumer could not override the default
4109
+ without one. At (0,1,0) a consumer's rule, a cascade layer or a parent's descendant rule
4110
+ all win normally, and the definition's own `display` still wins because it is written into
4111
+ the same rule after this. */
4112
+ const defStyle = declaresDisplay(def.style) ? def.style : { display: "block", ...def.style };
4113
+ /* Definition first, call site second, so the call site wins at equal specificity by source
4114
+ order — the promise §9.2 already makes for base-before-nested. Merged into ONE call
4115
+ because each `applyStyle` releases what the last one interned. */
4116
+ const hostStyle = mergeStyle(defStyle as JxStyle, callSiteStyles.get(this));
2938
4117
 
2939
4118
  // Render template into light DOM (once, not in effect — inner effects handle reactivity)
2940
- applyStyle(this, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
4119
+ applyStyle(this, hostStyle, (state["$media"] as Record<string, string>) ?? {}, state);
2941
4120
  applyAttributes(this, def.attributes ?? {}, state);
4121
+ bindDefinitionHandlers(this, def, state);
2942
4122
 
2943
4123
  /*
2944
4124
  * Root-level `textContent` is a definition's content just as much as `children` is — it is
@@ -2949,17 +4129,30 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2949
4129
  bindProperty(this, "textContent", def.textContent, state);
2950
4130
  }
2951
4131
  const children = Array.isArray(def.children) ? def.children : [];
2952
- for (const childDef of children) {
2953
- this.append(renderNode(childDef, state));
4132
+ /* An instance the studio stamped is part of the page being edited, so everything the
4133
+ definition draws inside it is too — and the de-link rules cannot see that from the nodes
4134
+ themselves. Restored rather than cleared, because one definition may render another. */
4135
+ const wasInside = _canvasInsideStampedHost;
4136
+ _canvasInsideStampedHost ||= this.dataset.jxPath !== undefined;
4137
+ try {
4138
+ for (const childDef of children) {
4139
+ this.append(renderNode(childDef, state));
4140
+ }
4141
+ } finally {
4142
+ _canvasInsideStampedHost = wasInside;
2954
4143
  }
2955
4144
 
2956
4145
  // Slot distribution (light DOM)
2957
4146
  distributeSlots(this, slottedChildren);
2958
4147
 
2959
- // Lifecycle: onMount
4148
+ /* Lifecycle: onMount, with the HOST as its second argument.
4149
+ A sidecar that must reach the element it belongs to had no way to: the runtime handed it
4150
+ the scope alone, so elements resorted to dispatching a `jx-ready` event at themselves and
4151
+ catching it with a root handler purely to learn `currentTarget`. The host is what the
4152
+ callback needed; the event round trip was the workaround. */
2960
4153
  const { onMount } = state;
2961
4154
  if (typeof onMount === "function") {
2962
- queueMicrotask(() => (onMount as (s: JxScope) => unknown)(state));
4155
+ queueMicrotask(() => (onMount as (s: JxScope, host: HTMLElement) => unknown)(state, this));
2963
4156
  }
2964
4157
  }
2965
4158
 
@@ -2979,15 +4172,14 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2979
4172
  if (!this._state || oldVal === newVal) {
2980
4173
  return;
2981
4174
  }
2982
- const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
2983
- const current = this._state[camelKey];
2984
- if (typeof current === "number") {
2985
- this._state[camelKey] = Number(newVal);
2986
- } else if (typeof current === "boolean") {
2987
- this._state[camelKey] = newVal !== null && newVal !== "false";
2988
- } else {
2989
- this._state[camelKey] = newVal;
2990
- }
4175
+ // The CURRENT definition's defaults, so a redefinition (embedding.md §7) that changes one
4176
+ // Reaches an instance that is already connected.
4177
+ absorbAttribute(
4178
+ this._state,
4179
+ name,
4180
+ newVal,
4181
+ declaredDefaults(_elementDefs.get(tagName)?.doc ?? initialDef),
4182
+ );
2991
4183
  }
2992
4184
  };
2993
4185
 
@@ -3044,15 +4236,33 @@ function renderCustomElementWithProps(
3044
4236
  }
3045
4237
  }
3046
4238
 
3047
- // Apply host-level style and attributes from the usage site
4239
+ /* Apply host-level style and attributes from the usage site.
4240
+ Also REMEMBERED, because `connectedCallback` applies the definition's style through the same
4241
+ interning path and `applyStyleInto` releases the element's existing rules first — so without
4242
+ this the call site's declarations were written here and deleted a moment later, and a document
4243
+ could not style an element instance at all. See {@link callSiteStyles}. */
4244
+ if (def.style) {
4245
+ callSiteStyles.set(el, def.style);
4246
+ }
3048
4247
  applyStyle(el, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
3049
4248
  applyAttributes(el, def.attributes ?? {}, state);
3050
-
3051
- // Append slotted children
4249
+ /* And its ordinary properties and handlers — `id`, `hidden`, `className`, an `onselect` — exactly
4250
+ as an ordinary element gets them. A custom element used in a document is still an element in
4251
+ that document; only `$props` are the definition's to absorb. */
4252
+ applyProperties(el, def, state);
4253
+
4254
+ // Append slotted children. A mapped array is not a node: its rows are rendered in place, ahead of
4255
+ // Their anchor, exactly as they are under an ordinary element — so a `$map` of menu rows inside a
4256
+ // `jx-menu` is a list of rows, not one `<div>` standing where the list should be.
3052
4257
  const children = Array.isArray(def.children) ? def.children : [];
3053
4258
  for (let i = 0; i < children.length; i++) {
4259
+ const child = children[i]!;
3054
4260
  const childOpts = options && path ? { ...options, _path: [...path, "children", i] } : undefined;
3055
- el.append(renderNode(children[i]!, state, childOpts));
4261
+ if (isMappedArray(child)) {
4262
+ renderMappedArrayInto(el, child, state, childOpts);
4263
+ } else {
4264
+ el.append(renderNode(child, state, childOpts));
4265
+ }
3056
4266
  }
3057
4267
 
3058
4268
  return el;
@@ -3065,10 +4275,11 @@ function renderCustomElementWithProps(
3065
4275
  * @param {ChildNode[]} slottedChildren
3066
4276
  */
3067
4277
  function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
3068
- if (slottedChildren.length === 0) {
3069
- return;
3070
- }
3071
-
4278
+ /* No early return when nothing was slotted. A slot leaves no node WHETHER OR NOT it received
4279
+ anything — an unmatched one unwraps to its own fallback children, and a host given nothing at
4280
+ all is just every slot unmatched. Returning early here left the slot standing in exactly the
4281
+ case an element most wants to detect: `[part="label"]:empty` answered false for a control with
4282
+ no label, because the surviving slot was still a child. */
3072
4283
  const slots = host.querySelectorAll("slot");
3073
4284
  if (slots.length === 0) {
3074
4285
  return;
@@ -3089,14 +4300,16 @@ function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
3089
4300
  }
3090
4301
  }
3091
4302
 
4303
+ /* A `<slot>` UNWRAPS: its matches stand in its place and the slot itself leaves no node.
4304
+ It used to survive, holding its matches as children. The box tree looked right, because a
4305
+ slot is `display: contents` — but the SELECTOR tree did not, so every `& > x` rule in a
4306
+ definition silently stopped matching a slotted child, which is now a grandchild. Measured on
4307
+ the field row: 68.40px where the class form gives 88.40px, and its help text 80px wide
4308
+ instead of 260px, with `[data-span]` dead. An unmatched slot unwraps to its own fallback
4309
+ children, which is what the platform's slot does when nothing is assigned to it. */
3092
4310
  for (const slot of slots) {
3093
4311
  const name = slot.getAttribute("name");
3094
4312
  const matches = name ? (named.get(name) ?? []) : unnamed;
3095
- if (matches.length > 0) {
3096
- slot.replaceChildren();
3097
- for (const child of matches) {
3098
- slot.append(child);
3099
- }
3100
- }
4313
+ slot.replaceWith(...(matches.length > 0 ? matches : [...slot.childNodes]));
3101
4314
  }
3102
4315
  }