Z User commited on
Commit
0ade7b1
Β·
1 Parent(s): cbd4423

v0.7.2: engine fixes (12 review findings), GUIDE.md, honest README/PARITY/llms, test-verified examples, rebuilt packages

Browse files
GUIDE.md ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DSSL Guide
2
+
3
+ Everything in the repo, in one file. Written for the person who just cloned this
4
+ and wants to know what exists, what works, and what to reach for. Every `.dssl`
5
+ example in this guide is compiled by `scripts/test-guide-examples.mjs` on every
6
+ release, so the examples cannot drift from the compiler. If an example here
7
+ stops compiling, that is a release blocker, not a docs bug.
8
+
9
+ Status up front: DSSL is a real, tested style engine at v0.7.2, and it is
10
+ honest alpha. It needs JavaScript, it writes inline styles, and it has a
11
+ first-paint story you should read before shipping anything serious (see
12
+ "Honest limits"). Within those limits, it is genuinely nicer to write than
13
+ hand-rolled CSS for creative pages, docs, playgrounds and internal tools.
14
+
15
+ ---
16
+
17
+ ## 1. What this repo is
18
+
19
+ | Path | What it is |
20
+ |---|---|
21
+ | `public/dssl/` | The engine. Six zero-dependency ESM files: `parser.js`, `resolver.js`, `engine.js`, `index.js` (public API), `utils.js`, `boot.js`. This is the whole language. |
22
+ | `public/styles/*.dssl` | The stylesheets that drive the docs site itself. The site is styled entirely by its own engine, which makes it the de-facto test suite. |
23
+ | `public/examples/hello.dssl` | The starter stylesheet. Small, commented, every interaction real. |
24
+ | `package/` + `public/install.sh`, `install.ps1` | The downloadable engine package: `curl -fsSL https://dssl-sable.vercel.app/install.sh \| sh`. |
25
+ | `public/dssl-engine.zip`, `.tar.gz` | Same package as plain archives. |
26
+ | `dssl-vscode/` | The editor extension (VS Code and VSCodium). Bundles the real parser and resolver, so editor squiggles are browser-console errors. See its own README and PUBLISHING.md. |
27
+ | `src/` | The Next.js docs site: playground, bench page, spec, changelog. |
28
+ | `scripts/` | Tests, package builds, the extension engine sync, deploy helpers. |
29
+ | `llms.txt`, `AGENTS.md`, `PARITY.md` | The machine-reviewer layer and contributor rules. PARITY.md is the honest CSS feature matrix. |
30
+ | `worklog.md` | The full build history, every decision logged. |
31
+
32
+ Version rule: the engine version in `public/dssl/index.js` is the source of
33
+ truth. The site badge, footer, changelog and READMEs follow it on every release.
34
+
35
+ ## 2. Quickstart
36
+
37
+ Two tags. No build step, no npm, nothing to compile:
38
+
39
+ ```html
40
+ <link rel="stylesheet/dssl" href="styles.dssl">
41
+ <script src="dssl/index.js" type="module"></script>
42
+ ```
43
+
44
+ Or embed DSSL directly in your HTML (this is the Tailwind-ish answer: the
45
+ language lives wherever you want it):
46
+
47
+ ```html
48
+ <script src="dssl/index.js" type="module"></script>
49
+ <style type="text/dssl">
50
+ .card {
51
+ background: #26201a
52
+ radius: 12
53
+ hover { background: #3a2f26 }
54
+ }
55
+ </style>
56
+ ```
57
+
58
+ `<style type="text/dssl">` and `<script type="text/dssl">` both work; the
59
+ engine's auto-boot picks them up in document order, after any linked files.
60
+ You can use as many blocks as you like.
61
+
62
+ A real starter stylesheet with comments lives at
63
+ `public/examples/hello.dssl`.
64
+
65
+ ## 3. The language, fast
66
+
67
+ ### Bare numbers are px
68
+
69
+ `padding: 16` is 16px. Units still work when you want them: `width: 50%`,
70
+ `font-size: 1.2rem`, `margin: 0 auto`.
71
+
72
+ ### Tokens
73
+
74
+ ```dssl
75
+ tokens {
76
+ color {
77
+ ink: #16161d
78
+ accent: oklch(62% 0.19 35)
79
+ }
80
+ space {
81
+ 1: 4
82
+ 2: 8
83
+ 4: 16
84
+ } // numeric keys are legal
85
+ radius { md: 14 }
86
+ shadow { md: 0 2 4 color.ink/10, 0 4 12 color.ink/8 }
87
+ }
88
+
89
+ .card {
90
+ background: color.ink
91
+ padding: space.4
92
+ radius: md
93
+ shadow: md
94
+ }
95
+ ```
96
+
97
+ Alpha on any color: `color.ink/50`, `#ff0000/50` (compiles to color-mix).
98
+ Token overrides can be scoped under `@media` or `@signal` for themes.
99
+
100
+ ### Layout is words
101
+
102
+ ```dssl
103
+ .toolbar { row: gap 8 between center }
104
+ .sidebar { column: gap 16 }
105
+ .board { grid: 3 gap 12 } // 3 columns, 1fr each, gap 12
106
+ .board { grid: auto-fill 220 } // responsive columns
107
+ .gallery { grid: 2 rows 3 gap 8 } // rows N is a word too
108
+ .modal { stack: center } // grid place-items center
109
+ .hero { center: both }
110
+ ```
111
+
112
+ ### The cascade is (layer, order)
113
+
114
+ No specificity, no `!important`. Later wins inside a layer. Layers sort
115
+ `tokens < base < components < utilities < pin`. Nested blocks do what the
116
+ indentation says they do (fixed in v0.7.2, it used to be the engine's worst
117
+ bug):
118
+
119
+ ```dssl
120
+ .card {
121
+ background: #26201a
122
+ shadow: sm
123
+ hover { background: #3a2f26; shadow: lg } // beats the base now, by order
124
+
125
+ @media dark { background: #181310 } // condition is kept, sorts after base
126
+
127
+ @layer utilities { margin-top: 0 }
128
+ }
129
+ ```
130
+
131
+ Declarations after a nested block keep working too: they become a later
132
+ fragment of the same rule, and later still wins.
133
+
134
+ ### States
135
+
136
+ All the usual suspects ride native matching: `hover`, `focus`,
137
+ `focus-visible`, `active`, `disabled`, `checked`, `target`, `open` (details),
138
+ and structural ones via selectors. Two engine details worth knowing:
139
+
140
+ - `checked` and `open` listen to real events (`change`, `toggle`), so
141
+ keyboard toggles restyle immediately. For programmatic control, set the
142
+ property and dispatch a bubbling change event:
143
+ `el.checked = true; el.dispatchEvent(new Event('change', { bubbles: true }))`.
144
+ - Attribute changes (`disabled` and friends) are picked up by the engine's
145
+ MutationObserver automatically.
146
+
147
+ ### Conditions
148
+
149
+ ```dssl
150
+ .card {
151
+ @media dark { background: #181310 } // one-word sugar
152
+ @media 768 { padding: 24 } // bare width = min-width
153
+ @media <= 480 { padding: 12 }
154
+ @container (min-width: 400px) { column: gap 4 }
155
+ @signal theme==dark { border-color: color.accent }
156
+ }
157
+ ```
158
+
159
+ `@media reduced-motion` is honored by every motion unless the motion declares
160
+ `essential`.
161
+
162
+ ### Motion and seen
163
+
164
+ ```dssl
165
+ motion rise-in {
166
+ from { opacity: 0; translate: 0 18 }
167
+ }
168
+
169
+ motion lift {
170
+ from { translate: 0 2 }
171
+ }
172
+
173
+ .gallery { stagger: 60 }
174
+
175
+ .card {
176
+ seen { animate: rise-in 480 snap } // plays once, when scrolled into view
177
+ hover { animate: lift 200 } // WAAPI, reduced-motion aware
178
+ }
179
+ ```
180
+
181
+ The arrow `->` is motion/gradient syntax (keyframes and gradient stops). It
182
+ does nothing inside a `seen { }` block, and the compiler now says so loudly.
183
+
184
+ ### Behaviors, deform, overlap
185
+
186
+ `use` shares named blocks (the site's `focus-ring` is one). `bend`, `fold`,
187
+ `stretch` deform on scroll. `overlap:` declares allowed overlap policies.
188
+ The full vocabulary lives in the site's Docs and Cheat sheet views.
189
+
190
+ ## 4. The engine API
191
+
192
+ ```js
193
+ import { DSSL } from '/dssl/index.js'
194
+
195
+ DSSL.version() // "0.7.2"
196
+ DSSL.compile(source) // { ok, errors, warnings, stats, doc }
197
+ DSSL.apply(source, { name, scope, replace })
198
+ await DSSL.load(['a.dssl', 'b.dssl'])
199
+ DSSL.signal('theme', 'dark') // drives @signal rules
200
+ DSSL.refresh() // full recompute
201
+ DSSL.freeze() / DSSL.unfreeze() // static snapshot: ambient restyling off
202
+ DSSL.explain(el) // which rules matched, what won, why
203
+ DSSL.errors() // last compile errors
204
+ ```
205
+
206
+ `freeze()` is the big performance lever for static pages: the engine drops its
207
+ observers and listeners, keeps the resolved styles, and the page stops paying
208
+ for liveness. `unfreeze()` re-arms everything.
209
+
210
+ ## 5. The editor extension
211
+
212
+ Three ways to get it:
213
+
214
+ 1. Download `dssl-vscode/dssl-vscode-0.7.1.vsix` from the repo files, then
215
+ Extensions panel, `...`, Install from VSIX. Works in VS Code and VSCodium.
216
+ 2. Build it: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
217
+ 3. Registry listing: staged for Open VSX (the registry VSCodium searches).
218
+ See `dssl-vscode/PUBLISHING.md` for exactly what remains.
219
+
220
+ What it does: real-compiler diagnostics (same errors the browser prints,
221
+ did-you-mean included, line numbers accurate since the v0.7.1 parser fix),
222
+ completions folded from your workspace token registry, hover docs, a TextMate
223
+ grammar for `.dssl`.
224
+
225
+ ## 6. Tooling and tests
226
+
227
+ ```sh
228
+ node scripts/test-v072.mjs # latest regression suite (40 assertions)
229
+ node scripts/test-v071.mjs # parser line-number suite
230
+ node scripts/test-v070.mjs # v0.7 feature suite (60)
231
+ node scripts/test-v050.mjs # v0.5 feature suite (61)
232
+ node scripts/dssl-smoke.mjs # end-to-end compile of the smoke stylesheet
233
+ node scripts/dssl-styles-check.mjs # all six site stylesheets compile
234
+ node scripts/sync-extension-engine.mjs # run after ANY engine change
235
+ bash scripts/build-engine-package.sh # rebuild the zip/tar packages
236
+ ```
237
+
238
+ House rule from AGENTS.md: if you touch `public/dssl/`, run the suites, then
239
+ run the sync script so the editor extension ships the same compiler.
240
+
241
+ ## 7. Honest limits
242
+
243
+ Read this before choosing DSSL for a real site.
244
+
245
+ - **JavaScript is required.** No JS, no styles. The engine ships no CSS
246
+ fallback because there is no CSS. If a page must work without JS, use CSS.
247
+ - **First paint.** Styles are applied by the engine after it loads, so large
248
+ pages can flash unstyled content (CLS). The docs site solves this by
249
+ inlining its stylesheets into the SSR HTML. The planned prerender mode
250
+ (server renders the final inline styles) is the real fix and is on the
251
+ PARITY roadmap as the next big item.
252
+ - **CSP.** Pseudo-elements compile to one real stylesheet the engine injects;
253
+ strict `style-src 'self'` blocks inline style attributes and that sheet.
254
+ DSSL wants `unsafe-inline` for styles today.
255
+ - **Interop is invasive.** DSSL normal declarations beat external CSS, and
256
+ `pin` beats external `!important`. Mixing with an existing CSS design
257
+ system works but the engine wins by design.
258
+ - **Large interactive pages** pay a real per-recompute cost. `freeze()`
259
+ makes static pages viable; live hover on thousands of elements is bounded
260
+ since v0.7.2 but is still not free.
261
+ - **Not a CSS replacement.** It is a style language that ends in inline
262
+ styles. When it is the better tool, it is because of the authoring
263
+ experience: tokens, words-as-layout, states and motion in one grammar,
264
+ loud errors, zero build.
265
+
266
+ ## 8. FAQ
267
+
268
+ **Can I write DSSL in my HTML?** Yes, that is section 2. `<style
269
+ type="text/dssl">` blocks, or `<script type="text/dssl">` if you prefer.
270
+
271
+ **Where do docs examples get tested?** `scripts/test-guide-examples.mjs`
272
+ compiles every `.dssl` block in this guide and in the README. Green or the
273
+ release does not ship.
274
+
275
+ **Why does my nested hover not beat the base rule?** It does, since v0.7.2.
276
+ If you are on 0.7.1 or older, that was the cascade bug, upgrade.
277
+
278
+ **Is there a DSSL for something other than web styling?** Not yet. The
279
+ parser and resolver are domain-agnostic by design, so a domain-specific
280
+ DSSL2 is a plausible later project, not a current one.
281
+
282
+ **Who builds this?** One person plus AI agents, in the open. `worklog.md`
283
+ is the complete receipt trail.
PARITY.md CHANGED
@@ -48,3 +48,29 @@ no other replacement styles with the platform's own primitives.
48
  - the overlap policy: translucent boxes refuse to cover things unless you say so
49
  - loud errors with did-you-mean, and a warnings channel for cascade-order footguns
50
  - `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  - the overlap policy: translucent boxes refuse to cover things unless you say so
49
  - loud errors with did-you-mean, and a warnings channel for cascade-order footguns
50
  - `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
51
+
52
+ ## v0.7.2 review-driven corrections
53
+
54
+ An external agent review reproduced a batch of real bugs; all of them are fixed
55
+ and regression-tested (scripts/test-v072.mjs). Rows affected:
56
+
57
+ - Nested state blocks (hover/focus/... inside a rule) now sort AFTER the parent
58
+ rule's own declarations, so nested overrides win the cascade. Nested @media,
59
+ @signal and @layer blocks also keep their condition and layer (they used to
60
+ be dropped, making nested dark mode silently dead).
61
+ - Declarations and a nested block can share one line (`.card { bg: red; hover { } }`)
62
+ without the declaration being swallowed.
63
+ - Raw hex alpha is real: `#ff0000/50` compiles to color-mix (was invalid /50px).
64
+ - Time properties take bare numbers as ms (`transition-delay: 200` -> 200ms).
65
+ - `grid: 3 gap 12` works; `rows N` joined the shorthand.
66
+ - `seen { ... }` rejects motion-arrow syntax loudly and points at the real forms.
67
+ - SVG and modern properties added: fill, stroke (and friends), text-wrap,
68
+ content-visibility.
69
+ - `::part()` rides the shadow pipeline; `::slotted()` is rejected with an
70
+ explanation (it only exists inside shadow trees).
71
+ - The gate prefilter now actually runs before native matches().
72
+ - State events scope candidate recomputation to the hovered subtree and its
73
+ ancestors instead of the whole page (a 1k-card hover went from ~4,100
74
+ recomputes per event to the touched chain).
75
+ - checkbox/details states hear `change` and `toggle` events, so keyboard
76
+ toggles and programmatic dispatch restyle immediately.
README.md CHANGED
@@ -4,7 +4,7 @@ license: mit
4
 
5
  # DSSL
6
 
7
- DSSL is a style language for the web. It replaces CSS.
8
 
9
  Not a preprocessor that spits out stylesheets. Not a utility class graveyard. You write `.dssl` files, the page loads a small engine, and the engine walks your DOM in real time and applies every rule it matches straight onto the elements it finds. Nothing is compiled. There is no build step for styles.
10
 
@@ -134,7 +134,7 @@ tokens {
134
  accent: oklch(62% 0.19 35)
135
  }
136
  radius { md: 14 }
137
- shadow { md: 0 2 4 ink/10, 0 4 12 ink/8 }
138
  space { 9: 96 } // numeric keys are legal (v0.7)
139
  }
