@jxsuite/compiler 1.5.0 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "1.5.0",
3
+ "version": "2.0.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,17 +45,19 @@
45
45
  "upgrade": "bunx npm-check-updates -u && bun install"
46
46
  },
47
47
  "dependencies": {
48
- "@apidevtools/json-schema-ref-parser": "^15.5.0",
49
- "@jxsuite/create": "^1.2.2",
50
- "@jxsuite/runtime": "^1.3.2",
51
- "@jxsuite/schema": "^1.5.0",
52
- "esbuild": "^0.28.1",
48
+ "@apidevtools/json-schema-ref-parser": "^16.0.0",
49
+ "@jxsuite/create": "^1.3.0",
50
+ "@jxsuite/runtime": "^2.0.0",
51
+ "@jxsuite/schema": "^1.6.0",
52
+ "@vue/reactivity": "3.5.41",
53
+ "esbuild": "^0.28.2",
54
+ "lit-html": "^3.3.3",
53
55
  "sharp": "^0.35.3"
54
56
  },
55
57
  "devDependencies": {
56
- "@happy-dom/global-registrator": "^20.11.1",
57
- "@jxsuite/connector": "^0.4.2",
58
- "@jxsuite/parser": "^1.4.1",
58
+ "@happy-dom/global-registrator": "^20.11.2",
59
+ "@jxsuite/connector": "^0.5.0",
60
+ "@jxsuite/parser": "^1.5.0",
59
61
  "@types/mdast": "^4.0.4"
60
62
  }
61
63
  }
package/src/compiler.ts CHANGED
@@ -130,7 +130,7 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
130
130
  path: `${tagName}.js`,
131
131
  tagName,
132
132
  };
133
- const styleBlock = compileStyles(raw, raw.$media ?? {});
133
+ const styleBlock = compileStyles(raw, raw.$media ?? {}, projectStyle);
134
134
 
