@jxsuite/runtime 3.1.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/css.ts CHANGED
@@ -2,11 +2,15 @@
2
2
  * The CSS-authoring rules that are not the DOM.
3
3
  *
4
4
  * `@jxsuite/runtime` renders a document in a browser, and every consumer of its root export pays
5
- * for that: the element registry, the reactive scope, `@vue/reactivity`. These six exports need
6
- * none of it. They are pure string and regex math over what a `style` block MEANS — how a property
7
- * name is spelled in CSS, which media query an `@`-key resolves to, and which selector pair a
5
+ * for that: the element registry, the reactive scope, `@vue/reactivity`. These exports need none of
6
+ * it. They are pure string and regex math over what a `style` block MEANS — how a property name is
7
+ * spelled in CSS, which media query an `@`-key resolves to, and which selector pair a
8
8
  * scheme-conditional rule dual-emits as.
9
9
  *
10
+ * The rule builder joined them for the same reason: it is what a style object MEANS as a list of
11
+ * CSS rules, and three emitters used to answer that question three different ways. Its only import
12
+ * is `@jxsuite/schema/guards`, which imports nothing but types.
13
+ *
10
14
  * They are a subpath of their own because of who else needs them. `@jxsuite/site/site-style` turns
11
15
  * `project.json`'s `style` into a stylesheet, and it runs in places the DOM runtime cannot go — a
12
16
  * static build, and a Cloudflare Worker serving a live preview. Importing the root export for
@@ -17,6 +21,9 @@
17
21
  * has to change. This is a narrow door beside the wide one, not a replacement for it.
18
22
  */
19
23
 
