@zvndev/darkmown 1.3.0 → 1.4.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/AGENTS.md CHANGED
@@ -253,6 +253,8 @@ input:focus-visible
253
253
  ```
254
254
  Always include: a tokens block, a real type scale, generous spacing, a `max-width` centered `main`, styled `button`/`input` with `:hover`/`:focus-visible` states, and a responsive `@media` rule (`.skin` supports `@media` — its indented rules are wrapped automatically). The `font` property is the CSS shorthand when it leads with a size (`font 16px/1.6 system-ui`) and `font-family` otherwise. Prefer `.skin`; a plain `<style>` block in a `.wd` also works.
255
255
 
256
+ **Scoped styles — opt in with `scoped`.** A `.skin` is **global** by default (style site-wide; the right default for your design system). To stop a reusable component's classes from clashing with the rest of the page, make the **first line** of its `.skin` the word `scoped` — its selectors then only match the component it's colocated with (a page skin scopes that page; an include skin scopes just that include's subtree). You still write `class="card"`; the build stamps a `data-wd-scope` attribute and rewrites the selectors — zero runtime, a static page stays static. **Tokens stay global** (a `tokens`/`tokens dark` block still emits `:root` vars, so `$accent` and dark mode keep working). Escape a single rule with `:global(.x)` (whole-selector only). `page`/`*`/`html`/`body`/`::selection` are a compile error in a scoped skin — those belong in a global skin.
257
+
256
258
  **Layout gotcha — CTAs and link rows.** Markdown wraps adjacent links/buttons on consecutive lines into a single `<p>`, so putting `display:flex` on their container won't space them. To lay out a row of buttons or nav links, wrap them in explicit raw HTML and style that:
257
259
  ```wd
258
260
  <div class="actions">
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
4
4
 
5
+ ## 1.4.0 — 2026-06-28
6
+
7
+ Scoped styles — the long-standing "`.skin` selectors are global" gap, closed. Opt-in, compile-time, and **zero runtime** (the shared runtime is still 7459 B gzipped; nothing new ships to the client but one HTML attribute and one attribute selector).
8
+
9
+ - **`scoped` skins.** Put `scoped` as the first line of a `.skin` file and its selectors apply only to the page or include it ships with — so two components can reuse the same class name (`.card`) without colliding. The compiler appends a per-file `[data-wd-scope="wd-xxxx"]` attribute to each selector's subject and stamps the matching subtree's HTML; the scope id is a stable hash of the skin's path. Selectors compose as you'd expect: descendant selectors scope only the subject, comma lists scope each branch, `@media`/`@supports` wrappers pass through, and `&`-nesting/pseudo-classes resolve before the attribute is attached.
10
+ - **Tokens stay global.** A `tokens` / `tokens dark` block in a `scoped` skin still emits global `:root` custom properties, so `var(--…)`, dark mode, `:theme`, and the `$code-*` highlight tokens keep working across the whole site — only your component's selector rules are scoped.
11
+ - **`:global(...)` escape hatch** for a whole selector that must stay global (e.g. a class you portal elsewhere). Page-level selectors (`page`, `*`, `html`, `body`, `::selection`) are rejected in a scoped skin with a corrective hint — those belong in a global design-system skin.
12
+ - **Fully backward compatible.** A `.skin` without the `scoped` marker is unchanged, byte-for-byte (covered by a golden test). Existing global skins — `base.skin`, `nav.skin`, every scaffolded project — keep styling the whole site exactly as before. Unused scoped selectors produce a build *warning* (never silently removed, since a colocated `.js` behavior may add the class at runtime).
13
+
5
14
  ## 1.3.0 — 2026-06-28
6
15
 
7
16
  The "table-stakes batteries" release — the build-time, zero-JS features a markdown site is expected to ship. The runtime is unchanged (still 7459 B gzipped, well under the 8 KB budget); everything here is compile-time HTML/CSS or build output.