135
135
  const html = `<!DOCTYPE html>
136
136
  <html lang="en">
package/src/shadow.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shadow DOM, opt-in.
3
+ *
4
+ * Light DOM is the default and stays the default. It is not a placeholder for shadow DOM: a Jx
5
+ * component's styles are reachable from the page, which is how a project restyles a component it
6
+ * did not write, and the `<slot>` emulation that makes light-DOM composition work has different
7
+ * distribution semantics from real slotting. Switching a component to a shadow root changes both,
8
+ * so it is a per-component decision an author makes deliberately.
9
+ *
10
+ * `$shadow` on a component document, `defaults.shadow` on the project, `false` if neither says
11
+ * otherwise. `"open"` and `"closed"` are the standard's two modes; the difference matters more here
12
+ * than usual, because a **closed** declarative shadow root is not reachable through
13
+ * `element.shadowRoot` and hydration has to find it another way (§16.6).
14
+ *
15
+ * @docs framework/concepts/components
16
+ */
17
+
18
+ import type { JxDocument, JxElement, ProjectConfig } from "@jxsuite/schema/types";
19
+
20
+ /** The two modes the standard defines, or null for light DOM. */
21
+ export type ShadowMode = "open" | "closed";
22
+
23
+ /** A value `$shadow` or `defaults.shadow` may carry. */
24
+ export type ShadowSetting = ShadowMode | false;
25
+
26
+ function asSetting(value: unknown): ShadowSetting | undefined {
27
+ if (value === false || value === "open" || value === "closed") {
28
+ return value;
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ /**
34
+ * The shadow mode a component renders in, or null for light DOM.
35
+ *
36
+ * A component's own `$shadow` wins over the project default in both directions — including
37
+ * `$shadow: false`, which is how one component opts _out_ of a project that opted in.
38
+ *
39
+ * @param {JxDocument | JxElement | null | undefined} doc - The component definition
40
+ * @param {ProjectConfig["defaults"] | undefined} defaults
41
+ * @returns {ShadowMode | null}
42
+ */
43
+ export function resolveShadowMode(
44
+ doc: JxDocument | JxElement | null | undefined,
45
+ defaults?: ProjectConfig["defaults"],
46
+ ): ShadowMode | null {
47
+ const own = asSetting((doc as { $shadow?: unknown } | null | undefined)?.$shadow);
48
+ const setting = own ?? asSetting(defaults?.shadow) ?? false;
49
+ return setting === false ? null : setting;
50
+ }
51
+
52
+ /**
53
+ * The CSS scope prefix for a component's own rules.
54
+ *
55
+ * In light DOM the prefix is the tag name, which is what makes `sty-card .inner` reach only that
56
+ * component's descendants. Inside a shadow root the selector cannot see the host's tag name, and
57
+ * `:host` is the standard's way to address it.
58
+ *
59
+ * @param {string} tagName
60
+ * @param {ShadowMode | null} shadow
61
+ * @returns {string}
62
+ */
63
+ export function styleScopePrefix(tagName: string, shadow: ShadowMode | null): string {
64
+ return shadow === null ? tagName : ":host";
65
+ }
package/src/shared.ts CHANGED
@@ -18,21 +18,26 @@ import {
18
18
  import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
19
19
  import { runStatements } from "@jxsuite/runtime/statements";
20
20
  import {
21
+ bodyReturnsValue,
21
22
  childrenContainArray,
22
23
  hasStructuredBody,
23
24
  isExpressionDef,
24
25
  isFunctionDef,
25
26
  isMappedArray,
26
- bodyReturnsValue,
27
27
  isNamedFormulaDef,
28
28
  isPrototypeDef,
29
29
  isRef,
30
30
  isSchemaOnlyDef as isSchemaOnly,
31
31
  isServerFnDef,
32
+ isTagExpression,
32
33
  isTemplateString,
33
34
  paramNames,
35
+ tagNameCandidates,
34
36
  } from "@jxsuite/schema/guards";
37
+ import { styleScopePrefix } from "./shadow.ts";
38
+ import type { ShadowMode } from "./shadow.ts";
35
39
  import type { ExpressionNode } from "@jxsuite/runtime/expression";
40
+ import { readPath } from "@jxsuite/runtime/pointer";
36
41
  import type {
37
42
  JsonValue,
38
43
  JxElement,
@@ -54,7 +59,13 @@ export {
54
59
  schemeSelectors,
55
60
  toCSSText,
56
61
  } from "@jxsuite/runtime";
57
- export { compileExpression, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
62
+ export {
63
+ compileExpression,
64
+ compileOperandSource,
65
+ evaluateExpression,
66
+ evaluateOperand,
67
+ isMutating,
68
+ } from "@jxsuite/runtime/expression";
58
69
  export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
59
70
 
60
71
  /**
@@ -416,6 +427,62 @@ export function createCompileContext(
416
427
  return { media, scope, scopeDefs };
417
428
  }
418
429
 
430
+ /**
431
+ * `state.x = …`, `state.x += …`, `state.x++` — every form a string handler body can use to write a
432
+ * state entry. `===`/`!==`/`<=`/`>=` are excluded by the `(?!=)` guard and by the operator set.
433
+ */
434
+ const STATE_ASSIGNMENT_RE =
435
+ /\bstate\.([A-Za-z_$][\w$]*)\s*(?:\+\+|--|(?:\*\*|\|\||&&|\?\?|<<|>>>|>>|[-+*/%&|^])?=(?!=))/g;
436
+
437
+ /** `state.list.push(…)` and friends mutate in place without ever assigning to the entry. */
438
+ const STATE_MUTATING_METHOD_RE =
439
+ /\bstate\.([A-Za-z_$][\w$]*)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin)\s*\(/g;
440
+
441
+ /**
442
+ * Collect every state key a handler writes to, across all the forms a body can take: a string body,
443
+ * a structured body (spec §20), and an `$expression` node with a mutating operator.
444
+ *
445
+ * Used to keep prerender from baking a binding over an entry that changes after hydration. A plain
446
+ * `{ "type": "string" }` entry holds a perfectly ordinary build-time value, so nothing else
447
+ * distinguishes it from a constant — but if a handler assigns to it, interpolating it at build time
448
+ * replaces the template in the emitted output and the element is dead for the life of the page.
449
+ *
450
+ * @param {unknown} node
451
+ * @param {Set<string>} into
452
+ */
453
+ function collectAssignedStateKeys(node: unknown, into: Set<string>) {
454
+ if (typeof node === "string") {
455
+ for (const m of node.matchAll(STATE_ASSIGNMENT_RE)) {
456
+ into.add(m[1] as string);
457
+ }
458
+ for (const m of node.matchAll(STATE_MUTATING_METHOD_RE)) {
459
+ into.add(m[1] as string);
460
+ }
461
+ return;
462
+ }
463
+ if (!node || typeof node !== "object") {
464
+ return;
465
+ }
466
+ if (Array.isArray(node)) {
467
+ for (const entry of node) {
468
+ collectAssignedStateKeys(entry, into);
469
+ }
470
+ return;
471
+ }
472
+ const rec = node as Record<string, unknown>;
473
+ // A mutating expression names its destination as `target: { $ref: "#/state/x" }`.
474
+ const op = rec.operator;
475
+ if (typeof op === "string" && isMutating(op)) {
476
+ const target = rec.target as { $ref?: string } | undefined;
477
+ if (target && typeof target.$ref === "string" && target.$ref.startsWith("#/state/")) {
478
+ into.add(target.$ref.slice("#/state/".length).split("/")[0] as string);
479
+ }
480
+ }
481
+ for (const value of Object.values(rec)) {
482
+ collectAssignedStateKeys(value, into);
483
+ }
484
+ }
485
+
419
486
  /**
420
487
  * @param {Record<string, JxStateDefinition>} [defs]
421
488
  * @param {Record<string, unknown> | null} [parentScope]
@@ -439,6 +506,26 @@ export function buildInitialScope(
439
506
  writable: true,
440
507
  });
441
508
 
509
+ // Pass 0: every entry some handler in this document writes to is mutable, so prerender must not
510
+ // Bake a binding over it. Narrow on purpose — an entry nothing ever writes stays bakeable, so
511
+ // Prerendered content is preserved for SEO. A `$src` handler's assignments live in a JS file this
512
+ // Builder cannot open, so a write that only happens there is still missed (issue #125).
513
+ const assigned = new Set<string>();
514
+ for (const def of Object.values(defs)) {
515
+ if (def && typeof def === "object" && (isFunctionDef(def) || isExpressionDef(def))) {
516
+ collectAssignedStateKeys(
517
+ (def as { body?: unknown; $expression?: unknown }).body ??
518
+ (def as { $expression?: unknown }).$expression,
519
+ assigned,
520
+ );
521
+ }
522
+ }
523
+ for (const key of assigned) {
524
+ if (key in defs) {
525
+ runtimeOnly.add(key);
526
+ }
527
+ }
528
+
442
529
  for (const [key, def] of Object.entries(defs)) {
443
530
  if (typeof def !== "object" || def === null || Array.isArray(def)) {
444
531
  setOwnScopeValue(scope, key, cloneValue(def));
@@ -457,6 +544,10 @@ export function buildInitialScope(
457
544
  // Template-string entries are deferred to a third pass: whether one is resolvable depends on the
458
545
  // Runtime-only marks the loop below adds, and key order must not decide the answer.
459
546
  const templateDefs: [string, string][] = [];
547
+ // Computed (`bodyReturnsValue`) entries are deferred for the same reason. A computed is stored in
548
+ // The scope as its already-evaluated *result*, so a template reading it sees an ordinary string
549
+ // And prerender bakes it — destroying the binding rather than merely staling it (issue #124).
550
+ const computedBodies: [string, string][] = [];
460
551
 
461
552
  for (const [key, def] of Object.entries(defs)) {
462
553
  if (typeof def === "string" && isTemplateString(def)) {
@@ -515,6 +606,7 @@ export function buildInitialScope(
515
606
  fn(...params.map((name) => (name === "state" ? state : event)));
516
607
  if (bodyReturnsValue(body)) {
517
608
  defineLazyScopeValue(scope, key, () => invoke(scope));
609
+ computedBodies.push([key, body]);
518
610
  } else {
519
611
  setOwnScopeValue(scope, key, invoke);
520
612
  }
@@ -539,13 +631,23 @@ export function buildInitialScope(
539
631
  }
540
632
  }
541
633
 
542
- for (const [key, template] of templateDefs) {
543
- // A state entry that interpolates a runtime-only key cannot resolve until hydration either, so
544
- // It is runtime-only in turn. Without this, prerender would bake the failed evaluation (`null`)
545
- // Into every template that reads *this* entry, one step removed from the original.
546
- if (readsRuntimeOnlyState(template, scope)) {
547
- runtimeOnly.add(key);
634
+ // A state entry that reads a runtime-only key cannot resolve until hydration either, so it is
635
+ // Runtime-only in turn. Without this, prerender would bake the failed evaluation (`null`) into
636
+ // Every template that reads *this* entry, one step removed from the original. Iterated to a
637
+ // Fixpoint because marking one entry can qualify another, in either direction, and declaration
638
+ // Order must not decide the answer.
639
+ let marked = true;
640
+ while (marked) {
641
+ marked = false;
642
+ for (const [key, source] of [...computedBodies, ...templateDefs]) {
643
+ if (!runtimeOnly.has(key) && readsRuntimeOnlyState(source, scope)) {
644
+ runtimeOnly.add(key);
645
+ marked = true;
646
+ }
548
647
  }
648
+ }
649
+
650
+ for (const [key, template] of templateDefs) {
549
651
  defineLazyScopeValue(scope, key, () => evaluateStaticTemplate(template, scope));
550
652
  }
551
653
 
@@ -622,14 +724,15 @@ export function resolveRefValue(refValue: unknown, scope: Record<string, unknown
622
724
  return parts.length > 2 ? getPathValue(base, parts.slice(2).join("/")) : base;
623
725
  }
624
726
  if (refValue.startsWith("#/state/")) {
625
- const sub = refValue.slice("#/state/".length);
626
- const slash = sub.indexOf("/");
627
- if (slash === -1) {
628
- return scope[sub];
629
- }
630
- return getPathValue(scope[sub.slice(0, slash)], sub.slice(slash + 1));
631
- }
632
- return scope[refValue] ?? null;
727
+ // One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
728
+ // `#/state/a~1b/c` looked for a member called `a~1b` rather than `a/b`.
729
+ return getPathValue(scope, refValue.slice("#/state/".length));
730
+ }
731
+ /*
732
+ * An unrecognized scheme is still a path, matching the runtime resolvers and the lowerer.
733
+ * Reading it as one key returned null for `a/b` while the emitted client module walked it.
734
+ */
735
+ return getPathValue(scope, refValue) ?? null;
633
736
  }