24
+ import { isRef, isTemplateString } from "@jxsuite/schema/guards";
25
+ import type { JxRef, JxStyle } from "@jxsuite/schema/types";
26
+
20
27
  /**
21
28
  * Convert camelCase to kebab-case.
22
29
  *
@@ -60,6 +67,262 @@ export function resolveAtQuery(atKey: string, mediaQueries: Record<string, strin
60
67
  return null;
61
68
  }
62
69
 
70
+ /**
71
+ * At-rules whose body is a list of DECLARATIONS rather than a list of rules.
72
+ *
73
+ * Every other `@`-key in a `style` object wraps selectors — `@media`, `@supports`,
74
+ * `@starting-style` — so both emitters assume a selector child and write `@key { <selector> { … }
75
+ * }`. These four have no selector: `@position-try --flip { inset-block-start: auto }` IS the body,
76
+ * and wrapping it in one produces a block the parser discards without a word.
77
+ *
78
+ * That is why an anchored dropdown had no custom fallback to declare. `position-try-fallbacks` can
79
+ * name `flip-block` and friends without this, but a bespoke fallback position — the thing you need
80
+ * when the built-in flips do not fit — could not be expressed at all.
81
+ *
82
+ * The name is part of the key (`@position-try --flip`), so this is a PREFIX match. `@keyframes` is
83
+ * deliberately absent, and stays absent: its body is neither declarations nor selectors but
84
+ * keyframe stops, which is a third shape with its own predicate ({@link isKeyframesAtRule}) and its
85
+ * own serializer. Adding it here would be the tempting one-line fix and it deletes the animation
86
+ * without a word — every child of a keyframes block is a block, `declarationsOf` skips blocks, and
87
+ * a rule with no declarations is never emitted.
88
+ *
89
+ * @param {string} atKey - An `@`-prefixed style key
90
+ * @returns {boolean} True when the block is emitted verbatim, with no selector inside
91
+ * @docs framework/concepts/styling
92
+ */
93
+ export function isDeclarationAtRule(atKey: string): boolean {
94
+ return (
95
+ atKey.startsWith("@position-try") ||
96
+ atKey.startsWith("@property") ||
97
+ atKey.startsWith("@font-face") ||
98
+ atKey.startsWith("@counter-style")
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The at-rule whose body is a list of KEYFRAME BLOCKS: `@keyframes <name>`.
104
+ *
105
+ * The third body shape, and the only one. Its children are keyframe selectors — `from`, `to`,
106
+ * `50%`, `"0%, 100%"` — which name a point in an animation's timeline and not an element. Scoping
107
+ * one produces `@keyframes toast-in { .x from { … } }`, which a browser parses without complaint
108
+ * into a keyframes rule holding NO keyframes: the `animation` declaration still names a live
109
+ * animation, and that animation animates nothing. That is how the Studio toast lost its entry
110
+ * animation the day it became a Jx document.
111
+ *
112
+ * The name is part of the key, so this is a PREFIX match like {@link isDeclarationAtRule}.
113
+ *
114
+ * @param {string} atKey - An `@`-prefixed style key
115
+ * @returns {boolean}
116
+ * @docs framework/concepts/styling
117
+ */
118
+ export function isKeyframesAtRule(atKey: string): boolean {
119
+ return atKey.startsWith("@keyframes");
120
+ }
121
+
122
+ /**
123
+ * Resolve one nested style key against its parent selector.
124
+ *
125
+ * `&` splices, `:`/`.`/`[` concatenate, anything else is a descendant. Extracted because the same
126
+ * four-branch decision was written THREE times in `applyStyle` — the top-level nested loop,
127
+ * `emitNested`'s recursion and `emitMediaNested` — and three copies of one decision is three
128
+ * chances for a selector to mean different things depending on how deeply it was nested.
129
+ *
130
+ * @param {string} scope - The parent selector
131
+ * @param {string} key - The nested key, e.g. `":hover"`, `"& > li"`, `".child"`
132
+ * @returns {string} The resolved selector
133
+ */
134
+ export function resolveNestedSelector(scope: string, key: string): string {
135
+ const resolved: string[] = [];
136
+ for (const scopePart of splitSelectorList(scope)) {
137
+ for (const keyPart of splitSelectorList(key)) {
138
+ resolved.push(resolveOneNestedSelector(scopePart, keyPart));
139
+ }
140
+ }
141
+ return resolved.join(", ");
142
+ }
143
+
144
+ /** One member of a scope against one member of a key — the four-branch decision itself. */
145
+ function resolveOneNestedSelector(scope: string, key: string): string {
146
+ if (key.startsWith("&")) {
147
+ return key.replaceAll("&", scope);
148
+ }
149
+ if (key.startsWith("[") || key.startsWith(":") || key.startsWith(".")) {
150
+ return `${scope}${key}`;
151
+ }
152
+ return `${scope} ${key}`;
153
+ }
154
+
155
+ /**
156
+ * Split a selector list on its top-level commas.
157
+ *
158
+ * A comma inside `:is()`, `:where()`, `:not()`, an attribute value or a quoted string separates
159
+ * nothing, so depth and quotes are tracked. Members are trimmed and an empty member is dropped; a
160
+ * selector with no top-level comma comes back as itself, so callers never special-case the single
161
+ * form.
162
+ *
163
+ * Why this exists: a nested key was spliced onto its scope as ONE string, so `"& .a, & .b"` with a
164
+ * `":hover"` inside it emitted `#x .a, #x .b:hover` — the first member got the hover rule with no
165
+ * hover — and the second `&` of the key was never replaced at all. CSS Nesting distributes a nested
166
+ * selector over every member of the parent list (its implicit `:is()`); the flattener now does the
167
+ * same, member by member.
168
+ *
169
+ * @param {string} selector - A selector or a selector list
170
+ * @returns {string[]} The members, in order
171
+ */
172
+ export function splitSelectorList(selector: string): string[] {
173
+ const members: string[] = [];
174
+ let depth = 0;
175
+ let quote: string | null = null;
176
+ let start = 0;
177
+ for (let i = 0; i < selector.length; i += 1) {
178
+ const ch = selector[i]!;
179
+ if (quote) {
180
+ if (ch === "\\") {
181
+ i += 1;
182
+ } else if (ch === quote) {
183
+ quote = null;
184
+ }
185
+ continue;
186
+ }
187
+ if (ch === '"' || ch === "'") {
188
+ quote = ch;
189
+ } else if (ch === "(" || ch === "[") {
190
+ depth += 1;
191
+ } else if (ch === ")" || ch === "]") {
192
+ depth = Math.max(0, depth - 1);
193
+ } else if (ch === "," && depth === 0) {
194
+ members.push(selector.slice(start, i));
195
+ start = i + 1;
196
+ }
197
+ }
198
+ members.push(selector.slice(start));
199
+ const trimmed = members.map((member) => member.trim()).filter((member) => member !== "");
200
+ return trimmed.length > 0 ? trimmed : [selector];
201
+ }
202
+
203
+ /**
204
+ * Studio-canvas popover selector transposition — the style half of `setCanvasDelinkPopovers`.
205
+ *
206
+ * The canvas renames `popover` to `data-jx-popover` so an open panel leaves the TOP LAYER and
207
+ * becomes an ordinary positioned element the editor can measure, select and grow the artboard for.
208
+ * The moment it does, `:popover-open` matches nothing — so an authored open state would simply not
209
+ * apply, and the author would be styling a rule they could never see.
210
+ *
211
+ * `[data-jx-popover-open]` is the stand-in, and the substitution is exact in the way that matters:
212
+ * a pseudo-class and an attribute selector are both specificity (0,1,0), so a `:popover-open` block
213
+ * still wins and loses against the same neighbours it does on the shipped page.
214
+ *
215
+ * `null` means "do not emit this rule at all", and `::backdrop` is the one thing it is returned
216
+ * for. There is no backdrop pseudo-element outside the top layer; synthesising one would paint a
217
+ * full-canvas scrim over the document being edited, and emitting an inert rule would mark the
218
+ * selector as "styled" in the Style panel while doing nothing. Preview renders it natively.
219
+ *
220
+ * @param {string} selector - A fully resolved selector, canvas-side
221
+ * @returns {string | null} The selector to emit, or null to emit nothing
222
+ * @docs framework/concepts/overlays
223
+ */
224
+ export function transposeCanvasPopoverSelector(selector: string): string | null {
225
+ return transposeCanvasOverlaySelector(selector);
226
+ }
227
+
228
+ /** What {@link transposeCanvasOverlaySelector} needs to know about the element that owns a rule. */
229
+ export interface CanvasOverlayTransposeOptions {
230
+ /**
231
+ * The rule was authored on a `<dialog>`. It answers `[open]` only for a compound that names no
232
+ * element type at all (`&[open]`, `#d[open]`, `.panel[open]`) — a compound that names one is
233
+ * decided by that type instead, so a `<div>`'s `& dialog[open]` is transposed and a `<dialog>`'s
234
+ * `& details[open]` is not.
235
+ */
236
+ dialog?: boolean;
237
+ }
238
+
239
+ /** A character that ends the compound selector to the left of it. */
240
+ const COMPOUND_BREAK = /[\s>+~,()]/;
241
+
242
+ /** The leading type selector of a compound, if it has one. */
243
+ const LEADING_TYPE = /^[A-Za-z_\u{00A0}-\u{FFFF}][\w\u{00A0}-\u{FFFF}-]*/u;
244
+
245
+ /**
246
+ * Rewrite each `[open]` according to the compound it belongs to, not the element the style object
247
+ * hangs off. A compound naming `dialog` is the dialog's own open state and is transposed; one
248
+ * naming any other type (`details`, a custom element) is left alone; one naming no type — or naming
249
+ * the scope handle, which IS the styled element — inherits `options.dialog`.
250
+ */
251
+ function transposeOpenAttribute(selector: string, options: CanvasOverlayTransposeOptions): string {
252
+ let out = "";
253
+ let from = 0;
254
+ for (;;) {
255
+ const at = selector.indexOf("[open]", from);
256
+ if (at === -1) {
257
+ return out + selector.slice(from);
258
+ }
259
+ let start = at;
260
+ while (start > 0 && !COMPOUND_BREAK.test(selector[start - 1] as string)) {
261
+ start -= 1;
262
+ }
263
+ const type = LEADING_TYPE.exec(selector.slice(start, at))?.[0]?.toLowerCase();
264
+ const dialog = type === undefined ? options.dialog === true : type === "dialog";
265
+ out += selector.slice(from, at) + (dialog ? "[data-jx-dialog-open]" : "[open]");
266
+ from = at + "[open]".length;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Studio-canvas overlay selector transposition, for dialogs as well as popovers — the style half of
272
+ * `setCanvasDelinkPopovers` and `setCanvasDelinkCommands` together.
273
+ *
274
+ * A dialog the canvas shows in place is not `:modal` and, its `open` renamed with the rest of the
275
+ * invoker machinery, not `[open]` either; `[data-jx-dialog-open]` is the stand-in for both, at the
276
+ * same (0,1,0) specificity. `::backdrop` is dropped for the reason given above: neither kind of
277
+ * overlay has one outside the top layer.
278
+ *
279
+ * `[inert]` travels with `commandfor` and a dialog's `open`, and for the same reason: the canvas
280
+ * renames the attribute on every stamped node so the editor can select into an inert region, so a
281
+ * rule the author wrote against `[inert]` would match on the built page and silently not on the
282
+ * canvas. `[data-jx-inert]` is the same (0,1,0) specificity, so it wins and loses against the same
283
+ * neighbours. Unlike `[open]` it needs no option: `inert` is renamed whatever the tag is, where
284
+ * `<details open>` keeps its attribute and so its `[open]` must keep matching.
285
+ *
286
+ * **`[open]` follows the compound it is written on, not the element the style object hangs off.** A
287
+ * compound naming `dialog` is transposed whatever owns the rule, so a wrapper's `& dialog[open]`
288
+ * keeps matching; a compound naming any other type is left alone, so a dialog's `& details[open]`
289
+ * is not corrupted into a selector that can never match. Only a compound with no type of its own —
290
+ * `&[open]`, `#d[open]`, or the runtime's own scope handle — falls back to `options.dialog`. The
291
+ * handle needs no special case: it is not a parseable type name, so the scan finds none and the
292
+ * fallback is what answers for it, which is the right answer because the handle IS the styled
293
+ * element.
294
+ *
295
+ * Three shapes are out of reach of that scan and take the fallback: `:is(dialog)[open]`, a quoted
296
+ * attribute value carrying selector punctuation, and any functional pseudo between the type and the
297
+ * attribute — `dialog:not(.x)[open]` reads as no type, because a parenthesis breaks the backscan.
298
+ *
299
+ * **The rename is per NODE and the selector is not**, so one limitation survives whatever this
300
+ * does. `open` is renamed only on a node the canvas stamped, and a rule may address both stamped
301
+ * and unstamped dialogs at once: a stamped wrapper styling `& dialog[open]` whose dialog comes from
302
+ * a component's own template now emits an attribute that dialog does not carry. Before this, the
303
+ * same rule failed the other way round, on the stamped dialog. Whichever way it is decided one of
304
+ * the two is wrong, so it is decided for the addressable one and stated here rather than papered
305
+ * over.
306
+ *
307
+ * @param {string} selector - A fully resolved selector, canvas-side
308
+ * @param {CanvasOverlayTransposeOptions} [options]
309
+ * @returns {string | null} The selector to emit, or null to emit nothing
310
+ * @docs framework/concepts/overlays
311
+ */
312
+ export function transposeCanvasOverlaySelector(
313
+ selector: string,
314
+ options: CanvasOverlayTransposeOptions = {},
315
+ ): string | null {
316
+ if (selector.includes("::backdrop")) {
317
+ return null;
318
+ }
319
+ const transposed = selector
320
+ .replaceAll(":popover-open", "[data-jx-popover-open]")
321
+ .replaceAll(":modal", "[data-jx-dialog-open]")
322
+ .replaceAll("[inert]", "[data-jx-inert]");
323
+ return transposeOpenAttribute(transposed, options);
324
+ }
325
+
63
326
  // ─── Color Schemes ────────────────────────────────────────────────────────────
64
327
 
65
328
  /**
@@ -117,3 +380,504 @@ export function schemeSelectors(
117
380
  forced: `:where(:root[${COLOR_SCHEME_ATTR}="${scheme}"]) ${selector}`,
118
381
  };
119
382
  }
383
+
384
+ // ─── The Rule Builder ─────────────────────────────────────────────────────────
385
+
386
+ /**
387
+ * Where a rule points, relative to the element the style object was authored on.
388
+ *
389
+ * The runtime needs this to decide what it is allowed to SHARE. A `self` rule can be interned and
390
+ * pointed at by any number of elements, because everything it says applies to whichever element
391
+ * carries the scope handle. A `descendant` rule cannot be shared the moment it reads a custom
392
+ * property, because `var()` resolves from the nearest ancestor that set it and a shared handle has
393
+ * more than one such ancestor. `unscoped` is a declaration-body at-rule — `@font-face`,
394
+ * `@position-try` — or a `@keyframes` block, whose name is document-global and which is therefore
395
+ * hoisted once rather than emitted per element.
396
+ */
397
+ export type CssRuleTarget = "self" | "descendant" | "unscoped";
398
+
399
+ /** One keyframe stop: its keyframe selector, verbatim as authored, and what it declares. */
400
+ export interface CssKeyframeBlock {
401
+ /** `from`, `to`, `50%`, `"0%, 100%"` — a point on the timeline, never an element selector. */
402
+ selector: string;
403
+ /** Kebab-cased property/value pairs, in authored order. */
404
+ declarations: readonly (readonly [string, string])[];
405
+ }
406
+
407
+ /** One emitted CSS rule: its parts, its text, and a content hash of the two. */
408
+ export interface CssRule {
409
+ /** The rule as one `insertRule` argument, or one line of a `<style>` element. */
410
+ text: string;
411
+ /** At-rule wrappers, outermost first. Empty for a top-level rule. */
412
+ conditions: readonly string[];
413
+ /**
414
+ * The resolved selector, or null for a rule that has none: a declaration-body at-rule, or a
415
+ * `@keyframes` block (whose stops are in {@link CssRule.blocks} instead).
416
+ */
417
+ selector: string | null;
418
+ /** Kebab-cased property/value pairs, in authored order. Empty for a `@keyframes` block. */
419
+ declarations: readonly (readonly [string, string])[];
420
+ /**
421
+ * The stops of a `@keyframes` block, in authored order. Absent for every other rule.
422
+ *
423
+ * `declarations` cannot describe N stops each with its own list, so it stays empty here and this
424
+ * carries the body. Nothing in the repository reads either field — `text`, `key` and `target` are
425
+ * the whole cross-consumer contract — but a rule that plainly has declarations must not report
426
+ * none without saying where they went.
427
+ */
428
+ blocks?: readonly CssKeyframeBlock[];
429
+ /**
430
+ * The block's `$description`, if it wrote one: why the rule exists, in the author's words.
431
+ *
432
+ * It is NOT in {@link CssRule.text}, and that is the point. `text` is what a sheet inserts and
433
+ * what {@link hashCss} interns, so prose there would make two identical rules two rules and put a
434
+ * paragraph in every adopted sheet at runtime. A static emitter — the one writing a stylesheet a
435
+ * person will read — renders it as a comment; every other consumer ignores it, exactly as a
436
+ * document's own `$description` is ignored.
437
+ */
438
+ description?: string;
439
+ /** See {@link CssRuleTarget}. */
440
+ target: CssRuleTarget;
441
+ /** FNV-1a base36 hash of `text` — the dedup key. */
442
+ key: string;
443
+ }
444
+
445
+ /** Hooks that let one builder serve the DOM runtime, the compiler, the site builder and Studio. */
446
+ export interface CssBuildOptions {
447
+ /**
448
+ * The selector the style object hangs off. Every nested key resolves against it, so the runtime
449
+ * passes a placeholder it substitutes afterwards and the compiler passes `#id` / `.jx-N` / a
450
+ * tag.
451
+ */
452
+ scope?: string | null;
453
+ /** The project's `$media` map, for resolving `@--name` keys. */
454
+ mediaQueries?: Record<string, string>;
455
+ /** Applied to every declaration value. The canvas passes `canvasStyleValue`; hosts pass identity. */
456
+ transposeValue?: (value: string) => string;
457
+ /**
458
+ * Applied to every resolved selector. Returning null drops the rule entirely, which is how the
459
+ * canvas refuses to synthesise a `::backdrop` outside the top layer.
460
+ */
461
+ transposeSelector?: (selector: string) => string | null;
462
+ /**
463
+ * Resolve a value the builder cannot serialize on its own: a `${…}` template or a `{ $ref }`.
464
+ *
465
+ * The runtime returns `var(--jx-rN)` and writes the real value into that custom property from an
466
+ * effect, so the declaration lives in a rule where `:hover` can override it while the reactive
467
+ * part stays inline. Hosts with no scope to evaluate against omit the hook, and the declaration
468
+ * is dropped — which is what the compiler already did for template strings, and what the runtime
469
+ * should always have done with a `$ref` it was instead reading as a nested selector.
470
+ */
471
+ /**
472
+ * Resolve a reactive value to what the declaration should say, or `null` to emit no declaration
473
+ * at all. `target` is the rule's own target, so a resolver can treat a value on the element
474
+ * itself differently from one on a descendant.
475
+ */
476
+ resolveValue?: (property: string, value: string | JxRef, target: CssRuleTarget) => string | null;
477
+ }
478
+
479
+ /** A style-object key that names a nested selector rather than a CSS property. */
480
+ export function isNestedSelectorKey(key: string): boolean {
481
+ return key.startsWith(":") || key.startsWith(".") || key.startsWith("&") || key.startsWith("[");
482
+ }
483
+
484
+ /**
485
+ * A style-object key as a CSS property name.
486
+ *
487
+ * Custom properties pass through untouched. They are case-SENSITIVE — `--fooBar` and `--foo-bar`
488
+ * are two different properties — so kebab-casing one renames it, and the `var(--fooBar)` that reads
489
+ * it finds nothing.
490
+ *
491
+ * @param {string} property
492
+ * @returns {string}
493
+ */
494
+ export function cssPropertyName(property: string): string {
495
+ return property.startsWith("--") ? property : camelToKebab(property);
496
+ }
497
+
498
+ /**
499
+ * FNV-1a (32-bit), base36. Not a cryptographic hash and does not need to be: it is a dedup key and
500
+ * an SSR-stable replacement for `Math.random()`, and a collision costs two elements sharing a rule
501
+ * set that says the same thing.
502
+ *
503
+ * @param {string} text
504
+ * @returns {string}
505
+ */
506
+ export function hashCss(text: string): string {
507
+ /* oxlint-disable no-bitwise, unicorn/prefer-code-point -- FNV-1a IS bitwise arithmetic over
508
+ 16-bit code UNITS; `codePointAt` would consume a surrogate pair as one unit and change the
509
+ hash of any string containing an astral character, for no benefit to a non-cryptographic
510
+ content key. The final `>>> 0` is what keeps the result unsigned, and so base36-clean. */
511
+ let h = 2_166_136_261;
512
+ for (let i = 0; i < text.length; i += 1) {
513
+ h ^= text.charCodeAt(i);
514
+ h = Math.imul(h, 16_777_619);
515
+ }
516
+ return (h >>> 0).toString(36);
517
+ /* oxlint-enable no-bitwise, unicorn/prefer-code-point */
518
+ }
519
+
520
+ /**
521
+ * Serialize one rule: declarations, wrapped in a selector unless there is none, wrapped in its
522
+ * at-rule conditions from the inside out.
523
+ *
524
+ * @param {readonly string[]} conditions
525
+ * @param {string | null} selector
526
+ * @param {readonly (readonly [string, string])[]} declarations
527
+ * @returns {string}
528
+ */
529
+ export function cssRuleText(
530
+ conditions: readonly string[],
531
+ selector: string | null,
532
+ declarations: readonly (readonly [string, string])[],
533
+ ): string {
534
+ const body = declarations.map(([property, value]) => `${property}: ${value}`).join("; ");
535
+ let text = selector === null ? body : `${selector} { ${body} }`;
536
+ for (let i = conditions.length - 1; i >= 0; i -= 1) {
537
+ text = `${conditions[i]} { ${text} }`;
538
+ }
539
+ return text;
540
+ }
541
+
542
+ /**
543
+ * Serialize one `@keyframes` block: every stop, in authored order, inside ONE at-rule, wrapped in
544
+ * its enclosing conditions.
545
+ *
546
+ * One rule, not one per stop, and that is a correctness requirement rather than tidiness. CSS
547
+ * Animations 1: where two `@keyframes` rules share a name, the last in document order wins and the
548
+ * earlier ones are ignored ENTIRELY. So a block split into `@keyframes fade { from { … } }` plus
549
+ * `@keyframes fade { to { … } }` is valid CSS that animates only its last stop, which is the shape
550
+ * the site-style and compiler paths emitted before this existed.
551
+ *
552
+ * @param {readonly string[]} conditions
553
+ * @param {string} name - The animation name, as authored
554
+ * @param {readonly CssKeyframeBlock[]} blocks
555
+ * @returns {string}
556
+ */
557
+ function cssKeyframesText(
558
+ conditions: readonly string[],
559
+ name: string,
560
+ blocks: readonly CssKeyframeBlock[],
561
+ ): string {
562
+ const body = blocks
563
+ .map(
564
+ ({ selector, declarations }) =>
565
+ `${selector} { ${declarations.map(([property, value]) => `${property}: ${value}`).join("; ")} }`,
566
+ )
567
+ .join(" ");
568
+ let text = `@keyframes ${name} { ${body} }`;
569
+ for (let i = conditions.length - 1; i >= 0; i -= 1) {
570
+ text = `${conditions[i]} { ${text} }`;
571
+ }
572
+ return text;
573
+ }
574
+
575
+ /**
576
+ * A block with its `@keyframes` children removed, for the forced-scheme twin of a dual emission.
577
+ *
578
+ * A scheme-pure query emits twice, once media-guarded and once under the root attribute, because a
579
+ * SELECTOR can be re-pointed at the forced state. A `@keyframes` name cannot: it is document-global
580
+ * and has no selector, so a second copy would be a second definition of one name, and the
581
+ * unconditional copy would silently replace the media-guarded one for every visitor. The
582
+ * media-guarded copy is the one that keeps the author's condition, so it is the one that survives.
583
+ */
584
+ function withoutKeyframes(block: JxStyle): JxStyle {
585
+ const keyframeKeys = Object.keys(block).filter((key) => isKeyframesAtRule(key));
586
+ if (keyframeKeys.length === 0) {
587
+ return block;
588
+ }
589
+ const rest: JxStyle = { ...block };
590
+ for (const key of keyframeKeys) {
591
+ delete rest[key];
592
+ }
593
+ return rest;
594
+ }
595
+
596
+ /**
597
+ * Whether a nested key COMPOUNDS onto its scope rather than descending from it.
598
+ *
599
+ * `:hover`, `.wide`, `[open]` and their `&`-spliced spellings still match the element the style was
600
+ * authored on; `> li` and `span` match something inside it. That distinction is the whole of
601
+ * {@link CssRuleTarget}, so it is decided once, here.
602
+ */
603
+ function compoundsOntoScope(key: string): boolean {
604
+ const rest = key.startsWith("&") ? key.slice(1) : key;
605
+ return rest === "" || rest.startsWith(":") || rest.startsWith(".") || rest.startsWith("[");
606
+ }
607
+
608
+ /**
609
+ * Turn one authored `style` object into a flat list of CSS rules.
610
+ *
611
+ * This is the single answer to "what does a Jx style object MEAN as CSS", shared by the DOM
612
+ * runtime, the compiler's static emitter and the site-style builder. Before it, three emitters each
613
+ * walked the shape with their own recursion and each dropped a different combination: the runtime
614
+ * lost `selector → @media`, the compiler lost `@media → selector → pseudo`, and neither composed
615
+ * at-rules at all.
616
+ *
617
+ * The recursion carries `{ selector, conditions }` rather than special-casing depth, so nesting and
618
+ * at-rules compose to any depth in either order by construction. A scheme-pure media query
619
+ * dual-emits through {@link schemeSelectors} — a media-guarded copy for the OS preference and an
620
+ * unconditional copy for the forced root attribute — and the forced copy keeps recursing, so an
621
+ * `@--md` block inside a `@(prefers-color-scheme: dark)` block survives both branches.
622
+ *
623
+ * Nesting is FLATTENED, never handed to the browser as native CSS nesting: `&` is resolved here and
624
+ * no `&` reaches the output. That is deliberate — Jx compounds `.child` onto its scope where CSS
625
+ * Nesting would make it a descendant — and it is also what keeps the output parseable by the test
626
+ * DOM, which has no native nesting.
627
+ *
628
+ * @param {JxStyle} style - The authored style object
629
+ * @param {CssBuildOptions} [options]
630
+ * @returns {CssRule[]} Rules in cascade order: a scope's own declarations before its nested blocks
631
+ * @docs framework/concepts/styling
632
+ */
633
+ export function buildStyleRules(style: JxStyle, options: CssBuildOptions = {}): CssRule[] {
634
+ const {
635
+ scope = null,
636
+ mediaQueries = {},
637
+ transposeValue = (value: string) => value,
638
+ transposeSelector = (selector: string) => selector,
639
+ resolveValue,
640
+ } = options;
641
+ const rules: CssRule[] = [];
642
+
643
+ const isBlock = (value: unknown): value is JxStyle =>
644
+ value !== null && typeof value === "object" && !Array.isArray(value) && !isRef(value);
645
+
646
+ /**
647
+ * The blocks written under one key: the block itself, or each of an ARRAY of them, in order.
648
+ *
649
+ * An object's keys are unique, so a key can name a rule only once — and `@font-face` is the
650
+ * at-rule whose identity is not in its key, so a family with three weights had no spelling at
651
+ * all. It was not refused either: an array here emitted NOTHING, silently, so a style block that
652
+ * looked complete shipped no faces and the page fell to its fallback stack.
653
+ *
654
+ * **Only a DECLARATION at-rule takes the array**, and the narrowness is the point rather than
655
+ * caution. Those four are leaves — `walkAt` emits their declarations and recurses into nothing —
656
+ * so admitting a list there teaches no other walker anything new. Under a selector key an array
657
+ * would say what one block already says, while every style walker in the repo (the overlay lint,
658
+ * the a11y lint, the canvas) assumes a block key holds ONE block; allowing it there would leave
659
+ * those reading past it in silence, which is the failure this codebase keeps finding.
660
+ */
661
+ const blocksOf = (key: string, value: unknown): JxStyle[] => {
662
+ if (isBlock(value)) {
663
+ return [value];
664
+ }
665
+ if (!isDeclarationAtRule(key) || !Array.isArray(value) || value.length === 0) {
666
+ return [];
667
+ }
668
+ return value.every((entry) => isBlock(entry)) ? value : [];
669
+ };
670
+
671
+ const declarationValue = (
672
+ property: string,
673
+ value: unknown,
674
+ reactive: boolean,
675
+ target: CssRuleTarget,
676
+ ): string | null => {
677
+ if (value === undefined || value === null) {
678
+ return null;
679
+ }
680
+ /* Defect: a `{ $ref }` is a VALUE. Read as an object it looked like a nested selector, and the
681
+ runtime emitted `[data-jx="…"] color { $ref: #/state/tint }` — a rule for an element named
682
+ `color`, and a declaration whose property is `$ref`. */
683
+ if (isRef(value)) {
684
+ return reactive ? (resolveValue?.(property, value, target) ?? null) : null;
685
+ }
686
+ if (typeof value === "object") {
687
+ return null;
688
+ }
689
+ const raw = String(value);
690
+ if (isTemplateString(raw)) {
691
+ return reactive ? (resolveValue?.(property, raw, target) ?? null) : null;
692
+ }
693
+ return transposeValue(raw);
694
+ };
695
+
696
+ /**
697
+ * `reactive: false` drops a `${…}` or `{ $ref }` instead of resolving it, and only a `@keyframes`
698
+ * stop passes it. The resolver's answer is `var(--jx-rN-M)`, a custom property the runtime writes
699
+ * INLINE on the one element that declared the style — but a keyframes block is hoisted once for
700
+ * the whole document, so the variable would be read where it was never set. Worse, the hoist is
701
+ * refcounted by rule TEXT: two elements naming one animation would produce two different texts,
702
+ * hence two definitions of one name, and the later would erase the earlier.
703
+ */
704
+ const declarationsOf = (
705
+ node: JxStyle,
706
+ skipSelectorKeys: boolean,
707
+ reactive = true,
708
+ target: CssRuleTarget = "self",
709
+ ): [string, string][] => {
710
+ const declarations: [string, string][] = [];
711
+ for (const [key, value] of Object.entries(node)) {
712
+ /* `blocksOf`, not `isBlock`: an array of blocks is a rule written more than once, never a
713
+ declaration value, and reading it as one emitted `@font-face: [object Object]`. */
714
+ if (blocksOf(key, value).length > 0) {
715
+ continue;
716
+ }
717
+ // A scalar under a selector or at-rule key is an invalid shape, not a declaration.
718
+ if (skipSelectorKeys && (key.startsWith("@") || isNestedSelectorKey(key))) {
719
+ continue;
720
+ }
721
+ /* `$`-prefixed keys are METADATA, the way they are everywhere else in a document. No CSS
722
+ property begins with `$` — a custom property begins with `--` — so this can refuse the
723
+ whole prefix rather than name one key. `$description` used to emit
724
+ `$description: why this exists;`: an invalid declaration the parser drops in silence, which
725
+ also put the prose inside the text `hashCss` interns. */
726
+ if (key.startsWith("$")) {
727
+ continue;
728
+ }
729
+ const resolved = declarationValue(key, value, reactive, target);
730
+ if (resolved !== null) {
731
+ declarations.push([cssPropertyName(key), resolved]);
732
+ }
733
+ }
734
+ return declarations;
735
+ };
736
+
737
+ const emit = (
738
+ conditions: readonly string[],
739
+ selector: string | null,
740
+ declarations: readonly (readonly [string, string])[],
741
+ target: CssRuleTarget,
742
+ description?: unknown,
743
+ ) => {
744
+ if (declarations.length === 0) {
745
+ return;
746
+ }
747
+ let resolved = selector;
748
+ if (resolved !== null) {
749
+ const transposed = transposeSelector(resolved);
750
+ if (transposed === null) {
751
+ return;
752
+ }
753
+ resolved = transposed;
754
+ }
755
+ const text = cssRuleText(conditions, resolved, declarations);
756
+ rules.push({
757
+ text,
758
+ conditions: [...conditions],
759
+ selector: resolved,
760
+ declarations,
761
+ target,
762
+ key: hashCss(text),
763
+ ...(typeof description === "string" && description !== "" ? { description } : {}),
764
+ });
765
+ };
766
+
767
+ /**
768
+ * Emit a whole `@keyframes` block as ONE unscoped rule.
769
+ *
770
+ * The enclosing `selector` is not a parameter, which is the fix stated as a signature: a keyframe
771
+ * selector is a point on a timeline, so there is nothing for a scope to compound onto or descend
772
+ * from. Its key is therefore taken VERBATIM — no `resolveNestedSelector`, no selector-list split
773
+ * (`"0%, 100%"` is one valid keyframe selector already), and no `transposeSelector`, whose canvas
774
+ * implementation may return null and would delete a stop out of an otherwise sound animation.
775
+ * `transposeValue` still runs on every declaration, so a `translateY(10vh)` stop keeps the
776
+ * canvas's viewport-to-container rewrite.
777
+ *
778
+ * It pushes onto `rules` rather than going through `emit`, which serializes a declaration list
779
+ * and refuses an empty one.
780
+ */
781
+ const emitKeyframes = (atKey: string, block: JxStyle, conditions: readonly string[]) => {
782
+ const name = atKey.slice("@keyframes".length).trim();
783
+ if (name === "") {
784
+ return;
785
+ }
786
+ const blocks: CssKeyframeBlock[] = [];
787
+ for (const [key, value] of Object.entries(block)) {
788
+ if (!isBlock(value)) {
789
+ continue;
790
+ }
791
+ /* A stop holds declarations and nothing else. A nested selector or at-rule inside one is not
792
+ valid CSS, and `declarationsOf` drops both shapes rather than emitting a block a parser
793
+ would throw the whole animation away over. */
794
+ const declarations = declarationsOf(value, true, false, "unscoped");
795
+ if (declarations.length > 0) {
796
+ blocks.push({ declarations, selector: key.trim() });
797
+ }
798
+ }
799
+ if (blocks.length === 0) {
800
+ return;
801
+ }
802
+ const text = cssKeyframesText(conditions, name, blocks);
803
+ rules.push({
804
+ blocks,
805
+ conditions: [...conditions],
806
+ declarations: [],
807
+ key: hashCss(text),
808
+ selector: null,
809
+ target: "unscoped",
810
+ text,
811
+ });
812
+ };
813
+
814
+ const walkAt = (
815
+ atKey: string,
816
+ block: JxStyle,
817
+ selector: string | null,
818
+ conditions: readonly string[],
819
+ target: CssRuleTarget,
820
+ ) => {
821
+ /* Not a query. `@--` is the canvas's base-width block — the value a breakpoint panel renders
822
+ at when no named breakpoint applies — and resolving it would emit `@media --`. */
823
+ if (atKey === "@--") {
824
+ return;
825
+ }
826
+ if (isDeclarationAtRule(atKey)) {
827
+ emit(
828
+ [...conditions, atKey],
829
+ null,
830
+ declarationsOf(block, false, true, "unscoped"),
831
+ "unscoped",
832
+ block["$description"],
833
+ );
834
+ return;
835
+ }
836
+ if (isKeyframesAtRule(atKey)) {
837
+ emitKeyframes(atKey, block, conditions);
838
+ return;
839
+ }
840
+ const query = resolveAtQuery(atKey, mediaQueries);
841
+ const atRule = query === null ? atKey : `@media ${query}`;
842
+ const scheme = query === null ? null : pureSchemeOf(query);
843
+ if (scheme !== null && selector !== null) {
844
+ const { auto, forced } = schemeSelectors(selector, scheme);
845
+ walk(block, auto, [...conditions, atRule], target);
846
+ walk(withoutKeyframes(block), forced, conditions, target);
847
+ return;
848
+ }
849
+ walk(block, selector, [...conditions, atRule], target);
850
+ };
851
+
852
+ function walk(
853
+ node: JxStyle,
854
+ selector: string | null,
855
+ conditions: readonly string[],
856
+ target: CssRuleTarget,
857
+ ) {
858
+ emit(
859
+ conditions,
860
+ selector,
861
+ declarationsOf(node, true, true, target),
862
+ target,
863
+ node["$description"],
864
+ );
865
+ for (const [key, value] of Object.entries(node)) {
866
+ for (const block of blocksOf(key, value)) {
867
+ if (key.startsWith("@")) {
868
+ walkAt(key, block, selector, conditions, target);
869
+ } else if (selector !== null) {
870
+ walk(
871
+ block,
872
+ resolveNestedSelector(selector, key),
873
+ conditions,
874
+ compoundsOntoScope(key) ? target : "descendant",
875
+ );
876
+ }
877
+ }
878
+ }
879
+ }
880
+
881
+ walk(style, scope, [], "self");
882
+ return rules;
883
+ }