package/README.md CHANGED
@@ -809,6 +809,50 @@ A single `tokens dark` block powers **both** theming paths: it compiles to `:roo
809
809
 
810
810
  Because `tokens dark` already emits the `[data-theme="dark"]` rule, **no extra skin block is needed** — the same palette drives the toggle. `"light"` forces the light palette even under OS dark; `"auto"` clears the attribute and follows the OS again; `theme` persists across reloads and tabs like any `:store`. (`:theme name = "light"` renames the store and seeds a different default. The older `tokens [data-theme=dark]` block — a manual-only override — still works for bespoke setups.)
811
811
 
812
+ ## Scoped styles — `scoped`
813
+
814
+ *New in 1.4.0.* By default a colocated `.skin` is **global** — its selectors match the whole page, exactly like a stylesheet. That's the right default for a design system. But when two components both want a class called `.card`, global CSS makes them fight. Opt a skin into **scoping** so its selectors only ever match the component it ships with: make the **first line** of the `.skin` file the word `scoped`.
815
+
816
+ ```skin
817
+ scoped
818
+
819
+ .card
820
+ padding 1.5rem
821
+ bg $panel
822
+ radius $radius
823
+ ```
824
+
825
+ Scoping is **pure compile time** — a short, path-derived id (e.g. `wd-7c21`) is stamped onto the component's HTML (`data-wd-scope="wd-7c21"`) and appended to each of the skin's selectors:
826
+
827
+ ```css
828
+ .card[data-wd-scope="wd-7c21"] { padding: 1.5rem; }
829
+ .card[data-wd-scope="wd-7c21"] { background: var(--panel); }
830
+ ```
831
+
832
+ A second component's `.card` gets a **different** id, so the two never collide. There is **no runtime cost and no class renaming in your markup** — you still write `class="card"`; the framework adds the attribute during the build. A static page with a scoped skin stays **zero-JS**.
833
+
834
+ **What scopes, what stays global:**
835
+
836
+ | In a `scoped` skin | Result |
837
+ |---|---|
838
+ | A selector rule (`.card`, `.card:hover`, `.card .title`, `h2, h3`) | Scoped — the attribute lands on the **subject** (rightmost) selector, before any `:hover`/`::before` so it stays valid CSS |
839
+ | A descendant selector (`.card .title`) | Only `.title` (the subject) is scoped; `.card` matches inside the subtree |
840
+ | `tokens` / `tokens dark` / `tokens [data-theme=…]` | **Global** — design tokens always emit on `:root`, so `$accent` and dark mode keep working site-wide |
841
+ | `:global(.toast)` (whole selector) | **Opts back out** — emits a plain, unscoped `.toast { … }` |
842
+ | `@media` / `@supports` wrappers | Untouched; only the inner rule's selector is scoped |
843
+ | `page` / `*` / `html` / `body` / `::selection` | **Compile error** — page-level styles belong in a global skin, not a scoped one |
844
+
845
+ Where the scope applies depends on **where the skin lives**:
846
+
847
+ - A skin colocated with a **page** (`about.skin` next to `about.wd`) scopes that page's body.
848
+ - A skin colocated with an **include** (`card.skin` next to `card.wd`) scopes just that include's subtree wherever it's `@include`d — so the same scoped component can appear many times on a page without leaking into its neighbours.
849
+
850
+ **Caveats (honest limits this release):**
851
+
852
+ - **Whole-selector `:global()` only.** A descendant `:global` (`.card :global(.x)`) is **not** supported yet — use a whole-selector `:global(.x)`.
853
+ - **Unused selectors warn, they aren't removed.** If a scoped selector's class/element/id never appears in the stamped subtree, the build prints `hint: scoped selector ".badge" in card.skin matches no element` — but the rule is **kept** (a colocated `.js` may add the class at runtime). It's a typo nudge, not a dead-code remover.
854
+ - **Scoping is opt-in.** Every existing `.skin` (no `scoped` marker) is byte-for-byte unchanged.
855
+
812
856
  ## The escape hatch
813
857
 
814
858
  Reactive pages expose `window.wd` so a colocated `.js` file can do anything the directives deliberately don't — keyboard, drag/touch, canvas, charts, maps:
@@ -134,6 +134,22 @@ Four progressive-disclosure capabilities plus an assets and a security change. T
134
134
  - **Page-colocated static assets:** any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and colocated `.skin`/`.js`) is copied to `dist/` at its path (`site/pages/logo.svg` → `dist/logo.svg`). The shelf (`site/_/` → `/__wd/media`, `/__wd/data`) is unchanged; a name clash with an emitted route is skipped with a warning. As of v0.14.1, hidden path segments (`.`, `-`, `_`) and symlinked page assets are skipped. `.avif` was added to the served content-types.
135
135
  - **`:fetch` URL scheme hardening (security):** `:fetch`/`refresh=` URLs are validated at compile time — relative, `http(s)://`, or a leading `{ state }` interpolation are allowed; protocol-relative `//host` and non-http(s) schemes (`file:`, `data:`, `javascript:`, …) are rejected with a corrective hint. **This is scheme validation, not a host allowlist** — SSRF to arbitrary http(s) hosts remains the author's responsibility (see the trust boundary).
136
136
 