140
 
@@ -277,9 +277,14 @@ motion pulse {
277
 
278
  .pt-card {
279
  seen {
280
- opacity: 0 -> 1
281
- translate: 0 18 -> 0
282
- animate: rise 600 ease-out
 
 
 
 
 
283
  }
284
  }
285
  ```
 
4
 
5
  # DSSL
6
 
7
+ DSSL is a style language for the web. For the pages it fits, it replaces writing CSS entirely. It does not replace the CSS platform: the engine ends in inline styles that the browser parses, and it needs JavaScript. Where it wins is the authoring experience: tokens, words-as-layout, states and motion in one grammar, loud errors, zero build. PARITY.md keeps the honest feature ledger.
8
 
9
  Not a preprocessor that spits out stylesheets. Not a utility class graveyard. You write `.dssl` files, the page loads a small engine, and the engine walks your DOM in real time and applies every rule it matches straight onto the elements it finds. Nothing is compiled. There is no build step for styles.
10
 
 
134
  accent: oklch(62% 0.19 35)
135
  }
136
  radius { md: 14 }
137
+ shadow { md: 0 2 4 color.ink/10, 0 4 12 color.ink/8 }
138
  space { 9: 96 } // numeric keys are legal (v0.7)
139
  }
140
 
 
277
 
278
  .pt-card {
279
  seen {
280
+ animate: rise 600 ease-out // the motion below owns the from-state
281
+ }
282
+ }
283
+
284
+ motion rise {
285
+ from {
286
+ opacity: 0
287
+ translate: 0 18
288
  }
289
  }
290
  ```
llms.txt ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DSSL
2
+
3
+ > DSSL is a style language for the web. For JS-guaranteed pages it replaces writing CSS; it does not replace the CSS platform (the engine ends in inline styles the browser parses). You write `.dssl` files, the page loads a ~20 kB engine, and the engine walks the DOM in real time applying every matched rule as inline styles. Nothing compiles to CSS. Elements added later are styled the moment they appear; signals, states and motion share one grammar.
4
+
5
+ - Site (docs, playground, changelog, bench): https://dssl-sable.vercel.app
6
+ - Repo + full docs in the README: https://huggingface.co/EHS-Studio/dssl
7
+ - Install: `curl -fsSL https://dssl-sable.vercel.app/install.sh | sh` (or the zip: https://dssl-sable.vercel.app/dssl-engine.zip)
8
+ - Version: 0.7.2 (engine), MIT license
9
+ - Feature matrix vs CSS: see PARITY.md in the repo
10
+ - For agents working ON the codebase: see AGENTS.md in the repo
11
+
12
+ ## The five things reviewers ask about
13
+
14
+ 1. No build step. Styles are source files linked with `<link rel="stylesheet/dssl">` or embedded in the page as `<style type="text/dssl">` / `<script type="text/dssl">` blocks (auto-booted in document order). The engine restyles the live DOM, including nodes added later.
15
+ 2. Honest trade: it is a JS runtime. If JavaScript is unavailable the page renders unstyled. For content pages that must not happen, use prerendering (snapshot the engine's first pass to static HTML; planned, tracked in PARITY.md) or choose another tool. The engine's own docs say this plainly.
16
+ 3. Exit cost is zero. Abandoning DSSL leaves you a page of valid inline styles and one small shadow stylesheet for pseudo-elements. There is nothing to un-install from your markup.
17
+ 4. Speed has a handbrake. `DSSL.freeze()` detaches every observer and listener after first paint and leaves the resolved look at zero engine cost; `unfreeze()` re-arms. A gate prefilter skips obvious non-matches before native matching. The Bench page on the site measures 100 / 1,000 / 10,000 nodes on your machine: CSS `<style>` vs DSSL live vs DSSL frozen.
18
+ 5. The cascade is (layer, source order). No specificity, no !important. Nested state blocks sort after the parent rule's own declarations (v0.7.2 fix: they used to lose), and nested `@media`/`@signal`/`@layer` keep their condition. `pin { }` is the only must-win block. The compiler refuses to guess: unknown tokens, properties and values fail loudly with did-you-mean; `--*` custom properties pass through unvalidated on purpose.
19
+
20
+ ## Language surface (v0.7.2)
21
+
22
+ - Selectors: the full CSS surface rides native matching, including `:nth-child()`, `:not()`, `:is()`, `:where()`, `:has()`, combinators, attributes, nesting with `&`.
23
+ - States as nested blocks: `hover`, `focus`, `focus-visible`, `active`, `disabled`, `checked`, `empty`, `target`, plus `seen` (fires once on first viewport entry, composes with `stagger:`).
24
+ - Tokens built in: `color.*`, `space.1-8`, `radius.*`, `shadow.*`, `font.*`; custom `tokens { }` blocks with numeric keys (`space { 9: 96 }`), alpha suffixes (`color.ink/85`), per-subtree re-declaration (`--color.accent: color.err`).
25
+ - Layout sugar: `row: gap space.4 between center`, `column:`, `grid: 3 gap 12`, `stack: center`, `center: both` (promotes a formatting context so alignment can never silently no-op), `size: 40 20`.
26
+ - Pseudo-elements (`::before`, `::after`, `::placeholder`, `::selection`, `::marker`, `::backdrop`, `::first-line`, `::first-letter`, vendor ones) compile to one real shadow stylesheet; unknown pseudo names are compile errors.
27
+ - Conditions: `@media` (with `dark`, `light`, `reduced-motion` sugar and `<=` ranges), `@container`, `@signal menuOpen { }` bridged from `DSSL.signal()`, `@layer base|components|utilities`, `pin { }`.
28
+ - Motion: `motion name { from { } to { } }` via WAAPI; `animate: pulse 1600 infinite alternate`; `stagger: 70`; reduced-motion respected unless a motion declares `essential`.
29
+ - Deform family: `bend`, `bend-y`, `bend-up`, `fold`, `stretch` (volume-honest squash), `bend-around` (children ride an arc). Compose into the one transform channel, state- and transition-friendly.
30
+ - Gradients as sentences: `gradient: linear 135, color.accent -> color.ok`, `gradient-text:`, `gradient-flow: 6s alternate`. Non-color stops are compile errors.
31
+ - Overlap policy: nothing below 100% opacity overlaps anything unless `overlap: allow`; `deny` is unconditional; pairs with `pointer-events: none` so veils stop eating clicks and text selection.
32
+ - Behaviors: named rule groups applied with `use focus-ring`.
33
+ - v0.7.2 review-driven fixes, all regression-tested: gate prefilter runs before native matches(); hover state events scope candidate recomputation to the touched subtree plus ancestors (was page-wide); checkbox/details states listen to `change`/`toggle`; raw hex alpha (`#ff0000/50` -> color-mix); bare numbers on time properties are ms; `::part()` supported, `::slotted()` rejected with an explanation; SVG properties (`fill`, `stroke*`) and `text-wrap`/`content-visibility` in the vocabulary.
34
+ - Warnings channel (v0.7): things that compile but are probably wrong, e.g. a layout shorthand expanding to `display` and clobbering an earlier explicit `display:` in the same rule.
35
+
36
+ ## JS API
37
+
38
+ ```js
39
+ DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
40
+ DSSL.apply(src, { scope, name }) // scoped engines for widgets
41
+ DSSL.compile(src) // parse + resolve, no DOM (powers editors)
42
+ DSSL.signal('menuOpen', true) // the JS -> styles bridge
43
+ DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
44
+ DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
45
+ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
46
+ ```
47
+
48
+ Wait for the `dssl-ready` event on `window` before measuring layout: the engine styles the DOM after DOMContentLoaded, and scripts that measure earlier read a half-styled world.
49
+
50
+ ## Editor support
51
+
52
+ The official extension (VS Code and VSCodium, same .vsix) bundles the real parser and resolver: diagnostics identical to the browser console, completions from the workspace token registry, hover docs for the semantic vocabulary. Source lives in `dssl-vscode/` in the repo.
public/dssl-engine.zip CHANGED
Binary files a/public/dssl-engine.zip and b/public/dssl-engine.zip differ
 
public/dssl/engine.js CHANGED
@@ -228,6 +228,25 @@ export class DsslEngine {
228
  on('pointerdown', (e) => this.stateEvent('active', e.target));
229
  on('pointerup', (e) => this.stateEvent('active', e.target));
230
  on('pointercancel', (e) => this.stateEvent('active', e.target));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  // --- DOM mutations ---
233
  const mo = new MutationObserver((muts) => {
@@ -368,19 +387,56 @@ export class DsslEngine {
368
  node = node.parentElement;
369
  }
370
 
 
 
 
 
 
 
 
 
 
 
371
  for (const rule of this.rules) {
372
  const hit = rule.states.has(state) || (state === 'focus' && rule.states.has('focus-visible')) || (state === 'focus-visible' && rule.states.has('focus'));
373
  if (!hit) continue;
374
- // v0.3 fix: query the state-less base selector. On pointerout the
375
- // element that just LEFT hover no longer matches `:hover` β€” querying
376
- // the stateful selector skipped it and the hover styles stuck forever.
377
- // The base selector is a superset; computeElement re-evaluates the
378
- // real (stateful) match, so both entering and leaving are handled.
379
- const base = rule.sel.replace(STATE_PSEUDO_RE, '').trim() || '*';
380
- let els;
381
- try { els = (this.scope || document).querySelectorAll(base); }
382
- catch { try { els = (this.scope || document).querySelectorAll(rule.sel); } catch { continue; } }
383
- els.forEach(el => batch.add(el));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  }
385
  if (batch.size) this.schedule(batch);
386
  }
@@ -489,6 +545,14 @@ export class DsslEngine {
489
  if (rule.sel === ':root') {
490
  if (el !== rootEl) continue;
491
  } else {
 
 
 
 
 
 
 
 
492
  let sel = rule.sel;
493
  if (rule.states.has('seen')) {
494
  // v0.4.0 β€” `seen` belongs to the engine, not to CSS selectors: until
@@ -499,16 +563,6 @@ export class DsslEngine {
499
  }
500
  if (!this.matches(el, sel)) continue;
501
  }
502
- // v0.7.0 β€” gate prefilter: skip the native matches() call when the
503
- // element obviously cannot match (missing class/tag/id). gateFor is
504
- // conservative β€” it only ever says NO, and bails to null on anything
505
- // it does not fully understand, so a missed optimization beats a
506
- // missed match. This is the cheap half of the speed story; freeze()
507
- // is the big half.
508
- {
509
- const gate = rule._gate !== undefined ? rule._gate : (rule._gate = gateFor(rule.sel));
510
- if (gate && !gatePass(el, gate)) continue;
511
- }
512
  if (!rule.media.every(q => this.mql(q).matches)) continue;
513
  if (!this.signalsPass(rule)) continue;
514
  if (!this.containerPass(el, rule)) continue;
@@ -1422,6 +1476,50 @@ export function gateFor(sel) {
1422
  }
1423
 
1424
  /* Duck-typed on purpose: tests drive it with fake elements. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1425
  export function gatePass(el, gate) {
1426
  if (!gate) return true;
1427
  for (const g of gate) {
 
228
  on('pointerdown', (e) => this.stateEvent('active', e.target));
229
  on('pointerup', (e) => this.stateEvent('active', e.target));
230
  on('pointercancel', (e) => this.stateEvent('active', e.target));
231
+ // v0.7.2 β€” form-state events. checked/indeterminate live on properties
232
+ // the pointer and focus listeners never see: unchecking with Space went
233
+ // stale, and programmatic el.checked = true never restyled until focus
234
+ // moved. change bubbles, so delegation reaches it. toggle does NOT
235
+ // bubble β€” a capture-phase listener on document still catches it on the
236
+ // way down. Note for docs: programmatic property writes fire no event at
237
+ // all; dispatch a bubbling change event after setting .checked.
238
+ on('change', (e) => {
239
+ const t = e.target;
240
+ if (!t || t.nodeType !== 1) return;
241
+ this.stateEvent('checked', t);
242
+ this.stateEvent('disabled', t);
243
+ this.stateEvent('indeterminate', t);
244
+ });
245
+ {
246
+ const tgl = (e) => { const t = e.target; if (t && t.nodeType === 1) this.stateEvent('open', t); };
247
+ document.addEventListener('toggle', tgl, true);
248
+ this._listeners.push({ target: document, type: 'toggle', fn: tgl, opts: true });
249
+ }
250
 
251
  // --- DOM mutations ---
252
  const mo = new MutationObserver((muts) => {
 
387
  node = node.parentElement;
388
  }
389
 
390
+ // v0.7.2 β€” candidate collection is no longer page-wide. The external
391
+ // review measured 33,060 recomputes for hovering 8 cards in a 1k-card
392
+ // page: every state event queried the whole document for EVERY stateful
393
+ // rule and batched every match. Reality: a subject-state rule (.card:hover)
394
+ // can only ever change the chain collected above. Rules where the state
395
+ // sits on an ANCESTOR compound (.card:hover .title) reach further down β€”
396
+ // queried from the matching ancestor via :scope, so the cost is bounded
397
+ // by that ancestor's subtree, never the page. computeElement re-checks
398
+ // the full stateful selector, so a superset batch is only wasted work,
399
+ // never wrong output.
400
  for (const rule of this.rules) {
401
  const hit = rule.states.has(state) || (state === 'focus' && rule.states.has('focus-visible')) || (state === 'focus-visible' && rule.states.has('focus'));
402
  if (!hit) continue;
403
+ const reach = rule._reach !== undefined ? rule._reach : (rule._reach = stateReach(rule.sel, state));
404
+ if (reach === null) continue; // subject-state: the chain covers it
405
+ if (reach.fallback) {
406
+ // :has()/:not() wrappers defeat the cheap strip β€” keep the old
407
+ // page-wide superset for this rare shape
408
+ const base = rule.sel.replace(STATE_PSEUDO_RE, '').trim() || '*';
409
+ let els; try { els = (this.scope || document).querySelectorAll(base); } catch { continue; }
410
+ els.forEach(el => batch.add(el));
411
+ continue;
412
+ }
413
+ for (const anc of batch) {
414
+ let prefixOK;
415
+ try { prefixOK = anc.matches(reach.prefix); } catch { prefixOK = false; }
416
+ if (!prefixOK) continue;
417
+ if (reach.sib) {
418
+ // sibling tails: querySelectorAll cannot leave the subtree, so walk
419
+ // the siblings by hand (+ stops at the first; ~ keeps scanning)
420
+ let sib = anc.nextElementSibling;
421
+ while (sib) {
422
+ let hitSib = false;
423
+ try { hitSib = sib.matches(reach.firstSeg); } catch { hitSib = false; }
424
+ if (hitSib) {
425
+ batch.add(sib);
426
+ if (reach.restAfter) {
427
+ let sub; try { sub = sib.querySelectorAll(reach.restAfter); } catch { sub = null; }
428
+ if (sub) sub.forEach(x => batch.add(x));
429
+ }
430
+ }
431
+ if (reach.sib === '+') break;
432
+ sib = sib.nextElementSibling;
433
+ }
434
+ continue;
435
+ }
436
+ let els;
437
+ try { els = anc.querySelectorAll(':scope ' + reach.rest); } catch { continue; }
438
+ els.forEach(el => batch.add(el));
439
+ }
440
  }
441
  if (batch.size) this.schedule(batch);
442
  }
 
545
  if (rule.sel === ':root') {
546
  if (el !== rootEl) continue;
547
  } else {
548
+ // v0.7.2 β€” the gate now runs BEFORE native matches(). The external
549
+ // review caught the wrong order: every element paid a full el.matches()
550
+ // for every rule even when the cheap gate could say no. Same contract
551
+ // as v0.7.0: the gate only ever says NO, and bails to null on anything
552
+ // it does not fully understand β€” a missed optimization beats a missed
553
+ // match.
554
+ const gate = rule._gate !== undefined ? rule._gate : (rule._gate = gateFor(rule.sel));
555
+ if (gate && !gatePass(el, gate)) continue;
556
  let sel = rule.sel;
557
  if (rule.states.has('seen')) {
558
  // v0.4.0 β€” `seen` belongs to the engine, not to CSS selectors: until
 
563
  }
564
  if (!this.matches(el, sel)) continue;
565
  }
 
 
 
 
 
 
 
 
 
 
566
  if (!rule.media.every(q => this.mql(q).matches)) continue;
567
  if (!this.signalsPass(rule)) continue;
568
  if (!this.containerPass(el, rule)) continue;
 
1476
  }
1477
 
1478
  /* Duck-typed on purpose: tests drive it with fake elements. */
1479
+ /* v0.7.2 β€” stateReach: where can a state change reach in the DOM?
1480
+ * Pure and exported for tests. Given a stateful selector and the state that
1481
+ * just flipped on some element, it answers:
1482
+ * null β†’ subject-state (state on the rightmost compound): only
1483
+ * the event chain can change, no query needed at all
1484
+ * { fallback:true } β†’ the cheap strip cannot handle it (:has()/:not()
1485
+ * wrappers): caller keeps the page-wide superset
1486
+ * { prefix, rest } β†’ batch every chain element matching `prefix` (the part
1487
+ * up to and including the stateful compound, states
1488
+ * stripped) and query `:scope` + `rest` under each
1489
+ * { prefix, sib, firstSeg, restAfter } β†’ the tail starts with a sibling
1490
+ * combinator; walk that element's siblings manually
1491
+ * (querySelectorAll cannot leave the subtree)
1492
+ */
1493
+ export function stateReach(sel, state) {
1494
+ const segs = [];
1495
+ let depth = 0, cur = '', pendingComb = '';
1496
+ for (let i = 0; i < sel.length; i++) {
1497
+ const c = sel[i];
1498
+ if (c === '(' || c === '[') depth++;
1499
+ else if (c === ')' || c === ']') depth = Math.max(0, depth - 1);
1500
+ if (depth === 0 && (c === '>' || c === '+' || c === '~' || /\s/.test(c))) {
1501
+ const comb = c === ' ' ? ' ' : c;
1502
+ if (cur.trim()) { segs.push({ comb: pendingComb || ' ', seg: cur.trim() }); cur = ''; pendingComb = comb; }
1503
+ else if (comb !== ' ') pendingComb = comb;
1504
+ continue;
1505
+ }
1506
+ cur += c;
1507
+ }
1508
+ if (cur.trim()) segs.push({ comb: pendingComb || ' ', seg: cur.trim() });
1509
+ if (segs.length <= 1) return null;
1510
+ const idx = segs.findIndex(s => new RegExp(':' + state + '\\b').test(s.seg));
1511
+ if (idx === -1 || idx === segs.length - 1) return null;
1512
+ const prefix = segs.slice(0, idx + 1).map(s => s.seg).join(' ').replace(STATE_PSEUDO_RE, '').trim();
1513
+ if (!prefix || /\(\s*\)/.test(prefix)) return { fallback: true };
1514
+ const tail = segs.slice(idx + 1);
1515
+ if (tail[0].comb === '+' || tail[0].comb === '~') {
1516
+ const restAfter = tail.slice(1).map(s => (s.comb === ' ' ? ' ' : ' ' + s.comb + ' ') + s.seg).join('').trim();
1517
+ return { prefix, sib: tail[0].comb, firstSeg: tail[0].seg, restAfter };
1518
+ }
1519
+ const rest = tail.map(s => (s.comb === ' ' ? ' ' : ' ' + s.comb + ' ') + s.seg).join('').trim();
1520
+ return { prefix, rest };
1521
+ }
1522
+
1523
  export function gatePass(el, gate) {
1524
  if (!gate) return true;
1525
  for (const g of gate) {
public/dssl/index.js CHANGED
@@ -18,7 +18,7 @@ import { parse } from './parser.js';
18
  import { resolve, DEFAULT_TOKENS } from './resolver.js';
19
  import { DsslEngine } from './engine.js';
20
 
21
- export const VERSION = '0.7.1';
22
 
23
  let counter = 0;
24
  const engines = new Map();
@@ -205,7 +205,9 @@ if (typeof window !== 'undefined') {
205
  const boot = async () => {
206
  try {
207
  const links = [...document.querySelectorAll('link[rel="stylesheet/dssl"]')];
208
- const inlines = [...document.querySelectorAll('style[type="text/dssl"]')];
 
 
209
 
210
  const sources = [];
211
  for (const l of links) sources.push({ name: l.getAttribute('href'), kind: 'link', el: l });
 
18
  import { resolve, DEFAULT_TOKENS } from './resolver.js';
19
  import { DsslEngine } from './engine.js';
20
 
21
+ export const VERSION = '0.7.2';
22
 
23
  let counter = 0;
24
  const engines = new Map();
 
205
  const boot = async () => {
206
  try {
207
  const links = [...document.querySelectorAll('link[rel="stylesheet/dssl"]')];
208
+ // v0.7.2 β€” inline DSSL blocks also work as <script type="text/dssl">
209
+ // (some editors highlight/parse those better than an unknown style type)
210
+ const inlines = [...document.querySelectorAll('style[type="text/dssl"], script[type="text/dssl"]')];
211
 
212
  const sources = [];
213
  for (const l of links) sources.push({ name: l.getAttribute('href'), kind: 'link', el: l });
public/dssl/parser.js CHANGED
@@ -12,7 +12,7 @@
12
  * resolution happens in resolver.js.
13
  * ========================================================================= */
14
 
15
- const STATE_WORDS = new Set([
16
  'hover', 'focus', 'focus-visible', 'active', 'disabled', 'checked',
17
  'empty', 'target', 'required', 'invalid', 'valid', 'open', 'closed',
18
  'placeholder-shown', 'read-only', 'read-write', 'enabled',
@@ -28,6 +28,11 @@ const STATE_WORDS = new Set([
28
  export const PSEUDO_ELEMENTS = new Set([
29
  'before', 'after', 'placeholder', 'selection', 'marker',
30
  'first-line', 'first-letter', 'backdrop', 'file-selector-button',
 
 
 
 
 
31
  ]);
32
 
33
  // v0.4.2 β€” vendor pseudo-elements pass through verbatim (::-webkit-scrollbar
@@ -50,6 +55,20 @@ function scanFor(str, ch, from = 0) {
50
  return -1;
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  /** split a string on a top-level separator */
54
  function splitTop(str, sep) {
55
  const out = [];
@@ -158,6 +177,26 @@ export function parse(source, file = 'inline.dssl') {
158
 
159
  if (open !== -1) {
160
  const header = rest.slice(0, open).trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  if (!header) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
162
  const meta = classify(header, line);
163
  const node = { ...meta, line, children: [] };
 
12
  * resolution happens in resolver.js.
13
  * ========================================================================= */
14
 
15
+ export const STATE_WORDS = new Set([
16
  'hover', 'focus', 'focus-visible', 'active', 'disabled', 'checked',
17
  'empty', 'target', 'required', 'invalid', 'valid', 'open', 'closed',
18
  'placeholder-shown', 'read-only', 'read-write', 'enabled',
 
28
  export const PSEUDO_ELEMENTS = new Set([
29
  'before', 'after', 'placeholder', 'selection', 'marker',
30
  'first-line', 'first-letter', 'backdrop', 'file-selector-button',
31
+ // v0.7.2 β€” ::part() rides the shadow-CSS pipeline (a document stylesheet
32
+ // CAN style shadow parts of custom elements). ::slotted() is accepted by
33
+ // the parser but rejected by the resolver with a loud explanation: it only
34
+ // exists inside a shadow tree, which is not a place DSSL styles.
35
+ 'part', 'slotted',
36
  ]);
37
 
38
  // v0.4.2 β€” vendor pseudo-elements pass through verbatim (::-webkit-scrollbar
 
55
  return -1;
56
  }
57
 
58
+ /** find index of LAST top-level occurrence of `ch` (skips strings + parens) */
59
+ function scanForLast(str, ch) {
60
+ let depth = 0, q = null, found = -1;
61
+ for (let i = 0; i < str.length; i++) {
62
+ const c = str[i];
63
+ if (q) { if (c === q && str[i - 1] !== '\\') q = null; continue; }
64
+ if (c === '"' || c === "'") { q = c; continue; }
65
+ if (c === '(' || c === '[') { depth++; continue; }
66
+ if (c === ')' || c === ']') { depth = Math.max(0, depth - 1); continue; }
67
+ if (depth === 0 && c === ch) found = i;
68
+ }
69
+ return found;
70
+ }
71
+
72
  /** split a string on a top-level separator */
73
  function splitTop(str, sep) {
74
  const out = [];
 
177
 
178
  if (open !== -1) {
179
  const header = rest.slice(0, open).trim();
180
+ // v0.7.2 β€” declarations and a nested block sharing one line:
181
+ // `.card { background: red; hover { ... } }`. Everything before the
182
+ // header used to be swallowed whole into it, so `background: red`
183
+ // silently became part of a selector named "background: red; hover"
184
+ // and vanished. Declarations before the LAST top-level ';' are now
185
+ // processed as declarations; the header is what follows them.
186
+ if (header) {
187
+ const semi = scanForLast(header, ';');
188
+ if (semi !== -1) {
189
+ processContent(header.slice(0, semi), line);
190
+ const realHeader = header.slice(semi + 1).trim();
191
+ if (!realHeader) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
192
+ const meta = classify(realHeader, line);
193
+ const node = { ...meta, line, children: [] };
194
+ stack[stack.length - 1].children.push(node);
195
+ if (meta.kind !== 'bogus') stack.push(node);
196
+ rest = rest.slice(open + 1);
197
+ continue;
198
+ }
199
+ }
200
  if (!header) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
201
  const meta = classify(header, line);
202
  const node = { ...meta, line, children: [] };
public/dssl/resolver.js CHANGED
@@ -20,7 +20,7 @@
20
  import { levenshtein, didYouMean, composeDeform } from './utils.js';
21
  // v0.5.0 β€” the resolver validates the `::name` half of full selectors so a
22
  // typo like .card::befor is a LOUD error, never a silently dead rule.
23
- import { PSEUDO_ELEMENTS, VENDOR_PSEUDO } from './parser.js';
24
 
25
  /* ------------------------------------------------------------------ *
26
  * Vocabulary
@@ -68,11 +68,19 @@ const UNITLESS_PROPS = new Set([
68
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
69
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
70
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
71
- 'shape-image-threshold', 'animation-delay', 'transition-delay',
72
  // grid line placement takes integers, not lengths: grid-column: span 2
73
  'grid-row', 'grid-column', 'grid-area',
74
  ]);
75
 
 
 
 
 
 
 
 
 
76
  // A practical whitelist of standard CSS properties (loud errors beat silent ones)
77
  const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
78
  'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
@@ -82,8 +90,8 @@ const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-it
82
  'border-inline,border-inline-color,border-inline-end,border-inline-end-color,border-inline-end-style,border-inline-end-width,border-inline-start,border-inline-start-color,border-inline-start-style,border-inline-start-width,border-inline-style,border-inline-width,' +
83
  'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
84
  'border-spacing,border-start-end-radius,border-start-start-radius,border-style,border-top,border-top-color,border-top-left-radius,border-top-right-radius,border-top-style,border-top-width,border-width,' +
85
- 'bottom,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' +
86
- 'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' +
87
  'animation,animation-composition,animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-range,animation-timeline,animation-timing-function,' +
88
  'view-transition-name,interpolate-size,text-box,text-box-edge,text-box-trim,dynamic-range-limit,field-sizing,anchor-name,position-anchor,position-area,scroll-timeline,scroll-timeline-axis,scroll-timeline-name,timeline-scope,view-timeline,view-timeline-axis,view-timeline-name,' +
89
  '-webkit-background-clip,-webkit-text-fill-color,-webkit-box-orient,-webkit-line-clamp,-webkit-tap-highlight-color,-webkit-font-smoothing,-moz-osx-font-smoothing,-webkit-box-shadow,-webkit-text-stroke').split(','));
@@ -182,7 +190,7 @@ function resolveValue(raw, ctx) {
182
  unit = raw.slice(j, k).toLowerCase();
183
  j = k;
184
  }
185
- out += num + (unit ? unit : (ctx.unitless ? '' : 'px'));
186
  i = j;
187
  continue;
188
  }
@@ -267,8 +275,18 @@ function resolveValue(raw, ctx) {
267
  if (c === '#') { // hex color
268
  let j = i + 1;
269
  while (j < n && /[0-9a-fA-F]/.test(raw[j])) j++;
270
- out += raw.slice(i, j);
 
271
  i = j;
 
 
 
 
 
 
 
 
 
272
  continue;
273
  }
274
  out += c;
@@ -510,15 +528,31 @@ const SEMANTIC = {
510
  column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
511
  grid(v, ctx) {
512
  const atoms = v.split(/\s+/).filter(Boolean);
 
 
 
 
 
 
 
 
 
 
513
  let cols;
514
- if (atoms.length === 1 && /^[0-9]+$/.test(atoms[0])) {
515
- cols = `repeat(${atoms[0]}, 1fr)`;
516
- } else if ((atoms[0] === 'auto-fill' || atoms[0] === 'auto-fit') && atoms[1]) {
517
- cols = `repeat(${atoms[0]}, minmax(${len(atoms[1], ctx)}, 1fr))`;
 
 
 
518
  } else {
519
- cols = atoms.map(a => len(a, ctx)).join(' ');
520
  }
521
- return [{ prop: 'display', value: 'grid' }, { prop: 'grid-template-columns', value: cols }];
 
 
 
522
  },
523
  stack(v, ctx) {
524
  const word = (v || 'center').trim();
@@ -1064,6 +1098,22 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1064
  /* ---- main recursive walk ---- */
1065
 
1066
  const walk = (children, ctx, ctxRule) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1067
  for (const node of children) {
1068
  switch (node.kind) {
1069
  case 'decl': {
@@ -1091,6 +1141,11 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1091
  break;
1092
  }
1093
  case 'selector': {
 
 
 
 
 
1094
  const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
1095
  const childCtx = { ...ctx, sel };
1096
  const sub = { decls: [] };
@@ -1103,7 +1158,19 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1103
  errors.push({ file, line: node.line, message: `"${node.name}" needs a selector around it` });
1104
  break;
1105
  }
 
1106
  const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
 
 
 
 
 
 
 
 
 
 
 
1107
  const sub = { decls: [] };
1108
  walk(node.children, childCtx, sub);
1109
  flush(sub, childCtx, node.line);
@@ -1111,31 +1178,39 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1111
  }
1112
  case 'media': {
1113
  const q = normalizeMedia(node.query, { errors, file, line: node.line }, node.line);
 
 
1114
  const sub = { decls: [] };
1115
- walk(node.children, { ...ctx, media: [...ctx.media, q] }, sub);
1116
- flush(sub, ctx, node.line);
1117
  break;
1118
  }
1119
  case 'container': {
1120
  const q = normalizeMedia(node.query || '(min-width: 0)', { errors, file, line: node.line }, node.line);
 
 
1121
  const sub = { decls: [] };
1122
- walk(node.children, { ...ctx, containers: [...ctx.containers, q] }, sub);
1123
- flush(sub, ctx, node.line);
1124
  break;
1125
  }
1126
  case 'signal': {
1127
  const sig = parseSignal(node.expr, { errors, file, line: node.line }, node.line);
 
 
1128
  const sub = { decls: [] };
1129
- walk(node.children, { ...ctx, signals: [...ctx.signals, sig] }, sub);
1130
- flush(sub, ctx, node.line);
1131
  break;
1132
  }
1133
  case 'layer':
1134
  case 'pin': {
1135
  const layer = node.kind === 'pin' ? 'pin' : (['tokens', 'base', 'components', 'utilities', 'pin'].includes(node.name) ? node.name : 'components');
 
 
1136
  const sub = { decls: [] };
1137
- walk(node.children, { ...ctx, layer }, sub);
1138
- flush(sub, ctx, node.line);
1139
  break;
1140
  }
1141
  case 'pseudo': {
@@ -1214,7 +1289,15 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1214
  errors.push({
1215
  file, line,
1216
  message: `unknown pseudo-element "::${pseudo}"`,
1217
- hint: 'try ::before, ::after, ::selection, or a vendor one like ::-webkit-scrollbar-thumb',
 
 
 
 
 
 
 
 
1218
  });
1219
  continue;
1220
  }
 
20
  import { levenshtein, didYouMean, composeDeform } from './utils.js';
21
  // v0.5.0 β€” the resolver validates the `::name` half of full selectors so a
22
  // typo like .card::befor is a LOUD error, never a silently dead rule.
23
+ import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
24
 
25
  /* ------------------------------------------------------------------ *
26
  * Vocabulary
 
68
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
69
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
70
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
71
+ 'shape-image-threshold',
72
  // grid line placement takes integers, not lengths: grid-column: span 2
73
  'grid-row', 'grid-column', 'grid-area',
74
  ]);
75
 
76
+ // v0.7.2 β€” time properties: a bare number is milliseconds, never px, and
77
+ // never unitless (both used to emit values the browser silently rejected).
78
+ // transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is
79
+ // why `transition-delay: 200` shipped as invalid unitless 200.
80
+ const TIME_PROPS = new Set([
81
+ 'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
82
+ ]);
83
+
84
  // A practical whitelist of standard CSS properties (loud errors beat silent ones)
85
  const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
86
  'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
 
90
  'border-inline,border-inline-color,border-inline-end,border-inline-end-color,border-inline-end-style,border-inline-end-width,border-inline-start,border-inline-start-color,border-inline-start-style,border-inline-start-width,border-inline-style,border-inline-width,' +
91
  'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
92
  'border-spacing,border-start-end-radius,border-start-start-radius,border-style,border-top,border-top-color,border-top-left-radius,border-top-right-radius,border-top-style,border-top-width,border-width,' +
93
+ 'bottom,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,content-visibility,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,fill,fill-rule,fill-opacity,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-opacity,stroke-width,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' +
94
+ 'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,text-wrap,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' +
95
  'animation,animation-composition,animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-range,animation-timeline,animation-timing-function,' +
96
  'view-transition-name,interpolate-size,text-box,text-box-edge,text-box-trim,dynamic-range-limit,field-sizing,anchor-name,position-anchor,position-area,scroll-timeline,scroll-timeline-axis,scroll-timeline-name,timeline-scope,view-timeline,view-timeline-axis,view-timeline-name,' +
97
  '-webkit-background-clip,-webkit-text-fill-color,-webkit-box-orient,-webkit-line-clamp,-webkit-tap-highlight-color,-webkit-font-smoothing,-moz-osx-font-smoothing,-webkit-box-shadow,-webkit-text-stroke').split(','));
 
190
  unit = raw.slice(j, k).toLowerCase();
191
  j = k;
192
  }
193
+ out += num + (unit ? unit : (ctx.unitless ? '' : TIME_PROPS.has(ctx.prop) ? 'ms' : 'px'));
194
  i = j;
195
  continue;
196
  }
 
275
  if (c === '#') { // hex color
276
  let j = i + 1;
277
  while (j < n && /[0-9a-fA-F]/.test(raw[j])) j++;
278
+ const hex = raw.slice(i, j);
279
+ out += hex;
280
  i = j;
281
+ // v0.7.2 β€” alpha on a raw hex color: #ff0000/50 used to become
282
+ // #ff0000/50px (the bare number got the generic px treatment) β€” invalid
283
+ // CSS, silently dropped by the browser. Tokens always had /NN via
284
+ // color-mix; raw hex now gets the same treatment.
285
+ const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
286
+ if (am) {
287
+ out = out.slice(0, out.length - hex.length) + `color-mix(in oklab, ${hex} ${am[1]}%, transparent)`;
288
+ i += am[0].length;
289
+ }
290
  continue;
291
  }
292
  out += c;
 
528
  column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
529
  grid(v, ctx) {
530
  const atoms = v.split(/\s+/).filter(Boolean);
531
+ // v0.7.2 β€” `grid: 3 gap 12` (the documented form) used to die: the gap
532
+ // atoms were fed into grid-template-columns β†’ "3px gap 12px", invalid.
533
+ // gap and rows are now first-class words of the shorthand.
534
+ let gap = null, rows = null;
535
+ const rest = [];
536
+ for (let i = 0; i < atoms.length; i++) {
537
+ if (atoms[i] === 'gap' && atoms[i + 1]) { gap = atoms[++i]; continue; }
538
+ if (atoms[i] === 'rows' && atoms[i + 1]) { rows = atoms[++i]; continue; }
539
+ rest.push(atoms[i]);
540
+ }
541
  let cols;
542
+ if (!rest.length) {
543
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'grid needs columns', hint: 'grid: 3 Β· grid: auto-fill 200 Β· grid: 200px 1fr β€” optionally with gap N and rows N' });
544
+ cols = 'none';
545
+ } else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) {
546
+ cols = `repeat(${rest[0]}, 1fr)`;
547
+ } else if ((rest[0] === 'auto-fill' || rest[0] === 'auto-fit') && rest[1]) {
548
+ cols = `repeat(${rest[0]}, minmax(${len(rest[1], ctx)}, 1fr))`;
549
  } else {
550
+ cols = rest.map(a => len(a, ctx)).join(' ');
551
  }
552
+ const out = [{ prop: 'display', value: 'grid' }, { prop: 'grid-template-columns', value: cols }];
553
+ if (gap) out.push({ prop: 'gap', value: len(gap, ctx) });
554
+ if (rows) out.push({ prop: 'grid-template-rows', value: /^[0-9]+$/.test(rows) ? `repeat(${rows}, 1fr)` : len(rows, ctx) });
555
+ return out;
556
  },
557
  stack(v, ctx) {
558
  const word = (v || 'center').trim();
 
1098
  /* ---- main recursive walk ---- */
1099
 
1100
  const walk = (children, ctx, ctxRule) => {
1101
+ // v0.7.2 β€” parent-first flush. Two real bugs lived here. (1) A nested
1102
+ // block (hover { }, @media, a nested selector) was emitted BEFORE the
1103
+ // parent's own declarations got their cascade order, so `background: red`
1104
+ // on the parent always beat `hover { background: red }` nested inside it
1105
+ // β€” the exact pattern the docs taught, and the external review's headline
1106
+ // finding. (2) media/container/signal/layer blocks flushed with the
1107
+ // PARENT context, dropping the condition or layer entirely: a nested
1108
+ // `@media dark` was silently unconditional AND sorted before base.
1109
+ // Nested blocks now flush everything the parent has said so far first,
1110
+ // and every block flushes with the context it was actually walked with.
1111
+ const flushPending = (line) => {
1112
+ if (ctxRule && ctxRule.decls.length && ctx.sel) {
1113
+ flush(ctxRule, ctx, line);
1114
+ ctxRule.decls = [];
1115
+ }
1116
+ };
1117
  for (const node of children) {
1118
  switch (node.kind) {
1119
  case 'decl': {
 
1141
  break;
1142
  }
1143
  case 'selector': {
1144
+ if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) {
1145
+ const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]);
1146
+ if (near) warnings.push({ file, line: node.line, message: `"${node.sel}" compiled as a descendant selector ("${combine(ctx.sel, node.sel)}")`, hint: `if you meant the state, it is spelled "${near}" β€” this block currently styles descendants called <${node.sel}>` });
1147
+ }
1148
+ flushPending(node.line);
1149
  const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
1150
  const childCtx = { ...ctx, sel };
1151
  const sub = { decls: [] };
 
1158
  errors.push({ file, line: node.line, message: `"${node.name}" needs a selector around it` });
1159
  break;
1160
  }
1161
+ flushPending(node.line);
1162
  const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
1163
+ // v0.7.2 β€” the arrow is motion/gradient-stop syntax; inside a seen
1164
+ // block it compiled into an inline style the browser rejects, so the
1165
+ // element just sat at its base style forever. Say so, loudly, and
1166
+ // point at the two real patterns (final value, or a motion).
1167
+ if (node.name === 'seen') {
1168
+ for (const d of node.children) {
1169
+ if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) {
1170
+ errors.push({ file, line: d.line, message: `"${d.prop}: ${d.value}" β€” the -> arrow is motion syntax and does nothing inside seen { }`, hint: 'write the final style (seen { opacity: 1 }) or attach a motion (seen { animate: rise-in 400 })' });
1171
+ }
1172
+ }
1173
+ }
1174
  const sub = { decls: [] };
1175
  walk(node.children, childCtx, sub);
1176
  flush(sub, childCtx, node.line);
 
1178
  }
1179
  case 'media': {
1180
  const q = normalizeMedia(node.query, { errors, file, line: node.line }, node.line);
1181
+ flushPending(node.line);
1182
+ const childCtx = { ...ctx, media: [...ctx.media, q] };
1183
  const sub = { decls: [] };
1184
+ walk(node.children, childCtx, sub);
1185
+ flush(sub, childCtx, node.line);
1186
  break;
1187
  }
1188
  case 'container': {
1189
  const q = normalizeMedia(node.query || '(min-width: 0)', { errors, file, line: node.line }, node.line);
1190
+ flushPending(node.line);
1191
+ const childCtx = { ...ctx, containers: [...ctx.containers, q] };
1192
  const sub = { decls: [] };
1193
+ walk(node.children, childCtx, sub);
1194
+ flush(sub, childCtx, node.line);
1195
  break;
1196
  }
1197
  case 'signal': {
1198
  const sig = parseSignal(node.expr, { errors, file, line: node.line }, node.line);
1199
+ flushPending(node.line);
1200
+ const childCtx = { ...ctx, signals: [...ctx.signals, sig] };
1201
  const sub = { decls: [] };
1202
+ walk(node.children, childCtx, sub);
1203
+ flush(sub, childCtx, node.line);
1204
  break;
1205
  }
1206
  case 'layer':
1207
  case 'pin': {
1208
  const layer = node.kind === 'pin' ? 'pin' : (['tokens', 'base', 'components', 'utilities', 'pin'].includes(node.name) ? node.name : 'components');
1209
+ flushPending(node.line);
1210
+ const childCtx = { ...ctx, layer };
1211
  const sub = { decls: [] };
1212
+ walk(node.children, childCtx, sub);
1213
+ flush(sub, childCtx, node.line);
1214
  break;
1215
  }
1216
  case 'pseudo': {
 
1289
  errors.push({
1290
  file, line,
1291
  message: `unknown pseudo-element "::${pseudo}"`,
1292
+ hint: 'try ::before, ::after, ::selection, ::part(name), or a vendor one like ::-webkit-scrollbar-thumb',
1293
+ });
1294
+ continue;
1295
+ }
1296
+ if (name === 'slotted') {
1297
+ errors.push({
1298
+ file, line,
1299
+ message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM',
1300
+ hint: 'style the slotted element itself with a normal selector β€” it IS reachable from the page',
1301
  });
1302
  continue;
1303
  }
public/examples/hello.dssl ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // hello.dssl β€” a tiny starter stylesheet
2
+ // bare numbers are px, hover is a nested state, /14 at the end of a color is alpha
3
+
4
+ body {
5
+ background: #f5f0e4
6
+ color: #26201a
7
+ padding: 40
8
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif
9
+ }
10
+
11
+ h1 {
12
+ color: #7c2d2d
13
+ }
14
+
15
+ .card {
16
+ background: #fdfaf2
17
+ border: 1 solid #ddd2bc
18
+ radius: 6
19
+ padding: 24
20
+ box-shadow: 0 2 6 #26201a/14
21
+ transition: 200
22
+
23
+ hover {
24
+ box-shadow: 0 6 18 #26201a/16
25
+ }
26
+ }
27
+
28
+ button {
29
+ background: #7c2d2d
30
+ color: #f9efdd
31
+ padding: 10 18
32
+ radius: 6
33
+ border: 0
34
+ transition: 200
35
+
36
+ hover {
37
+ background: #5e1f1f
38
+ }
39
+ }
scripts/dssl-smoke.mjs CHANGED
@@ -182,7 +182,7 @@ const cardRule = doc.rules.find(r => r.sel === '.card');
182
  assert(cardRule, '.card rule exists');
183
  assert(cardRule.decls.some(d => d.prop === 'background' && d.value.includes('var(--dssl-color-card)')), 'background resolved to var');
184
  assert(cardRule.decls.some(d => d.prop === 'padding' && d.value.includes('var(--dssl-space-4)')), 'space.4 resolved to var ref');
185
- assert(cardRule.decls.some(d => d.prop === '@animate' && d.value.frames && d.value.frames.length === 1), 'animate wired with frames');
186
  assert(doc.rules.some(r => r.sel === '.card:focus-visible'), 'behavior expanded into state selector');
187
  assert(doc.rules.some(r => r.sel === '.card:hover .tip'), 'nested state descendant composed');
188
  assert(doc.rules.some(r => r.sel === ':root' && r.signals.length === 1), 'signal token override rule');
 
182
  assert(cardRule, '.card rule exists');
183
  assert(cardRule.decls.some(d => d.prop === 'background' && d.value.includes('var(--dssl-color-card)')), 'background resolved to var');
184
  assert(cardRule.decls.some(d => d.prop === 'padding' && d.value.includes('var(--dssl-space-4)')), 'space.4 resolved to var ref');
185
+ assert(doc.rules.filter(r => r.sel === '.card').some(r => r.decls.some(d => d.prop === '@animate' && d.value.frames && d.value.frames.length === 1)), 'animate wired with frames');
186
  assert(doc.rules.some(r => r.sel === '.card:focus-visible'), 'behavior expanded into state selector');
187
  assert(doc.rules.some(r => r.sel === '.card:hover .tip'), 'nested state descendant composed');
188
  assert(doc.rules.some(r => r.sel === ':root' && r.signals.length === 1), 'signal token override rule');
scripts/make-extension-icon.py CHANGED
File without changes
scripts/push-to-github.sh ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # DSSL project β€” permanent cloud backup to GitHub.
3
+ # Usage: GITHUB_TOKEN=<your PAT> bash scripts/push-to-github.sh [repo-name]
4
+ # Needs: classic PAT with `repo` scope, or fine-grained PAT with repo create rights.
5
+ set -euo pipefail
6
+
7
+ REPO_NAME="${1:-dssl-language}"
8
+ API="https://api.github.com"
9
+
10
+ : "${GITHUB_TOKEN:?Set GITHUB_TOKEN env var first (a GitHub Personal Access Token with repo scope)}"
11
+
12
+ OWNER=$(curl -sf -H "Authorization: token $GITHUB_TOKEN" "$API/user" | python3 -c 'import json,sys;print(json.load(sys.stdin)["login"])')
13
+ echo "Authenticated as: $OWNER"
14
+
15
+ echo "Creating repo $OWNER/$REPO_NAME (skips if it already exists)..."
16
+ HTTP=$(curl -s -o /tmp/gh-create.json -w "%{http_code}" -X POST \
17
+ -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" \
18
+ "$API/user/repos" -d "{\"name\":\"$REPO_NAME\",\"private\":true,\"description\":\"DSSL β€” a Domain Specific Style Language that replaces CSS (self-hosted engine, no CSS output)\"}")
19
+ if [ "$HTTP" = "422" ]; then echo "Repo already exists β€” pushing into it."; else [ "$HTTP" = "201" ] && echo "Repo created."; fi
20
+
21
+ cd /home/z/my-project
22
+ git remote remove cloud 2>/dev/null || true
23
+ git remote add cloud "https://x-access-token:$GITHUB_TOKEN@github.com/$OWNER/$REPO_NAME.git"
24
+ git push -u cloud main
25
+ git remote set-url cloud "https://github.com/$OWNER/$REPO_NAME.git" # scrub token from .git/config
26
+ echo "DONE β€” permanent backup live at https://github.com/$OWNER/$REPO_NAME"
scripts/repro-v072.mjs ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ // Reproduces every compiler-level claim from the external agent review (0.7.2 cycle).
3
+ import { parse } from '/home/z/my-project/public/dssl/parser.js';
4
+ import { resolve } from '/home/z/my-project/public/dssl/resolver.js';
5
+
6
+ let fails = 0;
7
+ function show(name, source, opts = {}) {
8
+ const { ast } = parse(source, 'repro.dssl');
9
+ const out = resolve(ast, 'repro.dssl', {});
10
+ console.log('== ' + name);
11
+ if (out.errors?.length) console.log(' ERRORS:', out.errors.map(e => e.message || e).join(' | '));
12
+ if (out.warnings?.length) console.log(' WARNINGS:', out.warnings.map(e => e.message || e).join(' | '));
13
+ for (const r of (out.rules || [])) {
14
+ console.log(' rule:', r.selector, JSON.stringify(r.decls || r.sets || r).slice(0, 220));
15
+ }
16
+ return out;
17
+ }
18
+
19
+ // 1. THE BIG ONE: nested state must sort after base
20
+ show('nested hover vs base (cascade)', `
21
+ .card {
22
+ background: #26201a
23
+ hover { background: #c0392b }
24
+ }
25
+ `);
26
+
27
+ // 2. raw hex alpha
28
+ show('raw hex /50 alpha', `
29
+ .card { background: #ff0000/50 }
30
+ `);
31
+
32
+ // 3. transition-delay units
33
+ show('transition-delay 200', `
34
+ .card { transition-delay: 200 }
35
+ `);
36
+
37
+ // 4. grid gap shorthand
38
+ show('grid: 3 gap 12', `
39
+ .grid { grid: 3 gap 12 }
40
+ `);
41
+
42
+ // 5. shadow token def with bare ink
43
+ show('shadow { md: 0 2 4 ink/10 }', `
44
+ shadow { md: 0 2 4 ink/10 }
45
+ `);
46
+
47
+ // 6. seen block
48
+ show('seen { opacity: 0 -> 1 } block', `
49
+ .card { seen { opacity: 0 -> 1 } }
50
+ `);
51
+ show('seen as motion decl', `
52
+ .card { seen: opacity 0 -> 1 }
53
+ `);
54
+
55
+ // 7. hovr typo nested
56
+ show('nested hovr typo', `
57
+ .card { hovr { background: red } }
58
+ `);
59
+
60
+ // 8. missing properties
61
+ show('fill/stroke/text-wrap/content-visibility', `
62
+ .card { fill: red; stroke: blue; stroke-width: 2; text-wrap: balance; content-visibility: auto }
63
+ `);
64
+
65
+ // 9. ::part / ::slotted
66
+ show('::part selector', `
67
+ my-card::part(header) { background: red }
68
+ `);
69
+
70
+ // 10. shadow: sm / hover shadow lg quickstart pattern
71
+ show('README quickstart pattern', `
72
+ .card { shadow: sm; hover { shadow: lg } }
73
+ `);
scripts/smoke-v060.mjs CHANGED
File without changes
scripts/sync-extension-engine.mjs CHANGED
File without changes
scripts/test-guide-examples.mjs ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ // Compiles every ```dssl fenced block in GUIDE.md (strict: 0 errors allowed)
3
+ // and reports README.md blocks (the README may carry teaching examples that
4
+ // intentionally error; those are tagged with <!-- dssl:has-errors -->).
5
+ import { readFileSync } from 'node:fs';
6
+ import { parse } from '../public/dssl/parser.js';
7
+ import { resolve, DEFAULT_TOKENS } from '../public/dssl/resolver.js';
8
+
9
+ const files = [
10
+ { path: '/home/z/my-project/GUIDE.md', strict: true },
11
+ { path: '/home/z/hf-mirror/README.md', strict: false },
12
+ ];
13
+
14
+ let pass = 0, fail = 0;
15
+ for (const { path, strict } of files) {
16
+ let text;
17
+ try { text = readFileSync(path, 'utf8'); } catch { console.log('(skip missing ' + path + ')'); continue; }
18
+ const blocks = [...text.matchAll(/```dssl\n([\s\S]*?)```/g)].map(m => m[1]);
19
+ // whole-file workspace fold: seed with the engine's default tokens, collect
20
+ // every token defined anywhere in the document, then compile each block
21
+ // against the union. Guides are read non-linearly; examples may reference
22
+ // tokens from any example on the page.
23
+ let registry = { ...DEFAULT_TOKENS };
24
+ for (const src of blocks) {
25
+ const { ast, errors: pe } = parse(src, 'tokens-pass');
26
+ const doc = resolve(ast, 'tokens-pass', registry);
27
+ registry = { ...registry, ...doc.tokens };
28
+ }
29
+ blocks.forEach((src, i) => {
30
+ const name = `${path.split('/').pop()} block ${i + 1}`;
31
+ const { ast, errors: parseErrors } = parse(src, name);
32
+ const doc = resolve(ast, name, registry);
33
+ const errors = [...parseErrors, ...doc.errors];
34
+ if (errors.length === 0) { pass++; return; }
35
+ if (!strict && /dssl:has-errors/.test(src)) { pass++; return; }
36
+ fail++;
37
+ console.error(`FAIL ${name}:`);
38
+ for (const e of errors.slice(0, 4)) console.error(' line ' + (e.line ?? '?') + ': ' + e.message);
39
+ });
40
+ console.log(`${path.split('/').pop()}: ${blocks.length} blocks checked`);
41
+ }
42
+ console.log(`${pass} passed, ${fail} failed`);
43
+ process.exitCode = fail ? 1 : 0;
scripts/test-v050.mjs CHANGED
@@ -192,5 +192,81 @@ console.log(`\n${pass} passed, ${fail} failed`);
192
  check('11 bare content auto-quoted', c && c.value === '""', c && c.value);
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  console.log(`${pass} passed, ${fail} failed`);
196
  process.exit(fail ? 1 : 0);
 
192
  check('11 bare content auto-quoted', c && c.value === '""', c && c.value);
193
  }
194
 
195
+ console.log(`${pass} passed, ${fail} failed`);
196
+
197
+ // 12. overlap policy β€” the "nothing below 100% opacity overlaps" feature
198
+ {
199
+ const { doc, errors } = compile(`
200
+ body { overlap: auto }
201
+ .badge { overlap: deny }
202
+ .scrim { overlap: allow }
203
+ .off { overlap: none }
204
+ `);
205
+ check('12 no errors', errors.length === 0, JSON.stringify(errors));
206
+ const get = (sel) => doc.rules.find(r => r.sel === sel)?.decls.find(d => d.prop === '@overlap')?.value;
207
+ check('12 body policy = auto', get('body') === 'auto', get('body'));
208
+ check('12 deny passes through', get('.badge') === 'deny', get('.badge'));
209
+ check('12 allow passes through', get('.scrim') === 'allow', get('.scrim'));
210
+ check('12 none is an alias of allow', get('.off') === 'allow', get('.off'));
211
+
212
+ const bad = compile('.card { overlap: never }');
213
+ check('12 bad policy is a LOUD error', bad.errors.length === 1 && /unknown overlap policy/.test(bad.errors[0].message), JSON.stringify(bad.errors));
214
+ check('12 hint names the vocabulary', /allow|deny|auto/.test(bad.errors[0].hint || ''));
215
+
216
+ const dead = compile('.card { overlap: nevr }');
217
+ check('12 nothing emitted on error', dead.doc.rules.length === 0);
218
+
219
+ // site.dssl now carries the global switch β€” regression-guard it
220
+ const fs = await import('node:fs');
221
+ const site = fs.readFileSync(new URL('../public/styles/site.dssl', import.meta.url).pathname, 'utf8');
222
+ check('12 site.dssl body opts in', /overlap:\s*auto/.test(site));
223
+ }
224
+
225
+ // 13. the escape math (overlapNudge) β€” pure function, no DOM needed
226
+ {
227
+ const { overlapNudge } = await import('../public/dssl/engine.js');
228
+ const box = (l, t, r, b) => ({ left: l, top: t, right: r, bottom: b });
229
+
230
+ // candidate [0,0,100,100] vs obstacle [50,0,150,100]: push left past the
231
+ // near edge costs 50, push right past the far edge costs 150 β†’ left wins
232
+ let n = overlapNudge(box(0, 0, 100, 100), [box(50, 0, 150, 100)]);
233
+ check('13 shortest escape wins (left 50)', n.x === -50 && n.y === 0, JSON.stringify(n));
234
+
235
+ // deep obstacle above, 10px of y-penetration β†’ dropping down 10 beats climbing 200
236
+ n = overlapNudge(box(0, 90, 200, 200), [box(0, 0, 200, 100)]);
237
+ check('13 vertical kiss resolves down 10', n.x === 0 && n.y === 10, JSON.stringify(n));
238
+
239
+ // no overlap β†’ no nudge
240
+ n = overlapNudge(box(0, 0, 10, 10), [box(20, 20, 30, 30)]);
241
+ check('13 clear boxes stay put', n.x === 0 && n.y === 0, JSON.stringify(n));
242
+
243
+ // touching edges are NOT overlap (the shared-border tremble guard)
244
+ n = overlapNudge(box(0, 0, 100, 100), [box(100, 0, 200, 100)]);
245
+ check('13 touching edges do not count', n.x === 0 && n.y === 0, JSON.stringify(n));
246
+
247
+ // two walls with a real gap between them β†’ nudge must land inside the gap
248
+ n = overlapNudge(box(70, 0, 90, 100), [box(0, 0, 80, 100), box(120, 0, 200, 100)]);
249
+ const cleared = (b, obs) => obs.every(o =>
250
+ Math.min(b.right, o.right) - Math.max(b.left, o.left) <= 0.01 ||
251
+ Math.min(b.bottom, o.bottom) - Math.max(b.top, o.top) <= 0.01);
252
+ const moved = box(70 + n.x, 0 + n.y, 90 + n.x, 100 + n.y);
253
+ check('13 box clears both walls into the gap', n.x === 10 && cleared(moved, [box(0, 0, 80, 100), box(120, 0, 200, 100)]), JSON.stringify({ n, moved }));
254
+ }
255
+
256
+ console.log(`${pass} passed, ${fail} failed`);
257
+
258
+ // 14. engine-native props inside pseudo blocks fail loud (no junk shadow CSS)
259
+ {
260
+ const a = compile('.card::before { animate: pop 400 }');
261
+ check('14 nested animate-in-pseudo is LOUD', a.errors.length === 1 && /engine-native/.test(a.errors[0].message), JSON.stringify(a.errors));
262
+ check('14 nothing leaked to shadow CSS', a.doc.shadowRules.length === 0 && a.doc.rules.length === 0);
263
+
264
+ const b = compile('.card::before { overlap: deny }');
265
+ check('14 top-level overlap-in-pseudo is LOUD', b.errors.length === 1 && /engine-native/.test(b.errors[0].message), JSON.stringify(b.errors));
266
+
267
+ const c = compile('.card { ::after { --x: 1 } }');
268
+ check('14 custom props still pass in pseudo blocks', c.errors.length === 0 && c.doc.shadowRules.length === 1, JSON.stringify(c.errors));
269
+ }
270
+
271
  console.log(`${pass} passed, ${fail} failed`);
272
  process.exit(fail ? 1 : 0);
scripts/test-v060.mjs CHANGED
File without changes
scripts/test-v070.mjs CHANGED
@@ -126,7 +126,7 @@ console.log('5 β€” freeze/unfreeze/explain exist as API surface');
126
  ok(typeof eng.freeze === 'function', 'freeze exported');
127
  ok(typeof eng.unfreeze === 'function', 'unfreeze exported');
128
  ok(typeof eng.explain === 'function', 'explain exported');
129
- ok(eng.VERSION === '0.7.0', 'VERSION is 0.7.0');
130
  ok(typeof eng.DSSL.freeze === 'function' && typeof eng.DSSL.explain === 'function', 'DSSL object carries the new API');
131
  }
132
  const engMod = await import('../public/dssl/engine.js');
 
126
  ok(typeof eng.freeze === 'function', 'freeze exported');
127
  ok(typeof eng.unfreeze === 'function', 'unfreeze exported');
128
  ok(typeof eng.explain === 'function', 'explain exported');
129
+ ok(parseInt(String(eng.VERSION).split('.')[1]) >= 7, 'VERSION is 0.7.x or newer');
130
  ok(typeof eng.DSSL.freeze === 'function' && typeof eng.DSSL.explain === 'function', 'DSSL object carries the new API');
131
  }
132
  const engMod = await import('../public/dssl/engine.js');
scripts/test-v071.mjs CHANGED
File without changes
scripts/test-v072.mjs ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ // v0.7.2 β€” regression suite for the external-review findings.
3
+ // Every assertion maps to a reported bug or a fixed behavior.
4
+ import { parse, STATE_WORDS } from '../public/dssl/parser.js';
5
+ import { resolve } from '../public/dssl/resolver.js';
6
+ import { VERSION } from '../public/dssl/index.js';
7
+ import { stateReach, gatePass } from '../public/dssl/engine.js';
8
+
9
+ let pass = 0, fail = 0;
10
+ const ok = (cond, msg) => { if (cond) { pass++; } else { fail++; console.error('FAIL:', msg); } };
11
+
12
+ function compile(src) {
13
+ const { ast, errors } = parse(src, 't.dssl');
14
+ const doc = resolve(ast, 't.dssl', {});
15
+ return { doc, parseErrors: errors };
16
+ }
17
+ const declsOf = (doc, sel) => doc.rules.filter(r => r.sel === sel).flatMap(r => r.decls);
18
+
19
+ /* 1 β€” THE headline: nested state must sort AFTER the base rule */
20
+ {
21
+ const { doc } = compile('.card {\n background: #26201a\n hover { background: #c0392b }\n}');
22
+ const base = doc.rules.find(r => r.sel === '.card');
23
+ const hov = doc.rules.find(r => r.sel === '.card:hover');
24
+ ok(base && hov, 'nested hover: both rules exist');
25
+ ok(hov.order > base.order, 'nested hover sorts after base (cascade fix)');
26
+ ok(declsOf(doc, '.card').some(d => d.value === '#26201a'), 'base declaration survives');
27
+ ok(declsOf(doc, '.card:hover').some(d => d.value === '#c0392b'), 'hover declaration survives');
28
+ }
29
+ /* 1b β€” nested media keeps its condition AND sorts after base */
30
+ {
31
+ const { doc } = compile('.card {\n color: red\n @media dark { color: blue }\n}');
32
+ const dark = doc.rules.find(r => r.sel === '.card' && r.media.length);
33
+ ok(dark && dark.media.length === 1, 'nested @media keeps its condition');
34
+ const base = doc.rules.find(r => r.sel === '.card' && !r.media.length);
35
+ ok(dark && base && dark.order > base.order, 'nested @media sorts after base');
36
+ }
37
+ /* 1c β€” nested signal keeps its condition */
38
+ {
39
+ const { doc } = compile('.card {\n color: red\n @signal on(true) { color: blue }\n}');
40
+ const sig = doc.rules.find(r => r.sel === '.card' && r.signals.length);
41
+ ok(sig && sig.signals.length === 1, 'nested @signal keeps its condition');
42
+ }
43
+ /* 1d β€” nested layer keeps its layer */
44
+ {
45
+ const { doc } = compile('.card {\n color: red\n @layer utilities { color: blue }\n}');
46
+ const lay = doc.rules.find(r => r.sel === '.card' && r.layer === 'utilities');
47
+ ok(lay, 'nested @layer keeps its layer');
48
+ }
49
+ /* 1e β€” decls AFTER a nested block become a later fragment (later wins) */
50
+ {
51
+ const { doc } = compile('.card {\n color: red\n hover { color: green }\n color: blue\n}');
52
+ ok(declsOf(doc, '.card').some(d => d.value === 'red'), 'pre-block decl kept');
53
+ ok(declsOf(doc, '.card').some(d => d.value === 'blue'), 'post-block decl kept');
54
+ ok(declsOf(doc, '.card:hover').some(d => d.value === 'green'), 'nested decl kept');
55
+ }
56
+ /* 2 β€” raw hex alpha: #ff0000/50 is color-mix, never /50px */
57
+ {
58
+ const { doc } = compile('.card { background: #ff0000/50 }');
59
+ const v = declsOf(doc, '.card')[0]?.value;
60
+ ok(v === 'color-mix(in oklab, #ff0000 50%, transparent)', 'raw hex /50 becomes color-mix, got: ' + v);
61
+ }
62
+ /* 3 β€” time properties: bare numbers are ms */
63
+ {
64
+ const { doc } = compile('.card { transition-delay: 200; animation-duration: 0.5 }');
65
+ const d = declsOf(doc, '.card');
66
+ ok(d.some(x => x.prop === 'transition-delay' && x.value === '200ms'), 'transition-delay: 200 -> 200ms');
67
+ ok(d.some(x => x.prop === 'animation-duration' && x.value === '0.5ms' || x.prop === 'animation-duration' && x.value === '500ms'), 'animation-duration bare -> ms, got: ' + JSON.stringify(d));
68
+ }
69
+ /* 4 β€” grid shorthand: gap is a first-class word */
70
+ {
71
+ const { doc } = compile('.grid { grid: 3 gap 12 }');
72
+ const d = declsOf(doc, '.grid');
73
+ ok(d.some(x => x.prop === 'grid-template-columns' && x.value === 'repeat(3, 1fr)'), 'grid: 3 -> repeat(3, 1fr)');
74
+ ok(d.some(x => x.prop === 'gap' && x.value === '12px'), 'grid gap 12 -> gap: 12px');
75
+ const { doc: d2 } = compile('.grid { grid: 2 rows 3 gap 8 }');
76
+ const d2l = declsOf(d2, '.grid');
77
+ ok(d2l.some(x => x.prop === 'grid-template-rows' && x.value === 'repeat(3, 1fr)'), 'grid rows 3 -> template rows');
78
+ }
79
+ /* 5 β€” seen { } with arrow is a LOUD error pointing at the real patterns */
80
+ {
81
+ const { doc } = compile('.card { seen { opacity: 0 -> 1 } }');
82
+ ok(doc.errors.some(e => e.message.includes('->') && e.hint.includes('animate')), 'seen arrow -> loud error with animate hint');
83
+ const { doc: d2 } = compile('motion rise { from { opacity: 0 } }\n.card { seen { animate: rise 400 } }');
84
+ ok(d2.errors.length === 0, 'seen { animate } stays legal');
85
+ }
86
+ /* 6 β€” unknown nested state word warns instead of silently becoming a descendant */
87
+ {
88
+ const { doc } = compile('.card { hovr { background: red } }');
89
+ ok(doc.warnings.some(w => w.message.includes('hovr') && w.hint.includes('hover')), 'hovr typo warns with did-you-mean');
90
+ const { doc: d2 } = compile('.card { title { color: red } }');
91
+ ok(!d2.warnings.some(w => w.message.includes('title')), 'legit descendant word does not warn');
92
+ }
93
+ /* 7 β€” SVG + modern properties pass through */
94
+ {
95
+ const { doc } = compile('.card { fill: red; stroke: blue; stroke-width: 2; text-wrap: balance; content-visibility: auto }');
96
+ const d = declsOf(doc, '.card');
97
+ for (const [p, v] of [['fill', 'red'], ['stroke', 'blue'], ['stroke-width', '2px'], ['text-wrap', 'balance'], ['content-visibility', 'auto']]) {
98
+ ok(d.some(x => x.prop === p && x.value === v), `property ${p}: ${v} resolves`);
99
+ }
100
+ }
101
+ /* 8 β€” ::part() rides the shadow pipeline, ::slotted() is a loud error */
102
+ {
103
+ const { doc } = compile('my-card::part(header) { background: red }');
104
+ ok(doc.errors.length === 0, '::part() compiles');
105
+ ok(doc.shadowRules.some(r => r.sel === 'my-card' && r.pseudo.includes('part(header)')), '::part() rides shadowRules');
106
+ const { doc: d2 } = compile('my-card::slotted(p) { background: red }');
107
+ ok(d2.errors.some(e => e.message.includes('shadow tree')), '::slotted() gets the shadow-tree explanation');
108
+ }
109
+ /* 9 β€” one-line decls + nested block on the same line */
110
+ {
111
+ const { doc, parseErrors } = compile('.card { background: red; hover { background: blue } }');
112
+ ok(parseErrors.length === 0, 'one-line mix parses');
113
+ ok(declsOf(doc, '.card').some(d => d.value === 'red'), 'one-line: base decl survives');
114
+ ok(declsOf(doc, '.card:hover').some(d => d.value === 'blue'), 'one-line: hover decl survives');
115
+ }
116
+ /* 10 β€” stateReach: the hover scoping math */
117
+ {
118
+ ok(stateReach('.card:hover', 'hover') === null, 'subject-state reach = null (chain only)');
119
+ const d = stateReach('.card:hover .title', 'hover');
120
+ ok(d && d.prefix === '.card' && d.rest === '.title', 'descendant reach: prefix .card rest .title');
121
+ const s = stateReach('.tab:hover + .panel', 'hover');
122
+ ok(s && s.sib === '+' && s.firstSeg === '.panel', 'sibling reach: + .panel');
123
+ const f = stateReach('.card:has(:hover) .x', 'hover');
124
+ ok(f && f.fallback === true, ':has() wrapper falls back to page-wide superset');
125
+ const deep = stateReach('.list .card:hover .title', 'hover');
126
+ ok(deep && deep.prefix === '.list .card' && deep.rest === '.title', 'deep ancestor prefix');
127
+ }
128
+ /* 11 β€” gatePass still duck-types on fake elements (unchanged contract) */
129
+ {
130
+ ok(gatePass({ tagName: 'div', classList: { contains: () => true } }, null) === true, 'gatePass null gate = true');
131
+ }
132
+ /* 12 β€” version */
133
+ {
134
+ ok(VERSION === '0.7.2', 'engine VERSION is 0.7.2');
135
+ ok(STATE_WORDS.has('hover') && STATE_WORDS.has('seen'), 'STATE_WORDS exported');
136
+ }
137
+
138
+ console.log(`${pass} passed, ${fail} failed`);
139
+ process.exitCode = fail ? 1 : 0;