634
737
 
635
738
  /**
@@ -661,7 +764,15 @@ function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
661
764
  if (runtimeOnly instanceof Set && runtimeOnly.has(key)) {
662
765
  return true;
663
766
  }
664
- if (typeof scope[key] === "function") {
767
+ // Reading the key can run a computed's getter, which is free to throw on partial build-time
768
+ // Data. A throw says nothing about whether the entry is runtime-only, so it is not a mark.
769
+ let value: unknown;
770
+ try {
771
+ value = scope[key];
772
+ } catch {
773
+ continue;
774
+ }
775
+ if (typeof value === "function") {
665
776
  return true;
666
777
  }
667
778
  }
@@ -677,20 +788,21 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
677
788
  if (readsRuntimeOnlyState(str, scope)) {
678
789
  return null;
679
790
  }
791
+ /*
792
+ * `$site` and `$page` are bound as parameters, not just left on `state`.
793
+ *
794
+ * `injectContext` puts them on the document's state, so `${state.$site.name}` always worked — but
795
+ * every example in site-architecture.md §8.2 writes the bare `${$site.name}`, and that threw a
796
+ * ReferenceError the catch below turned into a silent null. A `$head` title referencing the site
797
+ * name reached the page as the literal template text.
798
+ */
799
+ const args = ["state", "$map", "$site", "$page"] as const;
800
+ const values = [scope, scope?.$map, scope?.$site, scope?.$page];
680
801
  try {
681
802
  const singleExprMatch = str.match(/^\$\{(.+)\}$/s);
682
- if (singleExprMatch) {
683
- const fn = new Function("state", "$map", `return (${singleExprMatch[1]})`) as (
684
- state: Record<string, unknown>,
685
- $map: unknown,
686
- ) => unknown;
687
- return fn(scope, scope?.$map);
688
- }
689
- const fn = new Function("state", "$map", `return \`${str}\``) as (
690
- state: Record<string, unknown>,
691
- $map: unknown,
692
- ) => unknown;
693
- return fn(scope, scope?.$map);
803
+ const body = singleExprMatch ? `return (${singleExprMatch[1]})` : `return \`${str}\``;
804
+ const fn = new Function(...args, body) as (...a: unknown[]) => unknown;
805
+ return fn(...values);
694
806
  } catch {
695
807
  return null;
696
808
  }