137
+ ## 1.4.0 additions — compile-time scoped styles (2026-06-28)
138
+
139
+ The 1.4.0 cycle ships one feature: **opt-in scoped styles** for colocated `.skin` files, closing the "no CSS scoping" gap an audit flagged (two components couldn't reuse a class name without collision). **Pure compile time, zero runtime** — `src/runtime.js` and the 8 KB budget are untouched, and a static page with a scoped skin stays `runtime: false`.
140
+
141
+ - **Authoring surface — the `scoped` marker:** a `.skin` scopes its selectors **only** when its first non-comment, non-blank, non-divider line is exactly `scoped`; colocation alone changes nothing. Every existing `.skin` (no marker) stays **global** and emits **byte-identical CSS** to before (golden-tested against `site/_/base.skin`). `scoped` anywhere but the first line is a compile error (`"scoped" must be the first line of a .skin file`). The only other new surface is `:global()` inside a scoped skin — no new `.wd` syntax.
142
+
143
+ - **Mechanism — id + selector rewrite + HTML stamp.** The scope id is a short, deterministic hash of the skin's **project-relative path** (e.g. `wd-7c21`) — path-based (stable across content edits, unique per file), computed in `src/builder.js` (the coordination point) and re-derived identically by the HTML stamp from the same path, so `src/skin.js` stays a pure string→CSS function (`compileSkin(src, { scope })`). In scoped mode `compileSkin` appends `[data-wd-scope="<id>"]` to the **subject** (rightmost compound) of each comma-separated selector, **after** `&`-nesting resolves and **before** any trailing pseudo-class/element (`.card[data-wd-scope="…"]:hover`, never `.card:hover[…]`; `::before` likewise) — so descendant selectors scope only the subject (`.card .title` → `.title` gets the attribute) and `@media`/`@supports` wrappers are untouched. The HTML half (`stampScope` in `src/compiler/page.js`, an `enhanceImages`-style regex post-pass) stamps `data-wd-scope="<id>"` onto every opening tag of the scoped subtree that doesn't already carry one (first stamp wins, so a nested include keeps its own id). A **page**-colocated scoped skin stamps the page body; an **include**-colocated scoped skin is stamped in `handleInclude` over just that include's returned subtree — so the same scoped component repeats on a page without its scope leaking to siblings.
144
+
145
+ - **Tokens stay global.** A `tokens` / `tokens dark` / `tokens [attr]` block in a scoped skin still emits a global `:root { --name: … }` (and the dark/manual-toggle variants) — never scoped — so `var(--x)`, dark mode, `:theme`, and the `$code-*` highlight tokens keep resolving site-wide. Enforced by only touching the selector-rule construction, leaving the token/`:root` path identical.
146
+
147
+ - **`:global()` escape + disallowed selectors.** A **whole-selector** `:global(.toast)` emits a plain, unscoped `.toast { … }`. Page-level selectors in a scoped skin — `page` (→ body), bare top-level `*`/`html`/`body`/`::selection`, and any bare top-level declaration that would land on `:root` — are a **compile error** with a hint pointing to a global skin (chosen over silently auto-`:global`-ing them).
148
+
149
+ - **Unused-selector handling — warn, never remove.** The builder has both the scoped subject set (collected by `compileSkin`) and the stamped subtree HTML, so it warns (`hint: scoped selector ".badge" in card.skin matches no element`) when a scoped subject's class/element/id never appears in the stamped subtree — but **keeps the rule** (a colocated `.js` may add the class at runtime). A typo nudge, not a dead-code remover.
150
+
151
+ - **Honest caveats:** only **whole-selector** `:global()` this release — descendant `:global` (`.card :global(.x)`) is deferred; unused selectors **warn, are not removed**. Demonstrated end-to-end by the [`/scoped/`](/scoped/) demo (two components reuse `.card` with no collision; a global skin still works alongside).
152
+
137
153
  ## 1.3.0 additions — table-stakes batteries (2026-06-28)
138
154
 
139
155
  The 1.3.0 cycle closes table-stakes gaps a new user expects out of the box. Tracks land in parallel; this entry covers the **syntax-highlighting** and **drafts + feeds** tracks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,43 @@
1
+ scoped
2
+
3
+ /* Component A — a warm promo card. Same class names as scoped-stat.skin
4
+ (.card / .title / .body), but scoped, so neither leaks. Tokens stay global,
5
+ so $accent / $warm / $radius still resolve from base.skin's :root. */
6
+
7
+ .card
8
+ display grid
9
+ gap .6rem
10
+ padding 1.5rem
11
+ bg $warm-soft
12
+ border 1px solid $warm
13
+ border-left 4px solid $warm
14
+ radius $radius
15
+
16
+ .badge
17
+ justify-self start
18
+ font-size .7rem
19
+ font-weight 700
20
+ text-transform uppercase
21
+ letter-spacing .08em
22
+ color #fff
23
+ bg $warm
24
+ padding .2rem .55rem
25
+ radius 999px
26
+
27
+ .title
28
+ margin 0
29
+ font-size 1.25rem
30
+ color $ink
31
+
32
+ .body
33
+ margin 0
34
+ color $ink-soft
35
+ max-width none
36
+
37
+ .cta
38
+ justify-self start
39
+ font-weight 600
40
+ color $warm
41
+
42
+ .cta:hover
43
+ color $ink
@@ -0,0 +1,10 @@
1
+ <!-- Scoped component A. Its colocated scoped-promo.skin opts into scoping, so the
2
+ `.card` here gets its OWN look — a warm, accent-bordered promo — without
3
+ clashing with the identically-named `.card` in scoped-stat.wd. handleInclude
4
+ stamps just this returned subtree with the component's scope id. -->
5
+ <article class="card">
6
+ <span class="badge">Promo</span>
7
+ <h3 class="title">Same class name, zero collision</h3>
8
+ <p class="body">This <code>.card</code> is a warm promo panel. The component beside it also calls its root <code>.card</code> — and they do not fight, because each scoped skin only matches inside its own stamped subtree.</p>
9
+ <a class="cta" href="/docs/">How scoping works →</a>
10
+ </article>
@@ -0,0 +1,32 @@
1
+ scoped
2
+
3
+ /* Component B — a dark stat tile. Identical class names to scoped-promo.skin,
4
+ but a completely different look. Scoping keeps them apart; tokens are global
5
+ so $accent-deep / $accent / $radius-lg come straight from base.skin's :root. */
6
+
7
+ .card
8
+ display grid
9
+ gap .35rem
10
+ justify-items center
11
+ text-align center
12
+ padding 2rem 1.5rem
13
+ bg $accent-deep
14
+ border 1px solid $accent-deep
15
+ radius $radius-lg
16
+
17
+ .title
18
+ font-size 3rem
19
+ font-weight 700
20
+ line-height 1
21
+ color #fff
22
+
23
+ .body
24
+ margin 0
25
+ max-width none
26
+ color $accent-soft
27
+
28
+ .badge
29
+ font-size .68rem
30
+ text-transform uppercase
31
+ letter-spacing .1em
32
+ color $accent
@@ -0,0 +1,9 @@
1
+ <!-- Scoped component B. Reuses the SAME class names as scoped-promo.wd
2
+ (.card / .title / .body) but its colocated scoped-stat.skin scopes them to a
3
+ dark, centered stat tile. Proof the leak is gone: two `.card` definitions on
4
+ one page, each only styling its own subtree. -->
5
+ <article class="card">
6
+ <span class="title">99.9%</span>
7
+ <p class="body">uptime, because there is no client framework to crash</p>
8
+ <span class="badge">measured</span>
9
+ </article>
@@ -414,6 +414,29 @@ For an explicit light/dark switch alongside the OS preference, declare `:theme`
414
414
 
415
415
  A single `tokens dark` block in your `.skin` already powers both the OS `prefers-color-scheme` query and this toggle — no extra block needed. `"auto"` clears the attribute and follows the OS again. See it on the [Ledger demo](/ledger/).
416
416
 
417
+ ## Scoped styles — `scoped`
418
+
419
+ A colocated `.skin` is **global** by default. Make its **first line** `scoped` and its selectors only ever match the component it ships with — so two components can both use `.card` without colliding. It's pure compile time: a path-derived id (e.g. `wd-7c21`) is stamped onto the HTML and appended to each selector.
420
+
421
+ ```skin
422
+ scoped
423
+
424
+ .card
425
+ padding 1.5rem
426
+ bg $panel
427
+ ```
428
+
429
+ compiles to `.card[data-wd-scope="wd-7c21"] { … }`, and the component's markup is stamped `data-wd-scope="wd-7c21"`. You still write `class="card"` — no renaming, no runtime, **zero JS** on a static page. A page skin scopes the page body; an **include** skin scopes just that include's subtree wherever it's used.
430
+
431
+ | In a `scoped` skin | Result |
432
+ |---|---|
433
+ | A selector rule | Scoped — attribute on the subject, before any `:hover`/`::before` |
434
+ | `tokens` / `tokens dark` | **Global** on `:root` — `$vars` and dark mode keep working site-wide |
435
+ | `:global(.toast)` (whole selector) | Opts back out — plain unscoped `.toast { … }` |
436
+ | `page` / `*` / `html` / `body` / `::selection` | Compile error — page-level styles belong in a global skin |
437
+
438
+ Honest limits this release: only **whole-selector** `:global()` (no `.card :global(.x)` yet), and an **unused** scoped selector **warns but is not removed** (a colocated `.js` may add the class). See the side-by-side [Scoped demo](/scoped/).
439
+
417
440
  ## Media — `:video`, `:audio`, `:embed`
418
441
 
419
442
  Three compile-time-only directives (they emit no `data-wd-*`, so a media page stays zero-JS):
@@ -0,0 +1,23 @@
1
+ scoped
2
+
3
+ /* Page-scoped skin for this route. The whole page body is stamped, so these
4
+ selectors style only this page's own markup — proving page-level scoping
5
+ alongside the two include-scoped components. A :global() escape hatch shows a
6
+ single rule opting back out to a plain, unscoped selector. */
7
+
8
+ .intro
9
+ margin 2rem 0 3rem
10
+
11
+ .duo
12
+ margin 3rem 0
13
+
14
+ .how
15
+ margin 4rem 0 2rem
16
+
17
+ .how ul
18
+ color $ink-soft
19
+
20
+ /* :global escape — this selector ships UNSCOPED, matching site-wide. */
21
+ :global(.scoped-note)
22
+ font-size .85rem
23
+ color $muted
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: Scoped styles — same class name, zero collision
3
+ description: Two components reuse the class name .card and never clash. Compile-time scoped styles stamp each component's subtree with a path-derived id and rewrite its skin's selectors to match only there. Zero runtime JS — a static page stays static.
4
+ transitions: true
5
+ ---
6
+
7
+ @include /nav.wd
8
+ @include /base.wd
9
+
10
+ <main>
11
+
12
+ <section class="intro">
13
+ <span class="eyebrow">Darkmown 1.4</span>
14
+
15
+ # Scoped styles
16
+
17
+ Two components on this page both call their root element `.card`. In plain CSS the second skin would overwrite the first. Here they coexist: a `.skin` whose first line is `scoped` only ever styles the subtree it ships with. It's pure compile time — a path-derived id stamped onto the HTML and appended to the skin's selectors. No runtime, no class hashing in your markup, no collision.
18
+
19
+ </section>
20
+
21
+ <section class="duo">
22
+
23
+ <div class="grid cols-2">
24
+
25
+ @include /scoped-promo.wd
26
+ @include /scoped-stat.wd
27
+
28
+ </div>
29
+
30
+ </section>
31
+
32
+ <section class="how">
33
+
34
+ ## What actually shipped
35
+
36
+ - The `.card` in the warm promo and the `.card` in the dark stat tile are **different rules**, scoped to different ids.
37
+ - This page also uses a **global** skin (`base.skin`, via `/base.wd`) — `.btn`, `.eyebrow`, `.grid` still work everywhere, unscoped.
38
+ - Design **tokens stay global**: every scoped skin still reads `$accent`, `$warm`, `$radius` from `:root`, so dark mode keeps working for free.
39
+ - A `:global(.x)` selector inside a scoped skin opts a single rule back out.
40
+
41
+ <a class="btn primary" href="/docs/">Read the docs</a>
42
+
43
+ </section>
44
+
45
+ </main>
46
+
47
+ @include /footer.wd
package/src/builder.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
18
18
  import { HIGHLIGHT_CSS } from "./highlight.js";
19
19
  import { discoverRoutes, outputPathForRoute } from "./router.js";
20
- import { compileSkin } from "./skin.js";
20
+ import { compileSkin, scopeIdFor } from "./skin.js";
21
21
 
22
22
  /**
23
23
  * @typedef {import("./config.js").Paths} Paths
@@ -91,7 +91,7 @@ export function buildSite(cwd = process.cwd(), options = {}) {
91
91
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
92
92
  fs.writeFileSync(outFile, page.html);
93
93
  if (route.route === "/404/") notFoundHtml = page.html;
94
- emitAssets(page.assets, paths);
94
+ emitAssets(page.assets, paths, page.html, warned);
95
95
  if (page.assets.runtime) emitRuntime(paths);
96
96
  emitBehaviors(page.assets, paths);
97
97
  if (page.assets.hasCode) emitHighlight(paths);
@@ -544,22 +544,95 @@ function emitHighlight(paths) {
544
544
  }
545
545
 
546
546
  /**
547
+ * Emit a page's colocated assets. `.skin` files compile through {@link compileSkin}
548
+ * (other files copy verbatim). A skin that opted into scoping (`assets.scopedSkins`)
549
+ * compiles in scoped mode: the scope id is derived here from its PROJECT-RELATIVE
550
+ * PATH — the single coordination point with the HTML stamp, which derives the same
551
+ * id from the same path — and threaded into `compileSkin({ scope })`. The compiler
552
+ * also reports the scoped subject selectors, which we check against the stamped page
553
+ * HTML to warn (never remove) on a scoped selector that matches no element.
547
554
  * @param {Assets} assets
548
555
  * @param {Paths} paths
556
+ * @param {string} pageHtml Assembled page HTML (carries the `data-wd-scope` stamps).
557
+ * @param {Set<string>} warned Cross-route dedupe set for emitted hints.
549
558
  * @returns {void}
550
559
  */
551
- function emitAssets(assets, paths) {
560
+ function emitAssets(assets, paths, pageHtml, warned) {
552
561
  for (const [source, href] of assets.files) {
553
562
  const out = path.join(paths.distRoot, href);
554
563
  fs.mkdirSync(path.dirname(out), { recursive: true });
555
564
  if (source.endsWith(".skin")) {
556
- fs.writeFileSync(out, compileSkin(fs.readFileSync(source, "utf8")));
565
+ const src = fs.readFileSync(source, "utf8");
566
+ if (assets.scopedSkins.has(source)) {
567
+ const rel = path.relative(paths.cwd, source).replaceAll(path.sep, "/");
568
+ const scope = scopeIdFor(rel);
569
+ /** @type {Set<string>} */
570
+ const subjects = new Set();
571
+ fs.writeFileSync(out, compileSkin(src, { scope, subjects }));
572
+ warnUnusedScoped(subjects, scope, pageHtml, path.basename(source), warned);
573
+ } else {
574
+ fs.writeFileSync(out, compileSkin(src));
575
+ }
557
576
  } else {
558
577
  fs.copyFileSync(source, out);
559
578
  }
560
579
  }
561
580
  }
562
581
 
582
+ /**
583
+ * Warn (do NOT remove) when a scoped selector's subject never appears in its
584
+ * stamped subtree — a likely typo or stale rule. We only have a class/element/id
585
+ * to look for, so we scan the elements actually stamped with THIS scope id in the
586
+ * assembled page and check each subject against them. A colocated `.js` may add
587
+ * classes at runtime, so this stays advisory: the rule is always emitted. An empty
588
+ * subject token (a bare attribute-selector subject) is unknowable here and skipped.
589
+ * @param {Set<string>} subjects Scoped subject tokens (`.card`, `h2`, `#id`).
590
+ * @param {string} scope Scope id.
591
+ * @param {string} pageHtml Assembled page HTML.
592
+ * @param {string} skinName Skin filename for the message.
593
+ * @param {Set<string>} warned Cross-route dedupe set.
594
+ * @returns {void}
595
+ */
596
+ function warnUnusedScoped(subjects, scope, pageHtml, skinName, warned) {
597
+ // The opening tags carrying this scope id — the only elements the scoped
598
+ // stylesheet can match. One scan, reused for every subject.
599
+ const stamped = pageHtml.match(
600
+ new RegExp(`<[a-zA-Z][\\w-]*[^>]*\\bdata-wd-scope="${scope}"[^>]*>`, "g")
601
+ );
602
+ const tags = stamped ?? [];
603
+ for (const subject of subjects) {
604
+ if (!subject) continue;
605
+ const present = tags.some((tag) => subjectMatchesTag(subject, tag));
606
+ if (present) continue;
607
+ const warning = `scoped selector "${subject}" in ${skinName} matches no element`;
608
+ if (warned.has(warning)) continue;
609
+ warned.add(warning);
610
+ console.warn(`hint: ${warning}`);
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Whether a scoped subject token (`.card` / `#id` / a bare tag like `h2`) could
616
+ * match a single stamped opening tag. Class subjects look in the tag's `class`
617
+ * attribute; id subjects in `id`; a bare tag compares the element name.
618
+ * @param {string} subject
619
+ * @param {string} tag A single opening tag string.
620
+ * @returns {boolean}
621
+ */
622
+ function subjectMatchesTag(subject, tag) {
623
+ if (subject.startsWith(".")) {
624
+ const cls = subject.slice(1);
625
+ const m = tag.match(/\bclass\s*=\s*"([^"]*)"/);
626
+ return m ? m[1].split(/\s+/).includes(cls) : false;
627
+ }
628
+ if (subject.startsWith("#")) {
629
+ const m = tag.match(/\bid\s*=\s*"([^"]*)"/);
630
+ return m ? m[1] === subject.slice(1) : false;
631
+ }
632
+ const name = tag.match(/^<([a-zA-Z][\w-]*)/);
633
+ return name ? name[1].toLowerCase() === subject.toLowerCase() : false;
634
+ }
635
+
563
636
  /**
564
637
  * Copy non-page shelf assets (JSON data, media) into `dist/__wd`.
565
638
  * @param {Paths} paths
@@ -35,6 +35,10 @@
35
35
  * @property {boolean} hasCode Whether the page has a build-time-highlighted code
36
36
  * block, so the framework highlight stylesheet (`/__wd/highlight.css`) is
37
37
  * emitted and linked — pay-for-what-you-use, zero JS.
38
+ * @property {Set<string>} scopedSkins Source paths of colocated `.skin` files
39
+ * that opted into scoping (`scoped` first line). The builder emits these with
40
+ * the scope attribute and runs the unused-selector warning against the stamped
41
+ * subtree; the HTML stamp uses them to scope the matching page/include subtree.
38
42
  */
39
43
 
40
44
  /**
@@ -144,7 +148,8 @@ export function createCompilation() {
144
148
  files: new Map(),
145
149
  runtime: false,
146
150
  behaviors: new Set(),
147
- hasCode: false
151
+ hasCode: false,
152
+ scopedSkins: new Set()
148
153
  },
149
154
  state: new Map(),
150
155
  stores: new Set(),
@@ -12,7 +12,7 @@
12
12
  import { compileBody } from "./body.js";
13
13
  import { at, createScope, LOOP_META } from "./context.js";
14
14
  import { FORMATTERS } from "./format.js";
15
- import { resolveInclude } from "./includes.js";
15
+ import { resolveInclude, scopedSkinFor } from "./includes.js";
16
16
  import {
17
17
  escapeHtml,
18
18
  getPath,
@@ -26,7 +26,7 @@ import {
26
26
  stripQuotes,
27
27
  validatePath
28
28
  } from "./interpolation.js";
29
- import { compileFile } from "./page.js";
29
+ import { compileFile, stampScope } from "./page.js";
30
30
  import { compileComputedExpr, compileWhen, evalPredicate } from "./predicates.js";
31
31
 
32
32
  /**
@@ -45,17 +45,23 @@ export function handleInclude(line, ctx, index) {
45
45
  if (!match) throw new Error(`Malformed @include in ${at(ctx.file, index)}: ${line}`);
46
46
  const target = resolveInclude(match[1], ctx.file, ctx.context);
47
47
  const args = parseIncludeArgs(match[2] || "", ctx);
48
- const scope = createScope(ctx.scope, args);
48
+ const childScope = createScope(ctx.scope, args);
49
49
  const child = compileFile(
50
50
  target,
51
51
  ctx.context,
52
52
  ctx.stack,
53
- scope,
53
+ childScope,
54
54
  ctx.comp,
55
55
  ctx.sections,
56
56
  ctx.loopItem
57
57
  );
58
- return child.html;
58
+ // An include with its OWN scoped colocated skin stamps just its returned
59
+ // subtree — so a scoped `.card` in the include can't collide with a `.card`
60
+ // anywhere else on the page, and the scope never leaks to the include's
61
+ // siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
62
+ // the same path-derived id, so attribute and stylesheet line up.
63
+ const scopedSkin = scopedSkinFor(target, ctx.context);
64
+ return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
59
65
  }
60
66
 
61
67
  /**
@@ -6,6 +6,7 @@
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
+ import { scopeIdFor } from "../skin.js";
9
10
  import { pageIncludeExtensions } from "./context.js";
10
11
  import { stripQuotes } from "./interpolation.js";
11
12
 
@@ -15,6 +16,47 @@ import { stripQuotes } from "./interpolation.js";
15
16
  * @typedef {import("./context.js").Compilation} Compilation
16
17
  */
17
18
 
19
+ /**
20
+ * Read a `.skin` file's first meaningful line (skipping `//` line comments,
21
+ * `/* … *\/` block comments, blanks, and decorative dividers — the same skip
22
+ * rules `compileSkin` applies) to decide whether it opts into scoping. A skin
23
+ * scopes its selectors ONLY when that first line is exactly `scoped`; otherwise
24
+ * it is global and untouched. This is the single source for "is this skin
25
+ * scoped + what is its id", so the CSS rewrite (builder) and the HTML stamp
26
+ * (page/include) always agree on both.
27
+ * @param {string} skinPath Absolute path to the colocated `.skin` file.
28
+ * @param {string} cwd Project root, for the path-based scope id.
29
+ * @returns {{ scoped: boolean, scopeId: string }}
30
+ */
31
+ export function scopeInfoForSkin(skinPath, cwd) {
32
+ const rel = path.relative(cwd, skinPath).replaceAll(path.sep, "/");
33
+ const scopeId = scopeIdFor(rel);
34
+ const src = fs.readFileSync(skinPath, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
35
+ for (const raw of src.split("\n")) {
36
+ const t = raw.trim();
37
+ if (!t || t.startsWith("//") || !/[A-Za-z0-9]/.test(t)) continue;
38
+ return { scoped: t === "scoped", scopeId };
39
+ }
40
+ return { scoped: false, scopeId };
41
+ }
42
+
43
+ /**
44
+ * The scope info for a page/include's OWN colocated `.skin` sibling, or null
45
+ * when there is no sibling skin. Used by the HTML stamp sites: a route's own
46
+ * scoped skin → stamp the page body; an include's scoped skin → stamp the
47
+ * returned subtree. Resolution mirrors `collectColocatedAssets` (same stem,
48
+ * `.skin` extension).
49
+ * @param {string} file Absolute path to the page/include source file.
50
+ * @param {Paths} context
51
+ * @returns {{ scoped: boolean, scopeId: string } | null}
52
+ */
53
+ export function scopedSkinFor(file, context) {
54
+ const ext = path.extname(file);
55
+ const candidate = `${file.slice(0, -ext.length)}.skin`;
56
+ if (!fs.existsSync(candidate)) return null;
57
+ return scopeInfoForSkin(candidate, context.cwd);
58
+ }
59
+
18
60
  /**
19
61
  * Register a page's colocated `.skin`/`.js` siblings as emitted assets.
20
62
  * @param {string} file
@@ -35,7 +77,12 @@ export function collectColocatedAssets(file, context, assets) {
35
77
  const outputExt = assetExt === ".skin" ? ".css" : ".js";
36
78
  const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
37
79
  assets.files.set(candidate, publicPath);
38
- if (assetExt === ".skin") assets.skins.add(publicPath);
80
+ if (assetExt === ".skin") {
81
+ assets.skins.add(publicPath);
82
+ // Surface "this skin is scoped" so the warning pass (builder) and the HTML
83
+ // stamp can find the scoped skins among all colocated ones by source path.
84
+ if (scopeInfoForSkin(candidate, context.cwd).scoped) assets.scopedSkins.add(candidate);
85
+ }
39
86
  if (assetExt === ".js") assets.scripts.add(publicPath);
40
87
  }
41
88
  }
@@ -20,4 +20,4 @@
20
20
  export { parseFrontmatter } from "./frontmatter.js";
21
21
  export { escapeHtml } from "./interpolation.js";
22
22
  export { loopKeyOf } from "./loops.js";
23
- export { compileDocument, compilePage, enhanceImages } from "./page.js";
23
+ export { compileDocument, compilePage, enhanceImages, stampScope } from "./page.js";
@@ -12,7 +12,7 @@ import { htmlHasHighlight } from "../highlight.js";
12
12
  import { compileBody } from "./body.js";
13
13
  import { createCompilation, createScope } from "./context.js";
14
14
  import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
15
- import { collectColocatedAssets, scanMarkdownHints } from "./includes.js";
15
+ import { collectColocatedAssets, scanMarkdownHints, scopedSkinFor } from "./includes.js";
16
16
  import { escapeHtml } from "./interpolation.js";
17
17
  import { selectMd } from "./markdown.js";
18
18
 
@@ -132,7 +132,14 @@ export function compilePage(file, context, options = {}) {
132
132
  ? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
133
133
  : "";
134
134
 
135
- const body = enhanceImages(compiled.html, context);
135
+ // A route whose OWN colocated skin opted into scoping stamps the whole page
136
+ // body so its scoped selectors match. Pure compile-time string post-pass (like
137
+ // enhanceImages) — no runtime, no `data-wd-*` marker, so a static page stays
138
+ // static. Include-scoped skins are stamped earlier, in handleInclude.
139
+ const scopedSkin = scopedSkinFor(file, context);
140
+ const stamped =
141
+ scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
142
+ const body = enhanceImages(stamped, context);
136
143
 
137
144
  return {
138
145
  meta: compiled.meta,
@@ -192,6 +199,30 @@ export function enhanceImages(html, paths) {
192
199
  });
193
200
  }
194
201
 
202
+ /**
203
+ * Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
204
+ * half of compile-time scoped styles, mirroring {@link enhanceImages}: a regex
205
+ * post-pass over assembled HTML, zero runtime JS. Every element start tag that
206
+ * doesn't already carry a scope attribute gets one, so the scoped stylesheet
207
+ * (whose selectors end in `[data-wd-scope="<id>"]`) matches inside this subtree
208
+ * and nowhere else. Void/self-closing tags are stamped too (the attribute is
209
+ * valid on any element). Closing tags, comments, doctype, and the `<!`/`<?`
210
+ * families are left alone. A tag that ALREADY has a `data-wd-scope` (e.g. a
211
+ * nested include with its own scope) keeps its own — first stamp wins.
212
+ * @param {string} html Subtree HTML to scope.
213
+ * @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
214
+ * @returns {string}
215
+ */
216
+ export function stampScope(html, scopeId) {
217
+ return html.replace(
218
+ /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g,
219
+ (tag, name, attrs, slash) => {
220
+ if (/\bdata-wd-scope\s*=/.test(attrs)) return tag;
221
+ return `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
222
+ }
223
+ );
224
+ }
225
+
195
226
  /**
196
227
  * Extract the `src` attribute value from an `<img>` tag, or "" if absent.
197
228
  * @param {string} tag
package/src/compiler.js CHANGED
@@ -22,5 +22,6 @@ export {
22
22
  enhanceImages,
23
23
  escapeHtml,
24
24
  loopKeyOf,
25
- parseFrontmatter
25
+ parseFrontmatter,
26
+ stampScope
26
27
  } from "./compiler/index.js";
package/src/skin.js CHANGED
@@ -1,3 +1,5 @@
1
+ import crypto from "node:crypto";
2
+
1
3
  const aliases = new Map([
2
4
  ["radius", "border-radius"],
3
5
  ["shadow", "box-shadow"],
@@ -19,6 +21,29 @@ function resolveProp(prop, rest) {
19
21
  return aliases.get(prop) || prop;
20
22
  }
21
23
 
24
+ /**
25
+ * Derive a short, deterministic scope id from a skin's PROJECT-RELATIVE PATH.
26
+ * Path-based (stable across content edits, unique per component file) and short
27
+ * enough to read in the emitted CSS (`wd-7c21`). The same path always yields the
28
+ * same id, so the CSS rewrite (builder) and the HTML stamp (page/include) agree
29
+ * without sharing state — they just hash the same relative path. The path is
30
+ * normalized to POSIX separators first so a build is identical on Windows.
31
+ * @param {string} relPath Skin path relative to the project root (cwd).
32
+ * @returns {string} The scope id, e.g. `wd-7c21`.
33
+ */
34
+ export function scopeIdFor(relPath) {
35
+ const posix = relPath.replaceAll("\\", "/");
36
+ const hash = crypto.createHash("sha256").update(posix).digest("hex");
37
+ return `wd-${hash.slice(0, 4)}`;
38
+ }
39
+
40
+ // Selectors that style the whole page, not a component subtree. In a `scoped`
41
+ // skin they are a mistake: scoping appends an attribute to the *subject* element,
42
+ // so `page`/`body`/`html`/`*`/`::selection` would either become a no-op (no
43
+ // element in the subtree carries the attribute on <body>/<html>) or silently
44
+ // fail to do what the author means. We error and point them at a global skin.
45
+ const PAGE_LEVEL_SELECTORS = new Set(["page", "*", "html", "body", "::selection"]);
46
+
22
47
  /**
23
48
  * A nesting frame in the indentation stack.
24
49
  * @typedef {object} SkinFrame
@@ -29,10 +54,25 @@ function resolveProp(prop, rest) {
29
54
 
30
55
  /**
31
56
  * Compile indentation-based `.skin` source into CSS.
57
+ *
58
+ * Scoping (opt-in): when `opts.scope` is set the skin is compiled in scoped
59
+ * mode — every selector RULE gets `[data-wd-scope="<id>"]` appended to the
60
+ * subject of each compound selector, so the stylesheet only ever matches inside
61
+ * the stamped subtree. Design tokens (`tokens` / `tokens dark` / `tokens [attr]`)
62
+ * stay GLOBAL on `:root` regardless, so `var(--x)` and dark mode keep working
63
+ * site-wide. A whole-selector `:global(.x)` escapes scoping. Page-level selectors
64
+ * (`page`/`*`/`html`/`body`/`::selection`) are a compile error in scoped mode.
65
+ * Without `opts.scope`, output is byte-identical to before this feature existed.
32
66
  * @param {string} source
67
+ * @param {{ scope?: string, subjects?: Set<string> }} [opts] `scope`: the scope
68
+ * id enabling scoped mode. `subjects`: an optional set the compiler fills with
69
+ * every scoped subject token (`.card`, `h2`, `#id`) for the unused-selector
70
+ * warning, so the caller need not re-parse the source.
33
71
  * @returns {string}
34
72
  */
35
- export function compileSkin(source) {
73
+ export function compileSkin(source, opts = {}) {
74
+ const scope = opts.scope;
75
+ const subjects = opts.subjects;
36
76
  const lines = source
37
77
  .replace(/\r\n?/g, "\n")
38
78
  .replace(/\/\*[\s\S]*?\*\//g, "") // strip /* … */ block comments (any span)
@@ -47,6 +87,19 @@ export function compileSkin(source) {
47
87
  })
48
88
  .map((raw) => ({ indent: (raw.match(/^\s*/)?.[0] ?? "").length, text: raw.trim() }));
49
89
 
90
+ // The opt-in marker: `scoped` must be the FIRST meaningful line (the filter
91
+ // above has already dropped comments, blanks, and dividers). Anywhere else it
92
+ // is an error — a `scoped` mid-file means the author misread the contract.
93
+ let start = 0;
94
+ if (scope && lines.length && lines[0].text === "scoped" && lines[0].indent === 0) {
95
+ start = 1;
96
+ }
97
+ for (let i = start; i < lines.length; i++) {
98
+ if (lines[i].text === "scoped" && lines[i].indent === 0) {
99
+ throw new Error('"scoped" must be the first line of a .skin file');
100
+ }
101
+ }
102
+
50
103
  /** @type {SkinFrame[]} */
51
104
  const stack = [];
52
105
  /** @type {string[]} */
@@ -65,7 +118,7 @@ export function compileSkin(source) {
65
118
  /** @type {Map<string, string[]>} */
66
119
  const attrVars = new Map();
67
120
 
68
- for (let i = 0; i < lines.length; i++) {
121
+ for (let i = start; i < lines.length; i++) {
69
122
  const { indent, text } = lines[i];
70
123
  // Structure decides meaning: a line opens a block (selector) exactly when
71
124
  // the next line is indented deeper. Everything else is a declaration.
@@ -119,17 +172,28 @@ export function compileSkin(source) {
119
172
  if (text.startsWith("@")) {
120
173
  stack.push({ indent, selector: parent ?? null, media: text });
121
174
  } else {
175
+ if (scope) guardPageLevel(text, parent);
122
176
  stack.push({ indent, selector: normalizeSelector(text, parent) });
123
177
  }
124
178
  continue;
125
179
  }
126
180
 
127
181
  const current = stack.at(-1)?.selector || ":root";
182
+ // A leaf with no enclosing selector lands on `:root` — a page-level write. In
183
+ // a scoped skin that is the same mistake `guardPageLevel` rejects (and the
184
+ // place a filtered bare `*`/`page` reset would otherwise leak through to), so
185
+ // it errors with the same hint instead of emitting a never-matching rule.
186
+ if (scope && current === ":root") {
187
+ throw new Error(
188
+ `page-level declaration "${text}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one`
189
+ );
190
+ }
128
191
  const media = stack.find((frame) => frame.media)?.media;
129
192
  const [prop, ...rest] = text.split(/\s+/);
130
193
  const cssProp = resolveProp(prop, rest);
131
194
  const value = rest.join(" ").replace(/\$([a-zA-Z0-9_-]+)/g, "var(--$1)");
132
- const rule = `${current} { ${cssProp}: ${value}; }`;
195
+ const subject = scope ? scopeSelector(current, scope, subjects) : current;
196
+ const rule = `${subject} { ${cssProp}: ${value}; }`;
133
197
  css.push(media ? `${media} { ${rule} }` : rule);
134
198
  }
135
199
 
@@ -153,6 +217,86 @@ export function compileSkin(source) {
153
217
  return css.join("\n");
154
218
  }
155
219
 
220
+ /**
221
+ * Reject page-level selectors in a scoped skin. Checks the AUTHORED selector
222
+ * text (before `page`→`body` rewrite) so `page` is caught by name. Each
223
+ * comma-separated part is inspected; a bare top-level selector (no parent) that
224
+ * is one of the page-level set, or a top-level `::selection`, errors with a hint
225
+ * pointing the author at a global skin.
226
+ * @param {string} text Raw selector line.
227
+ * @param {string | null | undefined} parent Enclosing selector, if nested.
228
+ * @returns {void}
229
+ */
230
+ function guardPageLevel(text, parent) {
231
+ // Only TOP-LEVEL selectors are page-level mistakes — `.card *` (a descendant
232
+ // wildcard inside a component) is fine, so the check applies when there is no
233
+ // parent selector chain (`:root` is the implicit root, treated as none).
234
+ if (parent && parent !== ":root") return;
235
+ for (const part of text.split(",")) {
236
+ const clean = part.trim();
237
+ if (PAGE_LEVEL_SELECTORS.has(clean)) {
238
+ throw new Error(
239
+ `page-level selector "${clean}" is not allowed in a scoped .skin — page-level styles belong in a global skin, not a scoped one`
240
+ );
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Append the scope attribute to a resolved selector list (post `&`-nesting).
247
+ * Each comma-separated compound selector gets `[data-wd-scope="<id>"]` on its
248
+ * SUBJECT (rightmost simple selector), inserted BEFORE any trailing
249
+ * pseudo-class/element so the result stays valid CSS (`.card[…]:hover`, never
250
+ * `.card:hover[…]`). A whole-selector `:global(.x)` unwraps to a plain `.x` with
251
+ * NO attribute — the documented escape hatch (whole-selector only this release).
252
+ * @param {string} selectorList Resolved selector list (may contain commas).
253
+ * @param {string} scope Scope id.
254
+ * @param {Set<string>} [subjects] Optional sink for subject tokens (warning).
255
+ * @returns {string}
256
+ */
257
+ function scopeSelector(selectorList, scope, subjects) {
258
+ return selectorList
259
+ .split(",")
260
+ .map((part) => {
261
+ const clean = part.trim();
262
+ // Whole-selector `:global(.toast)` → `.toast`, unscoped. Descendant
263
+ // `:global` (`.card :global(.x)`) is intentionally NOT supported yet.
264
+ const global = clean.match(/^:global\(\s*(.+?)\s*\)$/);
265
+ if (global) return global[1];
266
+ // The subject is the last whitespace-delimited compound. Combinators
267
+ // (`>`, `+`, `~`) stand as their own tokens, so the subject is whatever
268
+ // follows the final combinator/descendant gap.
269
+ const tokens = clean.split(/\s+/);
270
+ const subjectIndex = tokens.length - 1;
271
+ const lead = tokens.slice(0, subjectIndex);
272
+ const subject = tokens[subjectIndex];
273
+ // Split the subject into its base (tag/class/id, plus attribute selectors)
274
+ // and any trailing pseudo `:hover` / `::before`. The attribute slots in at
275
+ // the base/pseudo seam: `.card::before` → `.card[scope]::before`.
276
+ const seam = subject.search(/::?[a-zA-Z-]/);
277
+ const base = seam === -1 ? subject : subject.slice(0, seam);
278
+ const pseudo = seam === -1 ? "" : subject.slice(seam);
279
+ if (subjects) subjects.add(subjectBase(base));
280
+ const scoped = `${base}[data-wd-scope="${scope}"]${pseudo}`;
281
+ return [...lead, scoped].join(" ");
282
+ })
283
+ .join(", ");
284
+ }
285
+
286
+ /**
287
+ * Reduce a scoped subject base to the single token the unused-selector warning
288
+ * checks against the stamped subtree: the leading `.class`, `#id`, or bare tag,
289
+ * dropping any chained attribute selectors (`a[href]` → `a`). Returns `""` for a
290
+ * subject that has no checkable token (a bare attribute selector), which the
291
+ * caller treats as "always present" (never warns).
292
+ * @param {string} base
293
+ * @returns {string}
294
+ */
295
+ function subjectBase(base) {
296
+ const m = base.match(/^([.#]?[A-Za-z_][\w-]*)/);
297
+ return m ? m[1] : "";
298
+ }
299
+
156
300
  /**
157
301
  * Normalize a bracketed token modifier into a valid attribute selector,
158
302
  * quoting the value so `[data-theme=dark]` becomes `[data-theme="dark"]`.