@@ -702,14 +814,7 @@ export function evaluateStaticTemplate(str: string, scope: Record<string, unknow
702
814
  * @returns {unknown}
703
815
  */
704
816
  export function getPathValue(base: unknown, path: string) {
705
- if (!path) {
706
- return base;
707
- }
708
- let acc: unknown = base;
709
- for (const key of path.split("/")) {
710
- acc = acc == null ? undefined : (acc as Record<string, unknown>)[key];
711
- }
712
- return acc;
817
+ return readPath(base, path);
713
818
  }
714
819
 
715
820
  /**
@@ -801,15 +906,6 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
801
906
  }
802
907
  }
803
908
 
804
- if (def.tagName === "img") {
805
- if (!def.attributes?.loading) {
806
- out += ` loading="lazy"`;
807
- }
808
- if (!def.attributes?.decoding) {
809
- out += ` decoding="async"`;
810
- }
811
- }
812
-
813
909
  if (def.$static) {
814
910
  out += ` data-jx-static`;
815
911
  } else if (def.$prerendered) {
@@ -1173,11 +1269,16 @@ export function collectStyles(
1173
1269
  counter.n += 1;
1174
1270
  }
1175
1271
 
1272
+ /* A chosen tag needs a selector that matches every candidate, not the first one.
1273
+ `#id` and `.class` are unaffected — they identify the element regardless of what it turns out
1274
+ to be — but a bare tag selector has to become the union, or styling an `<a>|<div>` wrapper
1275
+ would style exactly one of the two branches and silently miss the other. */
1276
+ const tagSelector = tagNameCandidates(def.tagName).join(", ") || "*";
1176
1277
  const selector = def.id
1177
1278
  ? `#${def.id}`
1178
1279
  : def.className
1179
1280
  ? `.${def.className.split(" ")[0]}`
1180
- : (def.tagName ?? "*");
1281
+ : tagSelector;
1181
1282
 
1182
1283
  if (def.style) {
1183
1284
  const baseDecls = [];
@@ -1378,6 +1479,40 @@ export const SELF_CLOSING = new Set<string>([
1378
1479
  "source",
1379
1480
  ]);
1380
1481
 
1482
+ /**
1483
+ * A tag for the PRERENDER, resolved against the same scope the attributes are.
1484
+ *
1485
+ * The static renderer produces bytes, so it must commit to one element. Every candidate is a
1486
+ * literal `TagName`, so committing is safe — and it is resolved here rather than left as text
1487
+ * precisely because the old `${…}` form was left as text and then re-resolved against the page
1488
+ * scope, where a component's own state is undefined.
1489
+ *
1490
+ * @param {unknown} tagName
1491
+ * @param {Record<string, unknown>} scope
1492
+ * @returns {string}
1493
+ */
1494
+ export function resolveStaticTagName(
1495
+ tagName: unknown,
1496
+ scope: Record<string, unknown> | null,
1497
+ ): string {
1498
+ if (typeof tagName === "string") {
1499
+ return tagName;
1500
+ }
1501
+ const candidates = tagNameCandidates(tagName);
1502
+ if (candidates.length === 0) {
1503
+ return "div";
1504
+ }
1505
+ if (!isTagExpression(tagName)) {
1506
+ return candidates[0]!;
1507
+ }
1508
+ const expression = tagName.$expression;
1509
+ if (expression.operator === "?:") {
1510
+ return resolveStaticValue(expression.target, scope) ? expression.value : expression.initial;
1511
+ }
1512
+ const key = resolveStaticValue(expression.target, scope);
1513
+ return expression.cases[String(key)] ?? expression.default;
1514
+ }
1515
+
1381
1516
  /**
1382
1517
  * Recursively render a Jx node tree to static HTML for pre-rendering.
1383
1518
  *
@@ -1435,7 +1570,11 @@ export function renderStaticNode(
1435
1570
  return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
1436
1571
  }
1437
1572
 
1438
- const tag = node.tagName ?? "div";
1573
+ /* Resolved against the SAME scope the attributes are resolved against, so the prerendered markup
1574
+ and the client's first render agree about what element this is. The old `${…}` form could not
1575
+ do this — it re-resolved the emitted string against the page scope, where a component's own
1576
+ state does not exist, so SSR silently emitted the fallback branch's tag. */
1577
+ const tag = resolveStaticTagName(node.tagName, scope);
1439
1578
 
1440
1579
  // Replace <slot> with provided slot content
1441
1580
  if (tag === "slot" && slotContent != null) {
@@ -1584,13 +1723,55 @@ function _isStaticNode(node: JxElement | string | (JxElement | string)[]): boole
1584
1723
  * @param {Record<string, string>} [mediaQueries] - Project media query definitions
1585
1724
  * @returns {string} CSS text, or empty string if no styles
1586
1725
  */
1726
+ /** `::slotted()` and `::part()` select through a shadow boundary, not the host that owns it. */
1727
+ const SHADOW_STANDALONE = /^(?:::slotted\(|::part\()/;
1728
+
1729
+ /**
1730
+ * Turn one nested style key into a selector, given the component's scope prefix.
1731
+ *
1732
+ * Three shapes, and the interesting one is `:host`. A style object should mean the same thing in
1733
+ * both modes, so `:host` and `:host(.foo)` are **translated** rather than passed through: inside a
1734
+ * shadow root they stand alone, and in the light DOM they become the tag name and `<tag>.foo` —
1735
+ * which is what "the host, matching this" means when there is no shadow root. Moving a component
1736
+ * between modes therefore does not silently break its styles.
1737
+ *
1738
+ * @param {string} prop - The style-object key, e.g. `":hover"`, `"& .inner"`, `":host(.wide)"`
1739
+ * @param {string} scope - `":host"` in shadow mode, the tag name otherwise
1740
+ * @returns {string}
1741
+ */
1742
+ function resolveSelector(prop: string, scope: string): string {
1743
+ if (prop.startsWith("&")) {
1744
+ return prop.replace("&", scope);
1745
+ }
1746
+ if (prop.startsWith(":host")) {
1747
+ const inner = /^:host\((.*)\)$/.exec(prop)?.[1];
1748
+ if (scope === ":host") {
1749
+ return prop;
1750
+ }
1751
+ return inner === undefined ? scope : `${scope}${inner}`;
1752
+ }
1753
+ // `:host::slotted(x)` matches nothing — the pseudo-element attaches to a slot, not the host.
1754
+ if (scope === ":host" && SHADOW_STANDALONE.test(prop)) {
1755
+ return prop;
1756
+ }
1757
+ return `${scope}${prop}`;
1758
+ }
1759
+
1587
1760
  export function buildComponentCSS(
1588
1761
  tagName: string,
1589
1762
  styleDef?: JxStyle | null | undefined,
1590
1763
  doc: JxElement | null = null,
1591
1764
  mediaQueries: Record<string, string> = {},
1765
+ shadow: ShadowMode | null = null,
1592
1766
  ) {
1593
1767
  const rules: string[] = [];
1768
+ /*
1769
+ * The scope prefix. In light DOM it is the tag name, which is what keeps `sty-card .inner` from
1770
+ * reaching another component's `.inner`. Inside a shadow root the selector cannot see the host's
1771
+ * tag name at all, and `:host` is the standard's way to address it — so the same style object
1772
+ * produces different, correct CSS in each mode without the author restating it.
1773
+ */
1774
+ const scope = styleScopePrefix(tagName, shadow);
1594
1775
 
1595
1776
  if (styleDef && typeof styleDef === "object") {
1596
1777
  const decls: string[] = [];
@@ -1613,20 +1794,21 @@ export function buildComponentCSS(
1613
1794
  decls.push(` ${camelToKebab(prop)}: ${value};`);
1614
1795
  }
1615
1796
  if (decls.length > 0) {
1616
- rules.push(`${tagName} {\n${decls.join("\n")}\n}`);
1797
+ rules.push(`${scope} {\n${decls.join("\n")}\n}`);
1617
1798
  }
1618
1799
 
1619
1800
  for (const [prop, val] of Object.entries(styleDef)) {
1620
1801
  if (prop.startsWith("@")) {
1621
- pushConditionalRule(rules, prop, mediaQueries, tagName, val as Record<string, unknown>);
1802
+ pushConditionalRule(rules, prop, mediaQueries, scope, val as Record<string, unknown>);
1622
1803
  } else if (
1623
1804
  prop.startsWith(":") ||
1624
1805
  prop.startsWith(".") ||
1625
1806
  prop.startsWith("&") ||
1626
1807
  prop.startsWith("[")
1627
1808
  ) {
1628
- const resolved = prop.startsWith("&") ? prop.replace("&", tagName) : `${tagName}${prop}`;
1629
- rules.push(`${resolved} { ${toCSSText(val as Record<string, unknown>)} }`);
1809
+ rules.push(
1810
+ `${resolveSelector(prop, scope)} { ${toCSSText(val as Record<string, unknown>)} }`,
1811
+ );
1630
1812
  }
1631
1813
  }
1632
1814
  }