Z User commited on
Commit
c3fdf10
Β·
1 Parent(s): b468333

v0.8.0 the exit drop: row/column axis roles (between center bug), unknown at-rules skip balanced (@font-face crash, @supports leak), url() atomic, !important loud, animation/transition teaching errors; raw { } escape hatch; dssl CLI (check + build) + DSSL.toCSS static emitter; headless-Chromium docs tests; fluid sugar; engine packages + 0.8.0 vsix (engine verified byte-identical inside)

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitignore +3 -0
  2. .vercelignore +16 -0
  3. AGENTS.md +0 -0
  4. Caddyfile +23 -0
  5. GUIDE.md +76 -9
  6. PARITY.md +40 -2
  7. README.md +37 -10
  8. cli/dssl.mjs +175 -0
  9. dssl-vscode/.vscodeignore +0 -0
  10. dssl-vscode/LICENSE +0 -0
  11. dssl-vscode/PUBLISHING.md +0 -0
  12. dssl-vscode/README.md +0 -0
  13. dssl-vscode/{dssl-vscode-0.7.2.vsix β†’ dssl-vscode-0.8.0.vsix} +0 -0
  14. dssl-vscode/extension.js +0 -0
  15. dssl-vscode/icon.png +0 -0
  16. dssl-vscode/language-configuration.json +0 -0
  17. dssl-vscode/lib/analysis.js +0 -0
  18. dssl-vscode/lib/engine/parser.js +128 -5
  19. dssl-vscode/lib/engine/resolver.js +293 -44
  20. dssl-vscode/lib/props-docs.js +0 -0
  21. dssl-vscode/package.json +1 -1
  22. dssl-vscode/syntaxes/dssl.tmLanguage.json +0 -0
  23. engine-package/USAGE.md +119 -0
  24. engine-package/hello.dssl +39 -0
  25. engine-package/install.sh +19 -0
  26. examples/websocket/frontend.tsx +196 -0
  27. examples/websocket/server.ts +138 -0
  28. llms.txt +19 -6
  29. public/dssl-engine.tar.gz +0 -0
  30. public/dssl-engine.zip +0 -0
  31. public/dssl/engine.js +10 -0
  32. public/dssl/index.js +15 -2
  33. public/dssl/parser.js +90 -6
  34. public/dssl/resolver.js +189 -23
  35. public/dssl/static.js +133 -0
  36. public/llms.txt +22 -8
  37. public/styles/bench.dssl +0 -0
  38. public/styles/site.dssl +2 -1
  39. scripts/build-engine-package.sh +3 -2
  40. scripts/repro-v072.mjs +0 -0
  41. scripts/repro-v073.mjs +91 -0
  42. scripts/test-extension-lib.mjs +44 -0
  43. scripts/test-guide-browser.mjs +142 -0
  44. scripts/test-guide-examples.mjs +0 -0
  45. scripts/test-v072.mjs +4 -1
  46. scripts/test-v073.mjs +217 -0
  47. src/components/dssl/SiteRoot.tsx +2 -2
  48. src/components/dssl/views/BenchView.tsx +0 -0
  49. src/components/dssl/views/ChangelogView.tsx +44 -0
  50. src/components/dssl/views/SpecView.tsx +1 -1
.gitignore CHANGED
@@ -58,3 +58,6 @@ server.log
58
 
59
  .env
60
  mini-services/
 
 
 
 
58
 
59
  .env
60
  mini-services/
61
+
62
+ # local screenshots, never published
63
+ .zscripts/
.vercelignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /download/
2
+ /upload/
3
+ /tool-results/
4
+ /scripts/
5
+ /tests/
6
+ /examples/
7
+ /mini-services/
8
+ /db/
9
+ /prisma/
10
+ /node_modules/
11
+ /worklog.md
12
+ /*.tar.gz
13
+ /dev.log
14
+ /server.log
15
+ /.vercel
16
+ /package/
AGENTS.md CHANGED
File without changes
Caddyfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :81 {
2
+ @transform_port_query {
3
+ query XTransformPort=*
4
+ }
5
+
6
+ handle @transform_port_query {
7
+ reverse_proxy localhost:{query.XTransformPort} {
8
+ header_up Host {host}
9
+ header_up X-Forwarded-For {remote_host}
10
+ header_up X-Forwarded-Proto {scheme}
11
+ header_up X-Real-IP {remote_host}
12
+ }
13
+ }
14
+
15
+ handle {
16
+ reverse_proxy localhost:3000 {
17
+ header_up Host {host}
18
+ header_up X-Forwarded-For {remote_host}
19
+ header_up X-Forwarded-Proto {scheme}
20
+ header_up X-Real-IP {remote_host}
21
+ }
22
+ }
23
+ }
GUIDE.md CHANGED
@@ -109,6 +109,44 @@ Token overrides can be scoped under `@media` or `@signal` for themes.
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
@@ -211,7 +249,7 @@ for liveness. `unfreeze()` re-arms everything.
211
 
212
  Three ways to get it:
213
 
214
- 1. Download `dssl-vscode/dssl-vscode-0.7.2.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).
@@ -222,10 +260,31 @@ 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)
@@ -242,16 +301,24 @@ run the sync script so the editor extension ships the same compiler.
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.
 
109
  .hero { center: both }
110
  ```
111
 
112
+ Each place word owns an axis (v0.8.0 β€” the example above used to silently
113
+ lose the `between` because every word wrote both axes):
114
+
115
+ - `between`, `around`, `evenly` are main-axis words (`justify-content`)
116
+ - `stretch`, `baseline` are cross-axis words (`align-items`)
117
+ - `center`, `start`, `end` fill whichever axes are still free
118
+
119
+ One distribution word + one alignment word is the whole grammar. A word
120
+ whose axes are already taken warns loudly ("has nowhere to go") instead of
121
+ overwriting, and a word written twice warns too.
122
+
123
+ ### Sizing that moves: fluid
124
+
125
+ `font-size: fluid 16 24` (and any length-ish property: width, height, gap,
126
+ padding, margin…) compiles to the linear `clamp()` between 360px and 1200px
127
+ viewports β€” the formula everyone writes wrong by hand. Custom bounds:
128
+ `font-size: fluid 16 24 400 800`. The comma form still works: `fluid: 16, 24`.
129
+
130
+ ### raw { } β€” the escape hatch
131
+
132
+ DSSL has no sugar for `@font-face`, `@keyframes`, `@supports`, `@import` or
133
+ `@property` yet. `raw { }` (alias `@css { }`) drops verbatim CSS into the
134
+ page stylesheet, top-level only:
135
+
136
+ ```dssl
137
+ raw {
138
+ @font-face {
139
+ font-family: Inter
140
+ src: url(/fonts/inter.woff2)
141
+ }
142
+ }
143
+ ```
144
+
145
+ Unknown at-rules outside `raw { }` are skipped whole, with one clean error
146
+ and a pointer here β€” they never leak their inner rules (that was a real
147
+ 0.7.2 bug: `@supports (display:grid) { .a { … } }` applied `.a` everywhere
148
+ after erroring).
149
+
150
  ### The cascade is (layer, order)
151
 
152
  No specificity, no `!important`. Later wins inside a layer. Layers sort
 
249
 
250
  Three ways to get it:
251
 
252
+ 1. Download `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files, then
253
  Extensions panel, `...`, Install from VSIX. Works in VS Code and VSCodium.
254
  2. Build it: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
255
  3. Registry listing: staged for Open VSX (the registry VSCodium searches).
 
260
  completions folded from your workspace token registry, hover docs, a TextMate
261
  grammar for `.dssl`.
262
 
263
+ ### The exit lane: dssl check + dssl build
264
+
265
+ v0.8.0 answers the honest limits below without giving up the language:
266
+
267
+ ```sh
268
+ node cli/dssl.mjs check styles.dssl --strict # CI: real compiler, exit 1 on errors, --json
269
+ node cli/dssl.mjs build styles.dssl -o styles.css
270
+ ```
271
+
272
+ `build` compiles the static subset to a real stylesheet: tokens become
273
+ custom properties, states become `:hover`, `@media`/`@container` pass
274
+ through, layers become `@layer`, motions become `@keyframes`, `pin` becomes
275
+ `!important`, `raw { }` passes through verbatim, and `seen` applies its
276
+ final state unconditionally (the graceful no-JS look). What needs the
277
+ runtime stays out and gets counted: `@signal` rules and `animate:`.
278
+ The same output is available in the browser as `DSSL.toCSS(source)`.
279
+
280
  ## 6. Tooling and tests
281
 
282
  ```sh
283
+ node cli/dssl.mjs check public/styles/*.dssl # compile everything, exit code for CI
284
+ node scripts/test-v073.mjs # 0.8.0 regression suite (65 assertions)
285
+ node scripts/test-guide-examples.mjs # every guide example compiles
286
+ node scripts/test-guide-browser.mjs # guide examples asserted in headless Chromium
287
+ node scripts/test-v072.mjs # 0.7.2 regression suite (40 assertions)
288
  node scripts/test-v071.mjs # parser line-number suite
289
  node scripts/test-v070.mjs # v0.7 feature suite (60)
290
  node scripts/test-v050.mjs # v0.5 feature suite (61)
 
301
 
302
  Read this before choosing DSSL for a real site.
303
 
304
+ - **JavaScript is required** for the live engine. No JS, no styles at runtime.
305
+ v0.8.0 adds the exit lane: `dssl build styles.dssl -o styles.css` compiles
306
+ the static subset to a real stylesheet you can `<link>` instead of the
307
+ engine β€” tokens as custom properties, states as `:hover`, layers as
308
+ `@layer`, motions as `@keyframes`, `raw { }` verbatim. What genuinely needs
309
+ JS (`@signal`, `animate:`, deform, overlap) stays out and is counted in the
310
+ report, so you always know what a no-JS page gives up. If a page must work
311
+ without JS AND uses the dynamic features, use CSS.
312
  - **First paint.** Styles are applied by the engine after it loads, so large
313
+ pages can flash unstyled content (CLS). The build output fixes this for
314
+ static pages (real CSS paints before any JS). For live pages, the docs site
315
+ solves it by inlining its stylesheets into the SSR HTML. The planned
316
+ prerender mode (server renders the final inline styles) is the real fix
317
+ and is on the PARITY roadmap as the next big item.
318
  - **CSP.** Pseudo-elements compile to one real stylesheet the engine injects;
319
  strict `style-src 'self'` blocks inline style attributes and that sheet.
320
+ DSSL wants `unsafe-inline` for styles today β€” or use the `dssl build`
321
+ output, which is a normal external stylesheet and CSP-clean.
322
  - **Interop is invasive.** DSSL normal declarations beat external CSS, and
323
  `pin` beats external `!important`. Mixing with an existing CSS design
324
  system works but the engine wins by design.
PARITY.md CHANGED
@@ -28,9 +28,9 @@ The point of this table is to stop two bad arguments: "DSSL is missing everythin
28
  | print styles (`@media print`) | not implemented | planned | |
29
  | `!important` | by design no | by-design-no | `pin { }` is the must-win block; specificity wars are the thing DSSL deletes |
30
  | Cascade | (layer, source order) | shipped | later wins; identical selectors resolve by order, deterministically |
31
- | Static extraction / view-source styling | live engine only today; prerender snapshot planned | planned | freeze() already makes big static pages zero-cost after first paint |
32
  | DevTools story | `DSSL.explain(el)` returns the cascade as data | shipped | matched rules with file/line, winning declaration per property, pseudo rules; browser-extension panel planned |
33
- | Work without JavaScript | not possible for first paint (today) | planned (prerender) | the honest trade; content pages should wait for prerender or pick another tool |
34
 
35
  ## The exit story
36
 
@@ -39,6 +39,14 @@ Abandoning DSSL leaves you: a DOM of valid inline styles, one small `<style>` el
39
  keeps working with the engine removed. No other styling replacement can say that, because
40
  no other replacement styles with the platform's own primitives.
41
 
 
 
 
 
 
 
 
 
42
  ## What DSSL adds that CSS does not have
43
 
44
  - live tokens and signals: change one value or fire one bridge call, the whole DOM re-resolves
@@ -49,6 +57,36 @@ no other replacement styles with the platform's own primitives.
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
 
28
  | print styles (`@media print`) | not implemented | planned | |
29
  | `!important` | by design no | by-design-no | `pin { }` is the must-win block; specificity wars are the thing DSSL deletes |
30
  | Cascade | (layer, source order) | shipped | later wins; identical selectors resolve by order, deterministically |
31
+ | Static extraction / view-source styling | `dssl build` emits real CSS (v0.8.0); prerender snapshot for live pages planned | partially shipped | freeze() already makes big static pages zero-cost after first paint |
32
  | DevTools story | `DSSL.explain(el)` returns the cascade as data | shipped | matched rules with file/line, winning declaration per property, pseudo rules; browser-extension panel planned |
33
+ | Work without JavaScript | `dssl build` emits the static subset as real CSS (v0.8.0); `@signal`/`animate:` stay runtime-only and counted | shipped for the static subset; prerender snapshot still planned for live pages | the honest trade has an exit now |
34
 
35
  ## The exit story
36
 
 
39
  keeps working with the engine removed. No other styling replacement can say that, because
40
  no other replacement styles with the platform's own primitives.
41
 
42
+ And since v0.8.0 there is a second exit: `dssl build styles.dssl -o styles.css`
43
+ compiles the static subset to a real stylesheet before you ever ship β€” no runtime,
44
+ no inline styles, CSP-clean, works without JavaScript. Tokens become custom properties,
45
+ states become pseudo-classes, layers become `@layer`, motions become `@keyframes`,
46
+ `pin` becomes `!important`, `raw { }` passes through verbatim, `seen` applies its final
47
+ state. `@signal` rules and `animate:` stay runtime-only and are counted in the report,
48
+ so the boundary is explicit, not folklore.
49
+
50
  ## What DSSL adds that CSS does not have
51
 
52
  - live tokens and signals: change one value or fire one bridge call, the whole DOM re-resolves
 
57
  - loud errors with did-you-mean, and a warnings channel for cascade-order footguns
58
  - `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
59
 
60
+ ## v0.8.0 review-driven corrections
61
+
62
+ The second external review (repro snippets, headless browser) found five bugs and
63
+ asked for the static build first. All fixed or shipped:
64
+
65
+ - `row: gap 8 between center` β€” the guide's own example β€” kept its `between`
66
+ (every place word used to write both axes; the later word overwrote
67
+ `justify-content` silently). Place words now own an axis: between/around/evenly
68
+ are main-axis, stretch/baseline cross-axis, center/start/end fill free axes;
69
+ conflicts and duplicates warn loudly.
70
+ - `@font-face { ... }` crashed the compiler with an uncaught throw. Unknown
71
+ at-rules now skip their entire balanced block with one clean error.
72
+ - `@supports (...) { .a { ... } }` used to error AND still apply `.a`
73
+ unconditionally. The whole block is skipped now; nothing leaks.
74
+ - Unquoted `url(a.png)` false-errored ("unknown value a.png"); `url()` is
75
+ atomic now, and the missing background words (`no-repeat`, `repeat-x/y`,
76
+ `space`, `round`, `scroll`, `local`) joined the vocabulary.
77
+ - `color: red !important` used to error AND emit `red !important` (which the
78
+ browser then silently dropped). `!important` is now one loud error, nothing
79
+ is emitted, and the hint names `pin { }` as the actual answer.
80
+ - `animation: spin ...` suggested "did you mean span"; it now passes motion
81
+ names through, warns when the motion is undefined, and bare durations are ms.
82
+ - `transition: color .2s ease` (CSS order) is an error whose hint shows the
83
+ exact DSSL conversion (`transition: 200 ease color`).
84
+
85
+ And the wishlist items that shipped: `raw { }` / `@css { }` verbatim passthrough,
86
+ `dssl build` + `dssl check` (the CLI, with `--json` and CI exit codes),
87
+ `DSSL.toCSS()`, headless-Chromium computed-style tests for every guide example
88
+ (`scripts/test-guide-browser.mjs`), and `font-size: fluid 16 24` clamp sugar.
89
+
90
  ## v0.7.2 review-driven corrections
91
 
92
  An external agent review reproduced a batch of real bugs; all of them are fixed
README.md CHANGED
@@ -6,7 +6,7 @@ license: mit
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
 
11
  Elements that show up later get styled the moment they appear. Change a rule and the page updates on the next pass. The language is the runtime.
12
 
@@ -68,7 +68,7 @@ Both drop a `dssl-engine/` folder next to you: the engine, a starter token set,
68
 
69
  Prefer to click: [dssl-engine.zip](https://dssl-sable.vercel.app/dssl-engine.zip) or [dssl-engine.tar.gz](https://dssl-sable.vercel.app/dssl-engine.tar.gz). Same package, straight from the browser.
70
 
71
- Writing your own .dssl? The official editor extension (`ehs-studio.dssl-vscode`) puts the real compiler in your editor: the squiggles are the same errors the browser console prints, completions come from your own token registry, and hovers explain the semantic vocabulary. One package for VS Code and VSCodium (same extension API): download `dssl-vscode/dssl-vscode-0.7.2.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. An Open VSX listing (so it appears in VSCodium's Extensions tab, searchable) is staged and publishes with one Open VSX token; see `dssl-vscode/PUBLISHING.md`.
72
 
73
  AI agents and machine reviewers: this repo ships `llms.txt` (dense, structured facts), `AGENTS.md` (how to work on the codebase) and `PARITY.md` (the honest feature matrix against CSS). The site serves the same `llms.txt` at https://dssl-sable.vercel.app/llms.txt
74
 
@@ -369,6 +369,28 @@ DSSL.version // '0.7.0'
369
 
370
  Auto-boot picks up every `link[rel="stylesheet/dssl"]` and `style[type="text/dssl"]`, applies them, and fires `dssl-ready` on `window`. `<html data-dssl-reset>` enables the small built-in reset.
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  ## Performance: freeze, explain, and the cost of live
373
 
374
  DSSL is a runtime. It walks the DOM, matches rules, and writes inline styles. That buys live tokens, live signals and live layout, and it costs more than a static stylesheet by design. Three things tip the ledger back:
@@ -398,10 +420,10 @@ const rep = DSSL.explain(document.querySelector('.doc-card'))
398
 
399
  The matcher also pre-filters every rule through a cheap class/tag/id gate extracted from the selector's rightmost compound, so a recompute skips obvious non-matches instead of asking the browser rule by rule. The gate only ever says no; anything it does not fully understand falls back to native matching.
400
 
401
- ## What works today (v0.7.1)
402
 
403
  - the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
404
- - flexbox and grid with `row:` / `column:` / `grid:` / `stack:` / `center:` sugar
405
  - behaviors and `use`
406
  - design tokens, numeric keys included, scoped per file and per subtree
407
  - states, engine-native `seen`, `stagger` choreography
@@ -410,25 +432,30 @@ The matcher also pre-filters every rule through a cheap class/tag/id gate extrac
410
  - gradient sentences: `gradient`, `gradient-text`, `gradient-flow`
411
  - the overlap policy and `pointer-events` pass-through for veils
412
  - pseudo-elements in any spelling (shadow CSS), themed scrollbars included
413
- - compiler warnings for cascade-order footguns
414
- - `DSSL.freeze()` / `DSSL.unfreeze()` / `DSSL.explain()`
 
 
 
415
  - bare font names, `hyphens: manual`, loud did-you-mean errors
416
  - honest line numbers: a v0.7.1 parser bug (every `//` comment shifted later lines +1, so diagnostics pointed one line late) is fixed and regression-tested
417
  - the editor extension bundles the real compiler, so editor squiggles and browser console agree
 
418
 
419
  ## Repo layout
420
 
421
- - `public/dssl/` the engine: parser, resolver, engine, boot
422
  - `public/styles/` the styles that drive the site
 
423
  - `package/` what ships in the download: engine, starter files, usage guide, installers
424
  - `src/` the Next.js site (docs, playground, changelog)
425
- - `scripts/` smoke tests for the engine
426
  - `dssl-vscode/` the editor extension (VS Code + VSCodium, bundles the real parser/resolver)
427
- - `llms.txt`, `AGENTS.md`, `PARITY.md` for machine reviewers and contributors
428
 
429
  ## Status
430
 
431
- v0.7.1, experimental, moving fast. The language grows whenever the site needs something it cannot express yet, so the site doubles as the test suite. Honest ledger: great for creative sites, prototypes, docs, playgrounds and internal tools; if a page must work without JavaScript, a style runtime is not the right tool for it. Freeze makes big static pages viable; live interaction is what the engine is for.
432
 
433
  ## License
434
 
 
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 compiles at runtime β€” and since v0.8.0 there is also an exit lane: `dssl build` compiles the static subset of the same source to a real `.css` file for pages that must work without JavaScript or sit behind a strict CSP. You choose per page: live engine, built CSS, or both.
10
 
11
  Elements that show up later get styled the moment they appear. Change a rule and the page updates on the next pass. The language is the runtime.
12
 
 
68
 
69
  Prefer to click: [dssl-engine.zip](https://dssl-sable.vercel.app/dssl-engine.zip) or [dssl-engine.tar.gz](https://dssl-sable.vercel.app/dssl-engine.tar.gz). Same package, straight from the browser.
70
 
71
+ Writing your own .dssl? The official editor extension (`ehs-studio.dssl-vscode`) puts the real compiler in your editor: the squiggles are the same errors the browser console prints, completions come from your own token registry, and hovers explain the semantic vocabulary. One package for VS Code and VSCodium (same extension API): download `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. An Open VSX listing (so it appears in VSCodium's Extensions tab, searchable) is staged and publishes with one Open VSX token; see `dssl-vscode/PUBLISHING.md`.
72
 
73
  AI agents and machine reviewers: this repo ships `llms.txt` (dense, structured facts), `AGENTS.md` (how to work on the codebase) and `PARITY.md` (the honest feature matrix against CSS). The site serves the same `llms.txt` at https://dssl-sable.vercel.app/llms.txt
74
 
 
369
 
370
  Auto-boot picks up every `link[rel="stylesheet/dssl"]` and `style[type="text/dssl"]`, applies them, and fires `dssl-ready` on `window`. `<html data-dssl-reset>` enables the small built-in reset.
371
 
372
+ ## The exit lane: dssl check + dssl build (v0.8.0)
373
+
374
+ The same compiler that runs in your browser runs in your terminal:
375
+
376
+ ```sh
377
+ node cli/dssl.mjs check styles.dssl --strict # CI: exit 1 on errors, --json for machines
378
+ node cli/dssl.mjs build styles.dssl -o styles.css
379
+ ```
380
+
381
+ `build` emits real CSS from the static subset: tokens become custom properties,
382
+ states become `:hover`, `@media`/`@container` pass through, layers become
383
+ `@layer`, motions become `@keyframes`, `pin` becomes `!important`, `raw { }`
384
+ passes through verbatim, and `seen` applies its final state (the graceful
385
+ no-JS look). What genuinely needs the runtime (`@signal`, `animate:`, deform,
386
+ overlap) stays out and gets counted in the report, so the boundary is explicit
387
+ instead of folklore.
388
+
389
+ Two more tools from v0.8.0: `raw { }` (alias `@css { }`) drops verbatim CSS
390
+ like `@font-face` or `@keyframes` into the page stylesheet, and
391
+ `font-size: fluid 16 24` compiles to the linear `clamp()` between 360 and
392
+ 1200px viewports.
393
+
394
  ## Performance: freeze, explain, and the cost of live
395
 
396
  DSSL is a runtime. It walks the DOM, matches rules, and writes inline styles. That buys live tokens, live signals and live layout, and it costs more than a static stylesheet by design. Three things tip the ledger back:
 
420
 
421
  The matcher also pre-filters every rule through a cheap class/tag/id gate extracted from the selector's rightmost compound, so a recompute skips obvious non-matches instead of asking the browser rule by rule. The gate only ever says no; anything it does not fully understand falls back to native matching.
422
 
423
+ ## What works today (v0.8.0)
424
 
425
  - the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
426
+ - flexbox and grid with `row:` / `column:` / `grid:` / `stack:` / `center:` sugar β€” place words now own an axis (`between` is main-axis, `stretch` is cross-axis) and conflicts warn instead of silently overwriting
427
  - behaviors and `use`
428
  - design tokens, numeric keys included, scoped per file and per subtree
429
  - states, engine-native `seen`, `stagger` choreography
 
432
  - gradient sentences: `gradient`, `gradient-text`, `gradient-flow`
433
  - the overlap policy and `pointer-events` pass-through for veils
434
  - pseudo-elements in any spelling (shadow CSS), themed scrollbars included
435
+ - `raw { }` / `@css { }` for verbatim CSS (`@font-face`, `@keyframes`, `@supports`); unknown at-rules skip their whole block with one clean error
436
+ - `fluid 16 24` sizing sugar on any length property
437
+ - the CLI: `dssl check` (CI, --json) and `dssl build` (real CSS from the static subset)
438
+ - compiler warnings for cascade-order footguns and duplicate axis words
439
+ - `DSSL.freeze()` / `DSSL.unfreeze()` / `DSSL.explain()` / `DSSL.toCSS()`
440
  - bare font names, `hyphens: manual`, loud did-you-mean errors
441
  - honest line numbers: a v0.7.1 parser bug (every `//` comment shifted later lines +1, so diagnostics pointed one line late) is fixed and regression-tested
442
  - the editor extension bundles the real compiler, so editor squiggles and browser console agree
443
+ - every guide example is asserted in headless Chromium (computed styles, real mouse hover, real IntersectionObserver), so docs examples cannot drift from the engine again
444
 
445
  ## Repo layout
446
 
447
+ - `public/dssl/` the engine: parser, resolver, engine, boot, and `static.js` (the build target)
448
  - `public/styles/` the styles that drive the site
449
+ - `cli/dssl.mjs` the CLI: `check` + `build`, no dependencies
450
  - `package/` what ships in the download: engine, starter files, usage guide, installers
451
  - `src/` the Next.js site (docs, playground, changelog)
452
+ - `scripts/` smoke tests, regression suites, and the headless-browser docs tests
453
  - `dssl-vscode/` the editor extension (VS Code + VSCodium, bundles the real parser/resolver)
454
+ - `llms.txt`, `AGENTS.md`, `PARITY.md`, `GUIDE.md` for machine reviewers and contributors
455
 
456
  ## Status
457
 
458
+ v0.8.0, experimental, moving fast. The language grows whenever the site needs something it cannot express yet, so the site doubles as the test suite β€” and the docs examples are rendered and asserted in a real browser, so the guide cannot lie. Honest ledger: great for creative sites, prototypes, docs, playgrounds and internal tools; when a page must work without JavaScript, build the static subset with `dssl build` or pick another tool for that page. Freeze makes big static pages viable; live interaction is what the engine is for.
459
 
460
  ## License
461
 
cli/dssl.mjs ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* =========================================================================
3
+ * dssl β€” the command line for the DSSL style language
4
+ *
5
+ * dssl check styles.dssl [more.dssl …] [--json] [--strict] [--quiet]
6
+ * Compile files with the REAL parser + resolver (the same engine the
7
+ * browser runs). Prints file:line diagnostics with did-you-mean hints.
8
+ * Exit 1 on errors, 2 on usage problems. --strict makes warnings fatal.
9
+ * --json emits machine-readable diagnostics for CI and editors.
10
+ *
11
+ * dssl build styles.dssl [-o out.css] [--json] [--no-banner]
12
+ * The exit lane: compile the static subset to a real .css file.
13
+ * Tokens become custom properties, states become :hover, layers become
14
+ * @layer, motions become @keyframes, raw { } passes through verbatim.
15
+ * @signal rules and animate:/engine-native properties stay in the
16
+ * runtime β€” the report says exactly what was left behind.
17
+ *
18
+ * Files are compiled in the order given; every bundle's tokens fold into the
19
+ * next (name a tokens.dssl first, exactly like <link> order in the browser).
20
+ * ========================================================================= */
21
+ import { readFileSync, writeFileSync, statSync } from 'node:fs';
22
+ import { dirname, resolve as pathResolve, basename, join } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ // the engine is plain ESM in public/dssl β€” import directly, no build step
26
+ const here = pathResolve(dirname(fileURLToPath(import.meta.url)), '..');
27
+ const { parse } = await import('file://' + join(here, 'public/dssl/parser.js'));
28
+ const { resolve } = await import('file://' + join(here, 'public/dssl/resolver.js'));
29
+ const { toCSS } = await import('file://' + join(here, 'public/dssl/static.js'));
30
+ const { VERSION } = await import('file://' + join(here, 'public/dssl/index.js'));
31
+
32
+ const USAGE = `dssl v${VERSION} β€” the DSSL style language
33
+
34
+ dssl check <files…> compile with the real engine, print diagnostics
35
+ --json machine-readable output
36
+ --strict warnings are errors too (exit 1)
37
+ --quiet only print diagnostics, no summary
38
+ dssl build <file> compile the static subset to a real .css file
39
+ -o <out.css> output path (default: <file>.css)
40
+ --json machine-readable report
41
+ --no-banner omit the generated-file header
42
+ dssl --version print the version`;
43
+
44
+ const readArg = (args, flag) => {
45
+ const i = args.indexOf(flag);
46
+ if (i === -1) return null;
47
+ args.splice(i, 1);
48
+ return args.splice(i, 1)[0] || null;
49
+ };
50
+
51
+ const readSource = (file) => {
52
+ try {
53
+ statSync(file);
54
+ return readFileSync(file, 'utf8');
55
+ } catch {
56
+ console.error(`dssl: cannot read ${file}`);
57
+ process.exit(2);
58
+ }
59
+ };
60
+
61
+ /** compile files in order, folding tokens forward like the browser does */
62
+ function compileAll(files) {
63
+ const out = [];
64
+ let tokenBase = null;
65
+ for (const file of files) {
66
+ const src = readSource(file);
67
+ const name = basename(file);
68
+ const { ast, errors: parseErrors } = parse(src, name);
69
+ let doc;
70
+ try {
71
+ doc = resolve(ast, name, tokenBase);
72
+ } catch (e) {
73
+ // the compiler must never throw β€” this is a bug report if it happens
74
+ doc = { rules: [], shadowRules: [], raws: [], errors: [{ file: name, line: 0, message: `internal compiler error: ${e.message}`, hint: 'please report this β€” a snippet that crashes the compiler is a bug in DSSL, not in your code' }], warnings: [], tokens: {} };
75
+ }
76
+ const all = [...parseErrors.map(e => ({ ...e, file: name })), ...doc.errors.map(e => ({ ...e, file: e.file || name }))];
77
+ out.push({ file, name, doc, errors: all, warnings: doc.warnings || [] });
78
+ tokenBase = { ...(tokenBase || {}), ...doc.tokens };
79
+ }
80
+ return out;
81
+ }
82
+
83
+ const printDiags = (results, quiet) => {
84
+ let errCount = 0, warnCount = 0;
85
+ for (const r of results) {
86
+ for (const e of r.errors) {
87
+ errCount++;
88
+ console.error(`${e.file || r.name}:${e.line ?? 0} β€” error β€” ${e.message}${e.hint ? `\n ${e.hint}` : ''}`);
89
+ }
90
+ for (const w of r.warnings) {
91
+ warnCount++;
92
+ console.error(`${w.file || r.name}:${w.line ?? 0} β€” warning β€” ${w.message}${w.hint ? `\n ${w.hint}` : ''}`);
93
+ }
94
+ }
95
+ if (!quiet && results.length > 1) {
96
+ console.error(`\n${results.length} file(s), ${errCount} error(s), ${warnCount} warning(s)`);
97
+ }
98
+ return { errCount, warnCount };
99
+ };
100
+
101
+ const args = process.argv.slice(2);
102
+ const cmd = args.shift();
103
+
104
+ if (!cmd || cmd === '--help' || cmd === '-h') {
105
+ console.log(USAGE);
106
+ process.exit(cmd ? 0 : 2);
107
+ }
108
+ if (cmd === '--version' || cmd === '-v') {
109
+ console.log(VERSION);
110
+ process.exit(0);
111
+ }
112
+
113
+ if (cmd === 'check') {
114
+ const json = args.includes('--json');
115
+ const strict = args.includes('--strict');
116
+ const quiet = args.includes('--quiet');
117
+ for (const f of ['--json', '--strict', '--quiet']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
118
+ if (!args.length) { console.error('dssl check: give me at least one .dssl file'); process.exit(2); }
119
+ const results = compileAll(args);
120
+ if (json) {
121
+ console.log(JSON.stringify({
122
+ version: VERSION,
123
+ files: results.map(r => ({
124
+ file: r.file,
125
+ ok: r.errors.length === 0 && (!strict || r.warnings.length === 0),
126
+ errors: r.errors.map(e => ({ file: e.file || r.name, line: e.line ?? 0, message: e.message, hint: e.hint || null })),
127
+ warnings: r.warnings.map(w => ({ file: w.file || r.name, line: w.line ?? 0, message: w.message, hint: w.hint || null })),
128
+ })),
129
+ }, null, 2));
130
+ } else {
131
+ printDiags(results, quiet);
132
+ }
133
+ const bad = results.some(r => r.errors.length || (strict && r.warnings.length));
134
+ process.exit(bad ? 1 : 0);
135
+ }
136
+
137
+ if (cmd === 'build') {
138
+ const json = args.includes('--json');
139
+ const banner = !args.includes('--no-banner');
140
+ for (const f of ['--json', '--no-banner']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
141
+ const outPath = readArg(args, '-o') || readArg(args, '--out');
142
+ if (!args.length) { console.error('dssl build: give me a .dssl file to build'); process.exit(2); }
143
+ const input = args[0];
144
+ const results = compileAll([input]);
145
+ const r = results[0];
146
+ const built = toCSS(r.doc, { file: r.name, banner });
147
+ const outputPath = outPath || (input.replace(/\.dssl$/i, '') + '.css');
148
+ if (json) {
149
+ console.log(JSON.stringify({
150
+ version: VERSION,
151
+ input: r.file,
152
+ output: outputPath,
153
+ ok: r.errors.length === 0,
154
+ errors: r.errors.map(e => ({ file: e.file || r.name, line: e.line ?? 0, message: e.message, hint: e.hint || null })),
155
+ warnings: r.warnings.map(w => ({ file: w.file || r.name, line: w.line ?? 0, message: w.message, hint: w.hint || null })),
156
+ report: built.report,
157
+ css: built.css,
158
+ }, null, 2));
159
+ } else {
160
+ writeFileSync(outputPath, built.css);
161
+ const rep = built.report;
162
+ console.log(`built ${outputPath} from ${r.file}`);
163
+ console.log(` ${rep.rules} rule(s), ${rep.shadowRules} pseudo-element rule(s), ${rep.keyframes} @keyframes, ${rep.raws} raw block(s)`);
164
+ if (rep.layers.length) console.log(` layers: ${rep.layers.join(' < ')}`);
165
+ if (rep.seenStripped) console.log(` ${rep.seenStripped} seen rule(s) applied unconditionally (the no-JS state)`);
166
+ if (rep.skippedSignals || rep.skippedEngine) {
167
+ console.log(` stays in the runtime: ${rep.skippedSignals} @signal rule(s), ${rep.skippedEngine} engine-native rule(s)`);
168
+ }
169
+ printDiags(results, true);
170
+ }
171
+ process.exit(r.errors.length ? 1 : 0);
172
+ }
173
+
174
+ console.error(`dssl: unknown command "${cmd}"\n\n${USAGE}`);
175
+ process.exit(2);
dssl-vscode/.vscodeignore CHANGED
File without changes
dssl-vscode/LICENSE CHANGED
File without changes
dssl-vscode/PUBLISHING.md CHANGED
File without changes
dssl-vscode/README.md CHANGED
File without changes
dssl-vscode/{dssl-vscode-0.7.2.vsix β†’ dssl-vscode-0.8.0.vsix} RENAMED
Binary files a/dssl-vscode/dssl-vscode-0.7.2.vsix and b/dssl-vscode/dssl-vscode-0.8.0.vsix differ
 
dssl-vscode/extension.js CHANGED
File without changes
dssl-vscode/icon.png CHANGED
dssl-vscode/language-configuration.json CHANGED
File without changes
dssl-vscode/lib/analysis.js CHANGED
File without changes
dssl-vscode/lib/engine/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 = [];
@@ -67,6 +86,20 @@ function splitTop(str, sep) {
67
  return out;
68
  }
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  /** strip comments (respecting strings), newlines preserved so lines survive */
71
  function stripComments(src) {
72
  let out = '', q = null;
@@ -115,6 +148,7 @@ export function parse(source, file = 'inline.dssl') {
115
  const h = header.trim();
116
  if (h === 'tokens') return { kind: 'tokens' };
117
  if (h === 'pin') return { kind: 'pin' };
 
118
  if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
119
  if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
120
  if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
@@ -122,7 +156,13 @@ export function parse(source, file = 'inline.dssl') {
122
  if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
123
  if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
124
  if (h.startsWith('@')) {
125
- err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'try @media, @container, @signal or @layer');
 
 
 
 
 
 
126
  return { kind: 'bogus' };
127
  }
128
  if (h.startsWith('::')) {
@@ -158,12 +198,34 @@ 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: [] };
164
  stack[stack.length - 1].children.push(node);
165
- if (meta.kind !== 'bogus') stack.push(node);
166
- rest = rest.slice(open + 1);
 
167
  continue;
168
  }
169
 
@@ -172,6 +234,63 @@ export function parse(source, file = 'inline.dssl') {
172
  }
173
  };
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  const processContent = (content, line) => {
176
  for (const piece of splitTop(content, ';')) {
177
  const part = piece.trim();
@@ -245,7 +364,8 @@ export function parse(source, file = 'inline.dssl') {
245
  lines.forEach((raw, idx) => {
246
  const line = idx + 1;
247
  const trimmed = raw.trim();
248
- if (!trimmed) return; // blank lines never split a pending chunk
 
249
  const candidate = buf ? buf + ' ' + trimmed : raw;
250
  if (buf && needsMore(candidate)) { buf = candidate; return; }
251
  if (buf) { processChunk(candidate, bufLine || line); buf = ''; return; }
@@ -253,6 +373,9 @@ export function parse(source, file = 'inline.dssl') {
253
  processChunk(candidate, line);
254
  });
255
  if (buf.trim()) processChunk(buf, bufLine);
 
 
 
256
 
257
  if (stack.length > 1) {
258
  err(lines.length, `${stack.length - 1} block(s) were never closed with "}"`, 'every "{" needs a matching "}"');
 
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 = [];
 
86
  return out;
87
  }
88
 
89
+ /** find index of the matching close brace for the '{' just before `from`
90
+ * (string-aware). Returns -1 when the block is never closed. */
91
+ function matchBrace(str, from) {
92
+ let depth = 1, q = null;
93
+ for (let i = from; i < str.length; i++) {
94
+ const c = str[i];
95
+ if (q) { if (c === q && str[i - 1] !== '\\') q = null; continue; }
96
+ if (c === '"' || c === "'") { q = c; continue; }
97
+ if (c === '{') depth++;
98
+ else if (c === '}') { depth--; if (depth === 0) return i; }
99
+ }
100
+ return -1;
101
+ }
102
+
103
  /** strip comments (respecting strings), newlines preserved so lines survive */
104
  function stripComments(src) {
105
  let out = '', q = null;
 
148
  const h = header.trim();
149
  if (h === 'tokens') return { kind: 'tokens' };
150
  if (h === 'pin') return { kind: 'pin' };
151
+ if (h === 'raw' || h === '@css') return { kind: 'raw' };
152
  if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
153
  if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
154
  if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
 
156
  if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
157
  if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
158
  if (h.startsWith('@')) {
159
+ // v0.8.0 β€” unknown at-rules are skipped WHOLE (balanced braces). They
160
+ // used to error and then keep parsing inside their block, so the inner
161
+ // rules leaked out unconditionally: `@supports (display:grid) { .a { … } }
162
+ // applied .a everywhere. The block is now dropped in its entirety β€”
163
+ // and raw { } is the documented escape hatch for CSS DSSL has no sugar
164
+ // for yet (@font-face, @keyframes, @supports, @import…).
165
+ err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'the whole block is skipped β€” for verbatim CSS like @font-face / @keyframes / @supports, wrap it in raw { }');
166
  return { kind: 'bogus' };
167
  }
168
  if (h.startsWith('::')) {
 
198
 
199
  if (open !== -1) {
200
  const header = rest.slice(0, open).trim();
201
+ // v0.7.2 β€” declarations and a nested block sharing one line:
202
+ // `.card { background: red; hover { ... } }`. Everything before the
203
+ // header used to be swallowed whole into it, so `background: red`
204
+ // silently became part of a selector named "background: red; hover"
205
+ // and vanished. Declarations before the LAST top-level ';' are now
206
+ // processed as declarations; the header is what follows them.
207
+ if (header) {
208
+ const semi = scanForLast(header, ';');
209
+ if (semi !== -1) {
210
+ processContent(header.slice(0, semi), line);
211
+ const realHeader = header.slice(semi + 1).trim();
212
+ if (!realHeader) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
213
+ const meta = classify(realHeader, line);
214
+ const node = { ...meta, line, children: [] };
215
+ stack[stack.length - 1].children.push(node);
216
+ const after = openBlock(meta, node, rest, open, line);
217
+ if (after === null) { rest = rest.slice(open + 1); if (meta.kind !== 'bogus') stack.push(node); continue; }
218
+ rest = after;
219
+ continue;
220
+ }
221
+ }
222
  if (!header) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
223
  const meta = classify(header, line);
224
  const node = { ...meta, line, children: [] };
225
  stack[stack.length - 1].children.push(node);
226
+ const after = openBlock(meta, node, rest, open, line);
227
+ if (after === null) { rest = rest.slice(open + 1); if (meta.kind !== 'bogus') stack.push(node); continue; }
228
+ rest = after;
229
  continue;
230
  }
231
 
 
234
  }
235
  };
236
 
237
+ /** v0.8.0 β€” what happens right after a block header opens.
238
+ * - raw { } (top level): capture the block body VERBATIM as node.css and
239
+ * return the remainder of the chunk β€” or enter multi-line capture mode
240
+ * (returns '' with rawCapture armed).
241
+ * - bogus (unknown at-rule / pseudo-element): skip the whole balanced
242
+ * block so its content can never leak into the parent.
243
+ * - anything else: return null so the caller pushes the node and keeps
244
+ * parsing normally. */
245
+ let rawCapture = null; // { node, depth } while a multi-line raw { } is open
246
+ const openBlock = (meta, node, rest, open, line) => {
247
+ if (meta.kind === 'raw') {
248
+ if (stack.length > 1) {
249
+ err(line, 'raw { } must be top-level β€” it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
250
+ const close = matchBrace(rest, open + 1);
251
+ return close === -1 ? '' : rest.slice(close + 1);
252
+ }
253
+ const close = matchBrace(rest, open + 1);
254
+ if (close !== -1) {
255
+ node.css = rest.slice(open + 1, close);
256
+ return rest.slice(close + 1);
257
+ }
258
+ rawCapture = { node, depth: 1 };
259
+ return '';
260
+ }
261
+ if (meta.kind === 'bogus') {
262
+ const close = matchBrace(rest, open + 1);
263
+ if (close === -1) {
264
+ err(line, `the "${(meta.sel || '').slice(0, 30) || 'block'}" block was never closed with "}"`, 'every "{" needs a matching "}"');
265
+ return '';
266
+ }
267
+ return rest.slice(close + 1);
268
+ }
269
+ return null;
270
+ };
271
+
272
+ /** consume one source line while a multi-line raw { } is being captured */
273
+ const feedRawCapture = (raw, line) => {
274
+ let q = null;
275
+ for (let i = 0; i < raw.length; i++) {
276
+ const c = raw[i];
277
+ if (q) { if (c === q && raw[i - 1] !== '\\') q = null; continue; }
278
+ if (c === '"' || c === "'") { q = c; continue; }
279
+ if (c === '{') rawCapture.depth++;
280
+ else if (c === '}') {
281
+ rawCapture.depth--;
282
+ if (rawCapture.depth === 0) {
283
+ rawCapture.node.css += raw.slice(0, i);
284
+ const rest = raw.slice(i + 1);
285
+ rawCapture = null;
286
+ if (rest.trim()) processChunk(rest, line);
287
+ return;
288
+ }
289
+ }
290
+ }
291
+ rawCapture.node.css += raw + '\n';
292
+ };
293
+
294
  const processContent = (content, line) => {
295
  for (const piece of splitTop(content, ';')) {
296
  const part = piece.trim();
 
364
  lines.forEach((raw, idx) => {
365
  const line = idx + 1;
366
  const trimmed = raw.trim();
367
+ if (!trimmed) { if (rawCapture) rawCapture.node.css += raw + '\n'; return; } // blank lines never split a pending chunk
368
+ if (rawCapture) { feedRawCapture(raw, line); return; }
369
  const candidate = buf ? buf + ' ' + trimmed : raw;
370
  if (buf && needsMore(candidate)) { buf = candidate; return; }
371
  if (buf) { processChunk(candidate, bufLine || line); buf = ''; return; }
 
373
  processChunk(candidate, line);
374
  });
375
  if (buf.trim()) processChunk(buf, bufLine);
376
+ if (rawCapture) {
377
+ err(lines.length, 'the raw { } block was never closed with "}"', 'every "{" needs a matching "}" β€” the CSS captured so far is discarded');
378
+ }
379
 
380
  if (stack.length > 1) {
381
  err(lines.length, `${stack.length - 1} block(s) were never closed with "}"`, 'every "{" needs a matching "}"');
dssl-vscode/lib/engine/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
@@ -45,6 +45,10 @@ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,
45
  'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
46
  'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
47
  'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
 
 
 
 
48
  'contain,cover,fill,fit-content,min-content,max-content,minmax,repeat,clamp,calc,var,url,env,attr,' +
49
  'auto-fill,auto-fit,subgrid,dense,stretch,span,' +
50
  'scaleX,scaleY,translateX,translateY,translate,rotate,skew,skewX,skewY,matrix,' +
@@ -68,11 +72,23 @@ 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 +98,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 +198,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
  }
@@ -208,6 +224,15 @@ function resolveValue(raw, ctx) {
208
  }
209
  const inner = raw.slice(i + 1, j - 1);
210
  const fname = ident.toLowerCase();
 
 
 
 
 
 
 
 
 
211
  // v0.3.2 fix: repeat(COUNT, tracks) β€” the leading count is an integer,
212
  // never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
213
  // which the browser rejected and the whole grid silently collapsed.
@@ -267,8 +292,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;
@@ -469,7 +504,21 @@ function buildGradient(v, ctx) {
469
 
470
  function parseFlexWords(value, ctx, dir) {
471
  const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  const atoms = value.split(/\s+/).filter(Boolean);
 
473
  for (let k = 0; k < atoms.length; k++) {
474
  const a = atoms[k];
475
  if (a === 'gap') {
@@ -482,9 +531,36 @@ function parseFlexWords(value, ctx, dir) {
482
  if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; }
483
  if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; }
484
  if (a === 'inline') { out[0].value = 'inline-flex'; continue; }
485
- if (PLACE_WORDS[a]) {
486
- out.push({ prop: 'align-items', value: PLACE_WORDS[a][0] });
487
- out.push({ prop: 'justify-content', value: PLACE_WORDS[a][1] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  continue;
489
  }
490
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown word "${a}" in ${dir}`, hint: didYouMean(a, ['gap', 'center', 'start', 'end', 'between', 'around', 'evenly', 'stretch', 'baseline', 'wrap', 'nowrap', 'reverse', 'inline']) });
@@ -510,15 +586,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();
@@ -579,17 +671,7 @@ const SEMANTIC = {
579
  return out;
580
  },
581
  fluid(v, ctx) {
582
- const parts = v.split(',').map(s => s.trim());
583
- const px = (s) => {
584
- const m = String(s).match(/^([0-9.]+)\s*(px|rem|em|%)?$/);
585
- if (!m) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" is not a size` }); return '0px'; }
586
- return m[2] ? `${m[1]}${m[2]}` : `${m[1]}px`;
587
- };
588
- const min = px(parts[0]); const max = px(parts[1]);
589
- const vmin = px(parts[2] || '360'); const vmax = px(parts[3] || '1200');
590
- const mv = parseFloat(min), xv = parseFloat(max);
591
- const slope = ((xv - mv) / (parseFloat(vmax) - parseFloat(vmin))) * 100;
592
- return [{ prop: 'font-size', value: `clamp(${min}, calc(${min} + ${slope.toFixed(3)}vw), ${max})` }];
593
  },
594
  shadow(v, ctx) {
595
  const t = v.trim();
@@ -663,6 +745,22 @@ const SEMANTIC = {
663
  const atoms = part.split(/\s+/);
664
  const dur = atoms[0];
665
  if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration Β· easing Β· property β€” try 200 or 0.2s' });
667
  continue;
668
  }
@@ -842,13 +940,51 @@ const SEMANTIC = {
842
  },
843
  };
844
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
845
  /* ------------------------------------------------------------------ *
846
  * resolveDecl β€” one declaration β†’ zero or more engine decls
847
  * ------------------------------------------------------------------ */
848
 
849
  function resolveDecl(prop, value, ctx) {
850
  const errors = ctx.errors;
851
- const ctx2 = { ...ctx, prop, errors };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
 
853
  if (prop.startsWith('--')) {
854
  // v0.4.0 β€” scoped tokens. Dotted names are token re-declarations:
@@ -865,6 +1001,22 @@ function resolveDecl(prop, value, ctx) {
865
  const sem = SEMANTIC[prop];
866
  if (sem) return sem(value, ctx2);
867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
868
  if (!KNOWN_PROPS.has(prop)) {
869
  errors.push({
870
  file: ctx.file, line: ctx.line,
@@ -876,6 +1028,19 @@ function resolveDecl(prop, value, ctx) {
876
  return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
877
  }
878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879
  /* ------------------------------------------------------------------ *
880
  * Media / container / signal normalization
881
  * ------------------------------------------------------------------ */
@@ -926,6 +1091,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
926
  const errors = [];
927
  const rules = [];
928
  const shadowRules = [];
 
929
  const motions = {};
930
  const behaviors = {};
931
  // v0.7.0 β€” compile-time warnings: things that compile and run but are
@@ -965,7 +1131,10 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
965
  } else if (node.kind === 'motion') {
966
  motions[node.name] = node;
967
  } else {
968
- for (const child of node.children) if (child.kind !== 'decl') prepass([child]);
 
 
 
969
  }
970
  }
971
  };
@@ -984,7 +1153,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
984
  const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
985
  return m ? parseFloat(m[1]) : null;
986
  };
987
- const ctxBase = { file, line: node.line, errors, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
988
  for (const child of node.children) {
989
  if (child.kind === 'decl' && child.prop === 'essential') {
990
  node._parsed.essential = child.value === '' || child.value === 'true';
@@ -1041,7 +1210,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1041
 
1042
  const resolveTokenDecls = (keys, conds) => {
1043
  const decls = [];
1044
- const ctxBase = { file, line: 0, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false };
1045
  for (const key of keys) {
1046
  const raw = tokenMap.get(key);
1047
  const prop = '--dssl-' + key.replace(/\./g, '-');
@@ -1064,6 +1233,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': {
@@ -1080,7 +1265,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1080
  bh._active = false;
1081
  break;
1082
  }
1083
- const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
1084
  for (const r of resolved) {
1085
  // v0.7.0 β€” remember WHICH source property wrote a display value,
1086
  // so the ordering linter can tell an explicit "display:" from a
@@ -1091,6 +1276,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 +1293,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 +1313,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': {
@@ -1148,7 +1358,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1148
  for (const d of node.children) {
1149
  if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1150
  if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1151
- const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1152
  // v0.5.0 β€” engine-native properties have no pseudo-element story:
1153
  // there is no DOM handle to nudge, animate, stack or stagger. They
1154
  // used to leak into the shadow sheet as junk CSS the browser
@@ -1174,6 +1384,14 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1174
  if (tr) rules.push(tr);
1175
  break;
1176
  }
 
 
 
 
 
 
 
 
1177
  case 'behavior':
1178
  case 'motion':
1179
  if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` });
@@ -1214,7 +1432,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
  }
@@ -1283,7 +1509,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1283
  flush(rootRule, topCtx, 0);
1284
 
1285
  // parse all motions that were referenced
1286
- for (const r of rules) {
1287
  for (const d of r.decls) {
1288
  if (d.prop === '@animate' && d.value && d.value.motion) {
1289
  const m = parseMotion(d.value.motion);
@@ -1291,7 +1517,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1291
  errors.push({
1292
  file, line: d.line,
1293
  message: `unknown motion "${d.value.motion}"`,
1294
- hint: didYouMean(d.value.motion, Object.keys(motions)),
1295
  });
1296
  d.value.broken = true;
1297
  } else {
@@ -1299,13 +1525,36 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1299
  d.value.essential = m.essential;
1300
  }
1301
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1302
  }
1303
  }
1304
 
 
 
 
 
 
 
 
 
1305
  const mediaQueries = new Set();
1306
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1307
 
1308
- return { rules, shadowRules, motions, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1309
  }
1310
 
1311
  /** naive `&` substitution + descendant join */
 
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
 
45
  'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
46
  'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
47
  'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
48
+ // v0.8.0 β€” the background repeat/attachment words the url() fix exposed:
49
+ // `background: url(a.png) no-repeat` was a false error because "no-repeat"
50
+ // was missing while "repeat" existed
51
+ 'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' +
52
  'contain,cover,fill,fit-content,min-content,max-content,minmax,repeat,clamp,calc,var,url,env,attr,' +
53
  'auto-fill,auto-fit,subgrid,dense,stretch,span,' +
54
  'scaleX,scaleY,translateX,translateY,translate,rotate,skew,skewX,skewY,matrix,' +
 
72
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
73
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
74
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
75
+ 'shape-image-threshold',
76
  // grid line placement takes integers, not lengths: grid-column: span 2
77
  'grid-row', 'grid-column', 'grid-area',
78
  ]);
79
 
80
+ // v0.7.2 β€” time properties: a bare number is milliseconds, never px, and
81
+ // never unitless (both used to emit values the browser silently rejected).
82
+ // transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is
83
+ // why `transition-delay: 200` shipped as invalid unitless 200.
84
+ const TIME_PROPS = new Set([
85
+ 'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
86
+ // v0.8.0 β€” the animation shorthand takes bare numbers as ms too:
87
+ // `animation: spin 1s infinite` is fine, but `animation: ghost 400` used
88
+ // to become "ghost 400px" (invalid). 400 β†’ 400ms, matching animate:.
89
+ 'animation',
90
+ ]);
91
+
92
  // A practical whitelist of standard CSS properties (loud errors beat silent ones)
93
  const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
94
  'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
 
98
  '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,' +
99
  'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
100
  '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,' +
101
+ '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,' +
102
+ '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,' +
103
  '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,' +
104
  '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,' +
105
  '-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(','));
 
198
  unit = raw.slice(j, k).toLowerCase();
199
  j = k;
200
  }
201
+ out += num + (unit ? unit : (ctx.unitless ? '' : TIME_PROPS.has(ctx.prop) ? 'ms' : 'px'));
202
  i = j;
203
  continue;
204
  }
 
224
  }
225
  const inner = raw.slice(i + 1, j - 1);
226
  const fname = ident.toLowerCase();
227
+ // v0.8.0 β€” url() is atomic. The validator used to tokenize INSIDE it,
228
+ // so `url(a.png)` raised "unknown value a.png" (quoted urls worked
229
+ // only because strings were already consumed whole). CSS allows the
230
+ // unquoted form; DSSL passes it through untouched now.
231
+ if (fname === 'url') {
232
+ out += 'url(' + inner.trim() + ')';
233
+ i = j;
234
+ continue;
235
+ }
236
  // v0.3.2 fix: repeat(COUNT, tracks) β€” the leading count is an integer,
237
  // never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
238
  // which the browser rejected and the whole grid silently collapsed.
 
292
  if (c === '#') { // hex color
293
  let j = i + 1;
294
  while (j < n && /[0-9a-fA-F]/.test(raw[j])) j++;
295
+ const hex = raw.slice(i, j);
296
+ out += hex;
297
  i = j;
298
+ // v0.7.2 β€” alpha on a raw hex color: #ff0000/50 used to become
299
+ // #ff0000/50px (the bare number got the generic px treatment) β€” invalid
300
+ // CSS, silently dropped by the browser. Tokens always had /NN via
301
+ // color-mix; raw hex now gets the same treatment.
302
+ const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
303
+ if (am) {
304
+ out = out.slice(0, out.length - hex.length) + `color-mix(in oklab, ${hex} ${am[1]}%, transparent)`;
305
+ i += am[0].length;
306
+ }
307
  continue;
308
  }
309
  out += c;
 
504
 
505
  function parseFlexWords(value, ctx, dir) {
506
  const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
507
+ // v0.8.0 β€” axis roles. Every place word used to write BOTH axes, so
508
+ // `row: gap 8 between center` (the guide's own example) compiled to
509
+ // justify-content: space-between followed by justify-content: center β€”
510
+ // and center won, bunching everything in the middle. Words now declare
511
+ // the axis they are actually about: distribution words (between/around/
512
+ // evenly) own the main axis, stretch/baseline own the cross axis, and
513
+ // center/start/end fill whichever axes are still free. A word whose axes
514
+ // are all taken is a loud warning instead of a silent overwrite.
515
+ const FLEX_AXIS = {
516
+ between: 'main', around: 'main', evenly: 'main',
517
+ stretch: 'cross', baseline: 'cross',
518
+ center: 'both', start: 'both', end: 'both',
519
+ };
520
  const atoms = value.split(/\s+/).filter(Boolean);
521
+ let mainSet = null, crossSet = null;
522
  for (let k = 0; k < atoms.length; k++) {
523
  const a = atoms[k];
524
  if (a === 'gap') {
 
531
  if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; }
532
  if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; }
533
  if (a === 'inline') { out[0].value = 'inline-flex'; continue; }
534
+ if (FLEX_AXIS[a]) {
535
+ const role = FLEX_AXIS[a];
536
+ const pc = PLACE_WORDS[a]; // [cross, main]
537
+ const axisWarn = (prop, prev) => {
538
+ ctx.warnings = ctx.warnings || [];
539
+ ctx.warnings.push(prev === a
540
+ ? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir} β€” the second copy wins`, hint: `drop the duplicate; one distribution word + one alignment word is the whole grammar` }
541
+ : { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win β€” drop one, or reorder so the distribution word comes first` });
542
+ };
543
+ let wrote = false;
544
+ if (role === 'main' || (role === 'both' && !mainSet)) {
545
+ if (mainSet) axisWarn('justify-content', mainSet);
546
+ out.push({ prop: 'justify-content', value: pc[1] });
547
+ mainSet = a; wrote = true;
548
+ }
549
+ if (role === 'cross' || (role === 'both' && !crossSet)) {
550
+ if (crossSet) axisWarn('align-items', crossSet);
551
+ out.push({ prop: 'align-items', value: pc[0] });
552
+ crossSet = a; wrote = true;
553
+ }
554
+ if (!wrote) {
555
+ ctx.warnings = ctx.warnings || [];
556
+ ctx.warnings.push({
557
+ file: ctx.file, line: ctx.line,
558
+ message: mainSet === a || crossSet === a
559
+ ? `"${a}" is written twice in ${dir} β€” both axes already came from it`
560
+ : `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`,
561
+ hint: 'one distribution word + one alignment word is the whole grammar β€” the extra word is dropped',
562
+ });
563
+ }
564
  continue;
565
  }
566
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown word "${a}" in ${dir}`, hint: didYouMean(a, ['gap', 'center', 'start', 'end', 'between', 'around', 'evenly', 'stretch', 'baseline', 'wrap', 'nowrap', 'reverse', 'inline']) });
 
586
  column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
587
  grid(v, ctx) {
588
  const atoms = v.split(/\s+/).filter(Boolean);
589
+ // v0.7.2 β€” `grid: 3 gap 12` (the documented form) used to die: the gap
590
+ // atoms were fed into grid-template-columns β†’ "3px gap 12px", invalid.
591
+ // gap and rows are now first-class words of the shorthand.
592
+ let gap = null, rows = null;
593
+ const rest = [];
594
+ for (let i = 0; i < atoms.length; i++) {
595
+ if (atoms[i] === 'gap' && atoms[i + 1]) { gap = atoms[++i]; continue; }
596
+ if (atoms[i] === 'rows' && atoms[i + 1]) { rows = atoms[++i]; continue; }
597
+ rest.push(atoms[i]);
598
+ }
599
  let cols;
600
+ if (!rest.length) {
601
+ 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' });
602
+ cols = 'none';
603
+ } else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) {
604
+ cols = `repeat(${rest[0]}, 1fr)`;
605
+ } else if ((rest[0] === 'auto-fill' || rest[0] === 'auto-fit') && rest[1]) {
606
+ cols = `repeat(${rest[0]}, minmax(${len(rest[1], ctx)}, 1fr))`;
607
  } else {
608
+ cols = rest.map(a => len(a, ctx)).join(' ');
609
  }
610
+ const out = [{ prop: 'display', value: 'grid' }, { prop: 'grid-template-columns', value: cols }];
611
+ if (gap) out.push({ prop: 'gap', value: len(gap, ctx) });
612
+ if (rows) out.push({ prop: 'grid-template-rows', value: /^[0-9]+$/.test(rows) ? `repeat(${rows}, 1fr)` : len(rows, ctx) });
613
+ return out;
614
  },
615
  stack(v, ctx) {
616
  const word = (v || 'center').trim();
 
671
  return out;
672
  },
673
  fluid(v, ctx) {
674
+ return buildFluidParts(v.split(',').map(s => s.trim()).filter(Boolean), ctx);
 
 
 
 
 
 
 
 
 
 
675
  },
676
  shadow(v, ctx) {
677
  const t = v.trim();
 
745
  const atoms = part.split(/\s+/);
746
  const dur = atoms[0];
747
  if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
748
+ // v0.8.0 β€” the CSS habit is property-first (`transition: color .2s
749
+ // ease`). That used to be a generic "not a time" error and the part
750
+ // silently dropped. The error now REWRITES the value into the DSSL
751
+ // order (time Β· easing Β· property) so the fix is a copy-paste.
752
+ const timeAtom = atoms.find(a => /^\d*\.?\d+(ms|s)?$/.test(a));
753
+ const easeAtom = atoms.find(a => EASINGS[a]);
754
+ const propAtom = atoms.find(a => a !== timeAtom && a !== easeAtom && (KNOWN_PROPS.has(a) || DEFORM_TO_TRANSFORM[a]));
755
+ if (timeAtom) {
756
+ const msOut = /^\d+$/.test(timeAtom) ? timeAtom : /ms$/.test(timeAtom) ? timeAtom.replace(/ms$/, '') : String(parseFloat(timeAtom) * 1000);
757
+ ctx.errors.push({
758
+ file: ctx.file, line: ctx.line,
759
+ message: `transition: the CSS form (property first) is not DSSL`,
760
+ hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''} β€” time first, easing, property last`,
761
+ });
762
+ continue;
763
+ }
764
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration Β· easing Β· property β€” try 200 or 0.2s' });
765
  continue;
766
  }
 
940
  },
941
  };
942
 
943
+ /* v0.8.0 β€” fluid sizing sugar. `fluid: 16, 24` keeps working (font-size),
944
+ * and now any length-ish property takes it inline: `font-size: fluid 16 24`.
945
+ * Both compile to a clamp() that scales linearly between the viewport bounds
946
+ * (360px and 1200px by default) β€” the formula everyone writes by hand and
947
+ * gets wrong. */
948
+ const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' +
949
+ 'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left').split(','));
950
+
951
+ function buildFluidParts(parts, ctx, prop = 'font-size') {
952
+ const px = (s) => {
953
+ const m = String(s).match(/^([0-9.]+)\s*(px|rem|em|%)?$/);
954
+ if (!m) {
955
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" is not a size`, hint: 'fluid takes two sizes and optionally the viewport bounds β€” fluid 16 24, or fluid 16 24 360 1200' });
956
+ return '0px';
957
+ }
958
+ return m[2] ? `${m[1]}${m[2]}` : `${m[1]}px`;
959
+ };
960
+ const min = px(parts[0]); const max = px(parts[1]);
961
+ const vmin = px(parts[2] || '360'); const vmax = px(parts[3] || '1200');
962
+ const mv = parseFloat(min), xv = parseFloat(max);
963
+ const slope = ((xv - mv) / (parseFloat(vmax) - parseFloat(vmin))) * 100;
964
+ return [{ prop, value: `clamp(${min}, calc(${min} + ${slope.toFixed(3)}vw), ${max})` }];
965
+ }
966
+
967
  /* ------------------------------------------------------------------ *
968
  * resolveDecl β€” one declaration β†’ zero or more engine decls
969
  * ------------------------------------------------------------------ */
970
 
971
  function resolveDecl(prop, value, ctx) {
972
  const errors = ctx.errors;
973
+ const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings };
974
+
975
+ // v0.8.0 β€” !important does not exist in DSSL. It used to fall through as a
976
+ // value: the compiler said "unknown value important" AND still emitted
977
+ // `red !important`, which the browser then silently dropped as an invalid
978
+ // inline value. Now: one loud error, nothing emitted, and the pin { }
979
+ // answer to the actual question.
980
+ if (hasImportant(value)) {
981
+ errors.push({
982
+ file: ctx.file, line: ctx.line,
983
+ message: `!important does not exist in DSSL β€” "${prop}: ${String(value).replace(/\s*!\s*important\s*/i, '')}" was NOT applied`,
984
+ hint: 'the cascade is (layer, order) and later wins β€” put the rule in pin { } when it genuinely must win',
985
+ });
986
+ return [];
987
+ }
988
 
989
  if (prop.startsWith('--')) {
990
  // v0.4.0 β€” scoped tokens. Dotted names are token re-declarations:
 
1001
  const sem = SEMANTIC[prop];
1002
  if (sem) return sem(value, ctx2);
1003
 
1004
+ // v0.8.0 β€” the animation shorthand passes through (it is legal CSS), but
1005
+ // bare motion names must not pay the generic unknown-value tax: "spin"
1006
+ // used to error with the surreal suggestion "did you mean span?". Motion
1007
+ // names pass silently here and a dedicated warning in the final loop says
1008
+ // the DSSL way (motion + animate) when the name matches nothing.
1009
+ if (prop === 'animation' || prop === 'animation-name') {
1010
+ return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }];
1011
+ }
1012
+
1013
+ // v0.8.0 β€” inline fluid sugar on any length-ish property:
1014
+ // font-size: fluid 16 24 β†’ clamp(16px, calc(16px + 0.952vw), 24px)
1015
+ const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim());
1016
+ if (fluidM && FLUID_PROPS.has(prop)) {
1017
+ return buildFluidParts(fluidM[1].split(/[\s,]+/).filter(Boolean), ctx2, prop);
1018
+ }
1019
+
1020
  if (!KNOWN_PROPS.has(prop)) {
1021
  errors.push({
1022
  file: ctx.file, line: ctx.line,
 
1028
  return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
1029
  }
1030
 
1031
+ /** string-aware !important scan β€” quoted content: "!important" must not trip it */
1032
+ function hasImportant(v) {
1033
+ const s = String(v);
1034
+ let q = null;
1035
+ for (let i = 0; i < s.length; i++) {
1036
+ const c = s[i];
1037
+ if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
1038
+ if (c === '"' || c === "'") { q = c; continue; }
1039
+ if (c === '!' && /\s*important/i.test(s.slice(i + 1))) return true;
1040
+ }
1041
+ return false;
1042
+ }
1043
+
1044
  /* ------------------------------------------------------------------ *
1045
  * Media / container / signal normalization
1046
  * ------------------------------------------------------------------ */
 
1091
  const errors = [];
1092
  const rules = [];
1093
  const shadowRules = [];
1094
+ const raws = []; // v0.8.0 β€” raw { } / @css { } verbatim passthrough blocks
1095
  const motions = {};
1096
  const behaviors = {};
1097
  // v0.7.0 β€” compile-time warnings: things that compile and run but are
 
1131
  } else if (node.kind === 'motion') {
1132
  motions[node.name] = node;
1133
  } else {
1134
+ // v0.8.0 β€” defensive `|| []`: a malformed tree can throw from here
1135
+ // and take the whole stylesheet down (the @font-face crash report).
1136
+ // The parser now skips unknown at-rules whole, but the guard stays.
1137
+ for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]);
1138
  }
1139
  }
1140
  };
 
1153
  const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
1154
  return m ? parseFloat(m[1]) : null;
1155
  };
1156
+ const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
1157
  for (const child of node.children) {
1158
  if (child.kind === 'decl' && child.prop === 'essential') {
1159
  node._parsed.essential = child.value === '' || child.value === 'true';
 
1210
 
1211
  const resolveTokenDecls = (keys, conds) => {
1212
  const decls = [];
1213
+ const ctxBase = { file, line: 0, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false };
1214
  for (const key of keys) {
1215
  const raw = tokenMap.get(key);
1216
  const prop = '--dssl-' + key.replace(/\./g, '-');
 
1233
  /* ---- main recursive walk ---- */
1234
 
1235
  const walk = (children, ctx, ctxRule) => {
1236
+ // v0.7.2 β€” parent-first flush. Two real bugs lived here. (1) A nested
1237
+ // block (hover { }, @media, a nested selector) was emitted BEFORE the
1238
+ // parent's own declarations got their cascade order, so `background: red`
1239
+ // on the parent always beat `hover { background: red }` nested inside it
1240
+ // β€” the exact pattern the docs taught, and the external review's headline
1241
+ // finding. (2) media/container/signal/layer blocks flushed with the
1242
+ // PARENT context, dropping the condition or layer entirely: a nested
1243
+ // `@media dark` was silently unconditional AND sorted before base.
1244
+ // Nested blocks now flush everything the parent has said so far first,
1245
+ // and every block flushes with the context it was actually walked with.
1246
+ const flushPending = (line) => {
1247
+ if (ctxRule && ctxRule.decls.length && ctx.sel) {
1248
+ flush(ctxRule, ctx, line);
1249
+ ctxRule.decls = [];
1250
+ }
1251
+ };
1252
  for (const node of children) {
1253
  switch (node.kind) {
1254
  case 'decl': {
 
1265
  bh._active = false;
1266
  break;
1267
  }
1268
+ const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
1269
  for (const r of resolved) {
1270
  // v0.7.0 β€” remember WHICH source property wrote a display value,
1271
  // so the ordering linter can tell an explicit "display:" from a
 
1276
  break;
1277
  }
1278
  case 'selector': {
1279
+ if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) {
1280
+ const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]);
1281
+ 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}>` });
1282
+ }
1283
+ flushPending(node.line);
1284
  const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
1285
  const childCtx = { ...ctx, sel };
1286
  const sub = { decls: [] };
 
1293
  errors.push({ file, line: node.line, message: `"${node.name}" needs a selector around it` });
1294
  break;
1295
  }
1296
+ flushPending(node.line);
1297
  const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
1298
+ // v0.7.2 β€” the arrow is motion/gradient-stop syntax; inside a seen
1299
+ // block it compiled into an inline style the browser rejects, so the
1300
+ // element just sat at its base style forever. Say so, loudly, and
1301
+ // point at the two real patterns (final value, or a motion).
1302
+ if (node.name === 'seen') {
1303
+ for (const d of node.children) {
1304
+ if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) {
1305
+ 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 })' });
1306
+ }
1307
+ }
1308
+ }
1309
  const sub = { decls: [] };
1310
  walk(node.children, childCtx, sub);
1311
  flush(sub, childCtx, node.line);
 
1313
  }
1314
  case 'media': {
1315
  const q = normalizeMedia(node.query, { errors, file, line: node.line }, node.line);
1316
+ flushPending(node.line);
1317
+ const childCtx = { ...ctx, media: [...ctx.media, q] };
1318
  const sub = { decls: [] };
1319
+ walk(node.children, childCtx, sub);
1320
+ flush(sub, childCtx, node.line);
1321
  break;
1322
  }
1323
  case 'container': {
1324
  const q = normalizeMedia(node.query || '(min-width: 0)', { errors, file, line: node.line }, node.line);
1325
+ flushPending(node.line);
1326
+ const childCtx = { ...ctx, containers: [...ctx.containers, q] };
1327
  const sub = { decls: [] };
1328
+ walk(node.children, childCtx, sub);
1329
+ flush(sub, childCtx, node.line);
1330
  break;
1331
  }
1332
  case 'signal': {
1333
  const sig = parseSignal(node.expr, { errors, file, line: node.line }, node.line);
1334
+ flushPending(node.line);
1335
+ const childCtx = { ...ctx, signals: [...ctx.signals, sig] };
1336
  const sub = { decls: [] };
1337
+ walk(node.children, childCtx, sub);
1338
+ flush(sub, childCtx, node.line);
1339
  break;
1340
  }
1341
  case 'layer':
1342
  case 'pin': {
1343
  const layer = node.kind === 'pin' ? 'pin' : (['tokens', 'base', 'components', 'utilities', 'pin'].includes(node.name) ? node.name : 'components');
1344
+ flushPending(node.line);
1345
+ const childCtx = { ...ctx, layer };
1346
  const sub = { decls: [] };
1347
+ walk(node.children, childCtx, sub);
1348
+ flush(sub, childCtx, node.line);
1349
  break;
1350
  }
1351
  case 'pseudo': {
 
1358
  for (const d of node.children) {
1359
  if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1360
  if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1361
+ const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1362
  // v0.5.0 β€” engine-native properties have no pseudo-element story:
1363
  // there is no DOM handle to nudge, animate, stack or stagger. They
1364
  // used to leak into the shadow sheet as junk CSS the browser
 
1384
  if (tr) rules.push(tr);
1385
  break;
1386
  }
1387
+ case 'raw': {
1388
+ // v0.8.0 β€” verbatim CSS escape hatch (@font-face, @keyframes,
1389
+ // @supports, @import, @property…). The parser captured the body
1390
+ // untouched; it rides the same document stylesheet as the
1391
+ // pseudo-element rules and survives `dssl build` verbatim.
1392
+ if ((node.css || '').trim()) raws.push({ css: node.css.trim(), file, line: node.line, order: order++ });
1393
+ break;
1394
+ }
1395
  case 'behavior':
1396
  case 'motion':
1397
  if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` });
 
1432
  errors.push({
1433
  file, line,
1434
  message: `unknown pseudo-element "::${pseudo}"`,
1435
+ hint: 'try ::before, ::after, ::selection, ::part(name), or a vendor one like ::-webkit-scrollbar-thumb',
1436
+ });
1437
+ continue;
1438
+ }
1439
+ if (name === 'slotted') {
1440
+ errors.push({
1441
+ file, line,
1442
+ message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM',
1443
+ hint: 'style the slotted element itself with a normal selector β€” it IS reachable from the page',
1444
  });
1445
  continue;
1446
  }
 
1509
  flush(rootRule, topCtx, 0);
1510
 
1511
  // parse all motions that were referenced
1512
+ for (const r of [...rules, ...shadowRules]) {
1513
  for (const d of r.decls) {
1514
  if (d.prop === '@animate' && d.value && d.value.motion) {
1515
  const m = parseMotion(d.value.motion);
 
1517
  errors.push({
1518
  file, line: d.line,
1519
  message: `unknown motion "${d.value.motion}"`,
1520
+ hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } } β€” then "animate: ${d.value.motion} 400" runs it`,
1521
  });
1522
  d.value.broken = true;
1523
  } else {
 
1525
  d.value.essential = m.essential;
1526
  }
1527
  }
1528
+ // v0.8.0 β€” the animation shorthand is CSS passthrough, so a motion
1529
+ // name that matches nothing used to be a SILENT no-op (no @keyframes
1530
+ // can exist in DSSL). Warn with the whole story; if the keyframes come
1531
+ // from raw { } or another stylesheet, the warning says it can be ignored.
1532
+ if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') {
1533
+ const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w));
1534
+ const named = words.find(w => motions[w]);
1535
+ if (words.length && !named) {
1536
+ warnings.push({
1537
+ file, line: d.line,
1538
+ message: `animation "${d.value}" references no motion defined in this sheet β€” it will not run`,
1539
+ hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400 β€” @keyframes does not exist in DSSL; raw { } can carry verbatim keyframes`,
1540
+ });
1541
+ }
1542
+ }
1543
  }
1544
  }
1545
 
1546
+ // v0.8.0 β€” pre-parse EVERY motion (not just referenced ones) so the static
1547
+ // emitter can turn the whole motion vocabulary into real @keyframes.
1548
+ const motionFrames = {};
1549
+ for (const name of Object.keys(motions)) {
1550
+ const m = parseMotion(name);
1551
+ if (m && m.frames.length) motionFrames[name] = m;
1552
+ }
1553
+
1554
  const mediaQueries = new Set();
1555
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1556
 
1557
+ return { rules, shadowRules, raws, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1558
  }
1559
 
1560
  /** naive `&` substitution + descendant join */
dssl-vscode/lib/props-docs.js CHANGED
File without changes
dssl-vscode/package.json CHANGED
@@ -2,7 +2,7 @@
2
  "name": "dssl-vscode",
3
  "displayName": "DSSL Language Support",
4
  "description": "The DSSL style language in your editor: syntax highlighting, real-compiler diagnostics (the same errors the browser console shows), completions from the live token registry, hover docs. Works in VS Code and VSCodium.",
5
- "version": "0.7.2",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
 
2
  "name": "dssl-vscode",
3
  "displayName": "DSSL Language Support",
4
  "description": "The DSSL style language in your editor: syntax highlighting, real-compiler diagnostics (the same errors the browser console shows), completions from the live token registry, hover docs. Works in VS Code and VSCodium.",
5
+ "version": "0.8.0",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
dssl-vscode/syntaxes/dssl.tmLanguage.json CHANGED
File without changes
engine-package/USAGE.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DSSL engine β€” quickstart
2
+
3
+ DSSL is a style language that runs in the browser. You write `.dssl` files, add two tags to your page, and the engine does the rest. No compiler, no build step, no CSS output. The engine walks your DOM live and writes styles straight onto the elements it matches, including elements that show up later.
4
+
5
+ ## 1. Get the engine
6
+
7
+ Copy the `dssl/` folder from this package into your project, or grab it fresh from a terminal:
8
+
9
+ ```sh
10
+ curl -fsSL https://dssl-sable.vercel.app/install.sh | sh
11
+ ```
12
+
13
+ That drops a `dssl-engine/` folder next to you with everything you need.
14
+
15
+ ## 2. Wire it up
16
+
17
+ ```html
18
+ <!doctype html>
19
+ <html data-dssl-reset>
20
+ <head>
21
+ <script type="module" src="/dssl/boot.js"></script>
22
+ <link rel="stylesheet/dssl" href="/styles/tokens.dssl">
23
+ <link rel="stylesheet/dssl" href="/examples/hello.dssl">
24
+ </head>
25
+ <body>
26
+ <h1>Styled by DSSL</h1>
27
+ </body>
28
+ </html>
29
+ ```
30
+
31
+ What those lines do:
32
+
33
+ - `data-dssl-reset` (optional) turns on the small built-in reset.
34
+ - `boot.js` is the whole install. It boots the engine on page load.
35
+ - Every `<link rel="stylesheet/dssl">` is fetched and applied automatically, in the order you list them. Tokens first, then your styles.
36
+ - Inline styles work too: `<style type="text/dssl"> ... </style>`.
37
+ - When booting is done the page fires a `dssl-ready` event.
38
+
39
+ ## 3. Write some DSSL
40
+
41
+ `examples/hello.dssl` in this package:
42
+
43
+ ```dssl
44
+ body {
45
+ background: #f5f0e4
46
+ color: #26201a
47
+ padding: 40
48
+ }
49
+
50
+ .card {
51
+ background: #fdfaf2
52
+ border: 1 solid #ddd2bc
53
+ radius: 6
54
+ padding: 24
55
+
56
+ hover {
57
+ box-shadow: 0 6 18 #26201a/16
58
+ }
59
+ }
60
+ ```
61
+
62
+ Bare numbers are pixels. States like `hover` are nested blocks. The engine keeps them working forever, even for elements added to the page later.
63
+
64
+ ## The JavaScript API
65
+
66
+ `window.DSSL` is available after boot:
67
+
68
+ - `DSSL.load(urls)` fetch and apply one or more `.dssl` files (fetched in parallel, applied in order)
69
+ - `DSSL.apply(source, { name, scope })` apply a source string
70
+ - `DSSL.compile(source)` parse and resolve without touching the DOM, returns `ok`, `errors` and stats
71
+ - `DSSL.signal(name, value)` drive styles from JS with `@signal` conditions in your `.dssl`
72
+ - `DSSL.refresh()` force a full recompute
73
+ - `DSSL.replay(selector)` replay entrance motions on matching elements
74
+ - `DSSL.errors` the last reported errors (also grouped in the console, with did-you-mean hints)
75
+ - `DSSL.version` engine version
76
+
77
+ ## Scoped engines
78
+
79
+ ```js
80
+ DSSL.apply(source, { scope: '#widget' })
81
+ ```
82
+
83
+ gives one subtree its own engine, its own tokens and its own cascade. The playground on the live site is built exactly this way.
84
+
85
+ ## The exit lane (v0.8.0)
86
+
87
+ The engine is a JS runtime. When a page needs to work without JavaScript, sit
88
+ behind a strict CSP, or paint before any script, build the static subset to a
89
+ real stylesheet:
90
+
91
+ ```sh
92
+ node cli/dssl.mjs check styles.dssl --strict # CI: the real compiler, exit 1 on errors, --json for machines
93
+ node cli/dssl.mjs build styles.dssl -o styles.css
94
+ ```
95
+
96
+ `build` emits real CSS: tokens become custom properties, states become
97
+ `:hover`, layers become `@layer`, motions become `@keyframes`, `pin` becomes
98
+ `!important`, `raw { }` passes through verbatim, and `seen` applies its final
99
+ state (the graceful no-JS look). The report counts what stays runtime-only
100
+ (`@signal` rules, `animate:`), so the boundary is explicit.
101
+
102
+ In the browser the same output is `DSSL.toCSS(source)` β†’ `{ css, report }`.
103
+
104
+ Two more v0.8.0 tools worth knowing: `raw { }` (alias `@css { }`) drops
105
+ verbatim CSS like `@font-face` into the page stylesheet, and
106
+ `font-size: fluid 16 24` compiles to the linear `clamp()` between 360 and
107
+ 1200px viewports.
108
+
109
+ ## Files in this package
110
+
111
+ - `dssl/` the engine. `boot.js` is the entry, `index.js` the public API, `static.js` the build-target compiler.
112
+ - `cli/dssl.mjs` the CLI (`check` + `build`), no dependencies, Node 18+
113
+ - `styles/tokens.dssl` the starter token set (colors, fonts, radii, shadows)
114
+ - `examples/hello.dssl` a tiny starter stylesheet
115
+ - `USAGE.md` this file
116
+ - `LICENSE` MIT
117
+
118
+ Live site: https://dssl-sable.vercel.app
119
+ Source and full language docs: https://huggingface.co/EHS-Studio/dssl
engine-package/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
+ }
engine-package/install.sh ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # DSSL engine installer
3
+ # curl -fsSL https://dssl-sable.vercel.app/install.sh | sh
4
+ set -e
5
+ BASE="https://dssl-sable.vercel.app"
6
+ ZIP="/tmp/dssl-engine-$$.zip"
7
+
8
+ command -v curl >/dev/null 2>&1 || { echo "DSSL installer: 'curl' is required."; exit 1; }
9
+ command -v unzip >/dev/null 2>&1 || { echo "DSSL installer: 'unzip' is required."; exit 1; }
10
+
11
+ echo "fetching the DSSL engine..."
12
+ curl -fsSL "$BASE/dssl-engine.zip" -o "$ZIP"
13
+ rm -rf dssl-engine
14
+ unzip -q "$ZIP" -d .
15
+ rm -f "$ZIP"
16
+
17
+ echo ""
18
+ echo "DSSL engine installed to ./dssl-engine"
19
+ echo "read dssl-engine/USAGE.md to wire it into a page."
examples/websocket/frontend.tsx ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { io } from 'socket.io-client';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
8
+ import { ScrollArea } from '@/components/ui/scroll-area';
9
+
10
+ type User = {
11
+ id: string;
12
+ username: string;
13
+ }
14
+
15
+ type Message = {
16
+ id: string;
17
+ username: string;
18
+ content: string;
19
+ timestamp: Date | string;
20
+ type: 'user' | 'system';
21
+ }
22
+
23
+ export default function SocketDemo() {
24
+ const [messages, setMessages] = useState<Message[]>([]);
25
+ const [inputMessage, setInputMessage] = useState('');
26
+ const [username, setUsername] = useState('');
27
+ const [isUsernameSet, setIsUsernameSet] = useState(false);
28
+ const [socket, setSocket] = useState<any>(null);
29
+ const [isConnected, setIsConnected] = useState(false);
30
+ const [users, setUsers] = useState<User[]>([]);
31
+
32
+ useEffect(() => {
33
+ // Connect to websocket server
34
+ // Never use PORT in the URL, alyways use XTransformPort
35
+ // DO NOT change the path, it is used by Caddy to forward the request to the correct port
36
+ const socketInstance = io('/?XTransformPort=3003', {
37
+ transports: ['websocket', 'polling'],
38
+ forceNew: true,
39
+ reconnection: true,
40
+ reconnectionAttempts: 5,
41
+ reconnectionDelay: 1000,
42
+ timeout: 10000
43
+ })
44
+
45
+ setSocket(socketInstance);
46
+
47
+ socketInstance.on('connect', () => {
48
+ setIsConnected(true);
49
+ });
50
+
51
+ socketInstance.on('disconnect', () => {
52
+ setIsConnected(false);
53
+ });
54
+
55
+ socketInstance.on('message', (msg: Message) => {
56
+ setMessages(prev => [...prev, msg]);
57
+ });
58
+
59
+ socketInstance.on('user-joined', (data: { user: User; message: Message }) => {
60
+ setMessages(prev => [...prev, data.message]);
61
+ setUsers(prev => {
62
+ if (!prev.find(u => u.id === data.user.id)) {
63
+ return [...prev, data.user];
64
+ }
65
+ return prev;
66
+ });
67
+ });
68
+
69
+ socketInstance.on('user-left', (data: { user: User; message: Message }) => {
70
+ setMessages(prev => [...prev, data.message]);
71
+ setUsers(prev => prev.filter(u => u.id !== data.user.id));
72
+ });
73
+
74
+ socketInstance.on('users-list', (data: { users: User[] }) => {
75
+ setUsers(data.users);
76
+ });
77
+
78
+ return () => {
79
+ socketInstance.disconnect();
80
+ };
81
+ }, []);
82
+
83
+ const handleJoin = () => {
84
+ if (socket && username.trim() && isConnected) {
85
+ socket.emit('join', { username: username.trim() });
86
+ setIsUsernameSet(true);
87
+ }
88
+ };
89
+
90
+ const sendMessage = () => {
91
+ if (socket && inputMessage.trim() && username.trim()) {
92
+ socket.emit('message', {
93
+ content: inputMessage.trim(),
94
+ username: username.trim()
95
+ });
96
+ setInputMessage('');
97
+ }
98
+ };
99
+
100
+ const handleKeyPress = (e: React.KeyboardEvent) => {
101
+ if (e.key === 'Enter') {
102
+ sendMessage();
103
+ }
104
+ };
105
+
106
+ return (
107
+ <div className="container mx-auto p-4 max-w-2xl">
108
+ <Card>
109
+ <CardHeader>
110
+ <CardTitle className="flex items-center justify-between">
111
+ WebSocket Demo
112
+ <span className={`text-sm px-2 py-1 rounded ${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
113
+ {isConnected ? 'Connected' : 'Disconnected'}
114
+ </span>
115
+ </CardTitle>
116
+ </CardHeader>
117
+ <CardContent className="space-y-4">
118
+ {!isUsernameSet ? (
119
+ <div className="space-y-2">
120
+ <Input
121
+ value={username}
122
+ onChange={(e) => setUsername(e.target.value)}
123
+ onKeyPress={(e) => {
124
+ if (e.key === 'Enter') {
125
+ handleJoin();
126
+ }
127
+ }}
128
+ placeholder="Enter your username..."
129
+ disabled={!isConnected}
130
+ className="flex-1"
131
+ />
132
+ <Button
133
+ onClick={handleJoin}
134
+ disabled={!isConnected || !username.trim()}
135
+ className="w-full"
136
+ >
137
+ Join Chat
138
+ </Button>
139
+ </div>
140
+ ) : (
141
+ <>
142
+ <ScrollArea className="h-80 w-full border rounded-md p-4">
143
+ <div className="space-y-2">
144
+ {messages.length === 0 ? (
145
+ <p className="text-gray-500 text-center">No messages yet</p>
146
+ ) : (
147
+ messages.map((msg) => (
148
+ <div key={msg.id} className="border-b pb-2 last:border-b-0">
149
+ <div className="flex justify-between items-start">
150
+ <div className="flex-1">
151
+ <p className={`text-sm font-medium ${msg.type === 'system'
152
+ ? 'text-blue-600 italic'
153
+ : 'text-gray-700'
154
+ }`}>
155
+ {msg.username}
156
+ </p>
157
+ <p className={`${msg.type === 'system'
158
+ ? 'text-blue-500 italic'
159
+ : 'text-gray-900'
160
+ }`}>
161
+ {msg.content}
162
+ </p>
163
+ </div>
164
+ <span className="text-xs text-gray-500">
165
+ {new Date(msg.timestamp).toLocaleTimeString()}
166
+ </span>
167
+ </div>
168
+ </div>
169
+ ))
170
+ )}
171
+ </div>
172
+ </ScrollArea>
173
+
174
+ <div className="flex space-x-2">
175
+ <Input
176
+ value={inputMessage}
177
+ onChange={(e) => setInputMessage(e.target.value)}
178
+ onKeyPress={handleKeyPress}
179
+ placeholder="Type a message..."
180
+ disabled={!isConnected}
181
+ className="flex-1"
182
+ />
183
+ <Button
184
+ onClick={sendMessage}
185
+ disabled={!isConnected || !inputMessage.trim()}
186
+ >
187
+ Send
188
+ </Button>
189
+ </div>
190
+ </>
191
+ )}
192
+ </CardContent>
193
+ </Card>
194
+ </div>
195
+ );
196
+ }
examples/websocket/server.ts ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createServer } from 'http'
2
+ import { Server } from 'socket.io'
3
+
4
+ const httpServer = createServer()
5
+ const io = new Server(httpServer, {
6
+ // DO NOT change the path, it is used by Caddy to forward the request to the correct port
7
+ path: '/',
8
+ cors: {
9
+ origin: "*",
10
+ methods: ["GET", "POST"]
11
+ },
12
+ pingTimeout: 60000,
13
+ pingInterval: 25000,
14
+ })
15
+
16
+ interface User {
17
+ id: string
18
+ username: string
19
+ }
20
+
21
+ interface Message {
22
+ id: string
23
+ username: string
24
+ content: string
25
+ timestamp: Date
26
+ type: 'user' | 'system'
27
+ }
28
+
29
+ const users = new Map<string, User>()
30
+
31
+ const generateMessageId = () => Math.random().toString(36).substr(2, 9)
32
+
33
+ const createSystemMessage = (content: string): Message => ({
34
+ id: generateMessageId(),
35
+ username: 'System',
36
+ content,
37
+ timestamp: new Date(),
38
+ type: 'system'
39
+ })
40
+
41
+ const createUserMessage = (username: string, content: string): Message => ({
42
+ id: generateMessageId(),
43
+ username,
44
+ content,
45
+ timestamp: new Date(),
46
+ type: 'user'
47
+ })
48
+
49
+ io.on('connection', (socket) => {
50
+ console.log(`User connected: ${socket.id}`)
51
+
52
+ // Add test event handler
53
+ socket.on('test', (data) => {
54
+ console.log('Received test message:', data)
55
+ socket.emit('test-response', {
56
+ message: 'Server received test message',
57
+ data: data,
58
+ timestamp: new Date().toISOString()
59
+ })
60
+ })
61
+
62
+ socket.on('join', (data: { username: string }) => {
63
+ const { username } = data
64
+
65
+ // Create user object
66
+ const user: User = {
67
+ id: socket.id,
68
+ username
69
+ }
70
+
71
+ // Add to user list
72
+ users.set(socket.id, user)
73
+
74
+ // Send join message to all users
75
+ const joinMessage = createSystemMessage(`${username} joined the chat room`)
76
+ io.emit('user-joined', { user, message: joinMessage })
77
+
78
+ // Send current user list to new user
79
+ const usersList = Array.from(users.values())
80
+ socket.emit('users-list', { users: usersList })
81
+
82
+ console.log(`${username} joined the chat room, current online users: ${users.size}`)
83
+ })
84
+
85
+ socket.on('message', (data: { content: string; username: string }) => {
86
+ const { content, username } = data
87
+ const user = users.get(socket.id)
88
+
89
+ if (user && user.username === username) {
90
+ const message = createUserMessage(username, content)
91
+ io.emit('message', message)
92
+ console.log(`${username}: ${content}`)
93
+ }
94
+ })
95
+
96
+ socket.on('disconnect', () => {
97
+ const user = users.get(socket.id)
98
+
99
+ if (user) {
100
+ // Remove from user list
101
+ users.delete(socket.id)
102
+
103
+ // Send leave message to all users
104
+ const leaveMessage = createSystemMessage(`${user.username} left the chat room`)
105
+ io.emit('user-left', { user: { id: socket.id, username: user.username }, message: leaveMessage })
106
+
107
+ console.log(`${user.username} left the chat room, current online users: ${users.size}`)
108
+ } else {
109
+ console.log(`User disconnected: ${socket.id}`)
110
+ }
111
+ })
112
+
113
+ socket.on('error', (error) => {
114
+ console.error(`Socket error (${socket.id}):`, error)
115
+ })
116
+ })
117
+
118
+ const PORT = 3003
119
+ httpServer.listen(PORT, () => {
120
+ console.log(`WebSocket server running on port ${PORT}`)
121
+ })
122
+
123
+ // Graceful shutdown
124
+ process.on('SIGTERM', () => {
125
+ console.log('Received SIGTERM signal, shutting down server...')
126
+ httpServer.close(() => {
127
+ console.log('WebSocket server closed')
128
+ process.exit(0)
129
+ })
130
+ })
131
+
132
+ process.on('SIGINT', () => {
133
+ console.log('Received SIGINT signal, shutting down server...')
134
+ httpServer.close(() => {
135
+ console.log('WebSocket server closed')
136
+ process.exit(0)
137
+ })
138
+ })
llms.txt CHANGED
@@ -1,28 +1,28 @@
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`.
@@ -30,6 +30,9 @@
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
 
@@ -39,14 +42,24 @@
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. Install `dssl-vscode/dssl-vscode-0.7.2.vsix` from the repo files; older vsix packages (0.7.1 and below) were packaged before the review-driven fixes and carry the old compiler. Source lives in `dssl-vscode/` in the repo.
 
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. 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 at runtime β€” but v0.8.0 adds the exit lane: `dssl build` compiles the static subset to a real .css file for no-JS, strict-CSP or size-critical pages. 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.8.0 (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 β€” unless you build the static subset: `dssl build styles.dssl -o styles.css` emits real CSS (tokens β†’ custom properties, states β†’ :hover, layers β†’ @layer, motions β†’ @keyframes, `raw { }` verbatim, `seen` applied as its no-JS final state) and the report counts what stays runtime-only (`@signal` rules, `animate:`). For content pages that must not flash, use the build output or choose another tool.
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.8.0)
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`. v0.8.0 axis roles: distribution words (`between`/`around`/`evenly`) own the main axis, `stretch`/`baseline` own the cross axis, `center`/`start`/`end` fill free axes, and a word with nowhere to go warns instead of overwriting silently.
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`.
 
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
+ - Verbatim escape hatch (v0.8.0): `raw { }` / `@css { }` drops real CSS (@font-face, @keyframes, @supports, @import, @property) into the page stylesheet untouched; unknown at-rules skip their whole block with one clean error.
34
+ - Fluid sugar (v0.8.0): `font-size: fluid 16 24` (and any length-ish property) compiles to the linear clamp() between 360px and 1200px viewports; `fluid: 16, 24` still works.
35
+ - v0.8.0 review-driven fixes, all regression-tested incl. headless-Chromium computed-style tests of the docs: row/column axis roles (the documented `between center` example laid out wrong before); @font-face no longer crashes the compiler (unknown at-rules skip balanced); @supports inner rules no longer leak unconditionally; `url(...)` is atomic (unquoted form no longer false-errors); `!important` is a loud error that emits nothing; the animation shorthand warns when its motion is undefined instead of suggesting "span"; CSS-order transition values get the exact DSSL conversion in the error hint.
36
  - 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.
37
  - 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.
38
 
 
42
  DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
43
  DSSL.apply(src, { scope, name }) // scoped engines for widgets
44
  DSSL.compile(src) // parse + resolve, no DOM (powers editors)
45
+ DSSL.toCSS(src) // v0.8.0: the exit lane -> { css, report }
46
  DSSL.signal('menuOpen', true) // the JS -> styles bridge
47
  DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
48
  DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
49
  DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
50
  ```
51
 
52
+ ## CLI (v0.8.0)
53
+
54
+ ```sh
55
+ node cli/dssl.mjs check styles.dssl --strict # CI: exit 1 on errors, --json for machines
56
+ node cli/dssl.mjs build styles.dssl -o styles.css # the static subset as real CSS
57
+ ```
58
+
59
+ `check` runs the real compiler with file:line diagnostics and did-you-mean hints. `build` compiles the static subset (tokens β†’ custom properties, states β†’ :hover, layers β†’ @layer, motions β†’ @keyframes, raw { } verbatim, pin β†’ !important, seen β†’ its no-JS final state) and prints a report of what was emitted and what stays runtime-only.
60
+
61
  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.
62
 
63
  ## Editor support
64
 
65
+ 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. Install `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files. Source lives in `dssl-vscode/` in the repo.
public/dssl-engine.tar.gz CHANGED
Binary files a/public/dssl-engine.tar.gz and b/public/dssl-engine.tar.gz differ
 
public/dssl-engine.zip CHANGED
Binary files a/public/dssl-engine.zip and b/public/dssl-engine.zip differ
 
public/dssl/engine.js CHANGED
@@ -173,15 +173,19 @@ export class DsslEngine {
173
  rebuild() {
174
  const rules = [];
175
  const shadowRules = [];
 
176
  const bundles = [...this.bundles.values()].sort((a, b) => a.seq - b.seq);
177
  for (const b of bundles) {
178
  for (const r of b.rules) {
179
  rules.push(r); // scoped engines map :root rules onto the scope element
180
  }
181
  shadowRules.push(...b.shadowRules);
 
 
182
  }
183
  this.rules = rules;
184
  this.shadowRules = shadowRules;
 
185
 
186
  // unique media queries β†’ MediaQueryList cache
187
  for (const r of rules) for (const q of r.media) this.mql(q);
@@ -1311,6 +1315,12 @@ export class DsslEngine {
1311
 
1312
  let css = this.shadowRules.map(cssFor).join('\n\n');
1313
 
 
 
 
 
 
 
1314
  if (this.reset) {
1315
  css += `
1316
 
 
173
  rebuild() {
174
  const rules = [];
175
  const shadowRules = [];
176
+ const raws = [];
177
  const bundles = [...this.bundles.values()].sort((a, b) => a.seq - b.seq);
178
  for (const b of bundles) {
179
  for (const r of b.rules) {
180
  rules.push(r); // scoped engines map :root rules onto the scope element
181
  }
182
  shadowRules.push(...b.shadowRules);
183
+ // v0.8.0 β€” raw { } verbatim CSS rides the same document stylesheet
184
+ if (b.raws) raws.push(...b.raws);
185
  }
186
  this.rules = rules;
187
  this.shadowRules = shadowRules;
188
+ this.raws = raws;
189
 
190
  // unique media queries β†’ MediaQueryList cache
191
  for (const r of rules) for (const q of r.media) this.mql(q);
 
1315
 
1316
  let css = this.shadowRules.map(cssFor).join('\n\n');
1317
 
1318
+ // v0.8.0 β€” raw { } blocks: verbatim CSS (@font-face, @keyframes,
1319
+ // @supports, @import…), appended after the pseudo-element rules
1320
+ if (this.raws && this.raws.length) {
1321
+ css += '\n\n' + this.raws.map(r => r.css).join('\n\n');
1322
+ }
1323
+
1324
  if (this.reset) {
1325
  css += `
1326
 
public/dssl/index.js CHANGED
@@ -16,9 +16,10 @@
16
 
17
  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.2';
22
 
23
  let counter = 0;
24
  const engines = new Map();
@@ -143,6 +144,18 @@ export async function load(urls, opts = {}) {
143
  return results;
144
  }
145
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  export function signal(name, value) { for (const e of engines.values()) e.signal(name, value); }
147
  export function getSignal(name) { return engines.get('global')?.getSignal(name); }
148
  export function refresh() { for (const e of engines.values()) e.computeAll(); }
@@ -193,7 +206,7 @@ export function explain(el, opts = {}) {
193
  }
194
 
195
  export const DSSL = {
196
- version: VERSION, compile, apply, load, signal, getSignal, refresh, replay, errors,
197
  freeze, unfreeze, explain,
198
  __debug: { engines },
199
  };
 
16
 
17
  import { parse } from './parser.js';
18
  import { resolve, DEFAULT_TOKENS } from './resolver.js';
19
+ import { toCSS as docToCSS } from './static.js';
20
  import { DsslEngine } from './engine.js';
21
 
22
+ export const VERSION = '0.8.0';
23
 
24
  let counter = 0;
25
  const engines = new Map();
 
144
  return results;
145
  }
146
 
147
+ /** v0.8.0 β€” the exit lane: compile a DSSL source string to a real CSS
148
+ * stylesheet (tokens β†’ custom properties, states β†’ :hover, @media as-is,
149
+ * layers β†’ @layer, motions β†’ @keyframes, raw { } verbatim). Returns
150
+ * { css, report } β€” report counts what was emitted and what stays
151
+ * engine-only (@signal rules, animate: and friends). */
152
+ export function toCSS(source, opts = {}) {
153
+ const file = opts.file || 'inline.dssl';
154
+ const result = compile(source, file);
155
+ const built = docToCSS(result.doc, { file, banner: opts.banner });
156
+ return { ...built, ok: result.ok, errors: result.errors, warnings: result.warnings };
157
+ }
158
+
159
  export function signal(name, value) { for (const e of engines.values()) e.signal(name, value); }
160
  export function getSignal(name) { return engines.get('global')?.getSignal(name); }
161
  export function refresh() { for (const e of engines.values()) e.computeAll(); }
 
206
  }
207
 
208
  export const DSSL = {
209
+ version: VERSION, compile, apply, load, toCSS, signal, getSignal, refresh, replay, errors,
210
  freeze, unfreeze, explain,
211
  __debug: { engines },
212
  };
public/dssl/parser.js CHANGED
@@ -86,6 +86,20 @@ function splitTop(str, sep) {
86
  return out;
87
  }
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  /** strip comments (respecting strings), newlines preserved so lines survive */
90
  function stripComments(src) {
91
  let out = '', q = null;
@@ -134,6 +148,7 @@ export function parse(source, file = 'inline.dssl') {
134
  const h = header.trim();
135
  if (h === 'tokens') return { kind: 'tokens' };
136
  if (h === 'pin') return { kind: 'pin' };
 
137
  if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
138
  if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
139
  if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
@@ -141,7 +156,13 @@ export function parse(source, file = 'inline.dssl') {
141
  if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
142
  if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
143
  if (h.startsWith('@')) {
144
- err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'try @media, @container, @signal or @layer');
 
 
 
 
 
 
145
  return { kind: 'bogus' };
146
  }
147
  if (h.startsWith('::')) {
@@ -192,8 +213,9 @@ export function parse(source, file = 'inline.dssl') {
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
  }
@@ -201,8 +223,9 @@ export function parse(source, file = 'inline.dssl') {
201
  const meta = classify(header, line);
202
  const node = { ...meta, line, children: [] };
203
  stack[stack.length - 1].children.push(node);
204
- if (meta.kind !== 'bogus') stack.push(node);
205
- rest = rest.slice(open + 1);
 
206
  continue;
207
  }
208
 
@@ -211,6 +234,63 @@ export function parse(source, file = 'inline.dssl') {
211
  }
212
  };
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  const processContent = (content, line) => {
215
  for (const piece of splitTop(content, ';')) {
216
  const part = piece.trim();
@@ -284,7 +364,8 @@ export function parse(source, file = 'inline.dssl') {
284
  lines.forEach((raw, idx) => {
285
  const line = idx + 1;
286
  const trimmed = raw.trim();
287
- if (!trimmed) return; // blank lines never split a pending chunk
 
288
  const candidate = buf ? buf + ' ' + trimmed : raw;
289
  if (buf && needsMore(candidate)) { buf = candidate; return; }
290
  if (buf) { processChunk(candidate, bufLine || line); buf = ''; return; }
@@ -292,6 +373,9 @@ export function parse(source, file = 'inline.dssl') {
292
  processChunk(candidate, line);
293
  });
294
  if (buf.trim()) processChunk(buf, bufLine);
 
 
 
295
 
296
  if (stack.length > 1) {
297
  err(lines.length, `${stack.length - 1} block(s) were never closed with "}"`, 'every "{" needs a matching "}"');
 
86
  return out;
87
  }
88
 
89
+ /** find index of the matching close brace for the '{' just before `from`
90
+ * (string-aware). Returns -1 when the block is never closed. */
91
+ function matchBrace(str, from) {
92
+ let depth = 1, q = null;
93
+ for (let i = from; i < str.length; i++) {
94
+ const c = str[i];
95
+ if (q) { if (c === q && str[i - 1] !== '\\') q = null; continue; }
96
+ if (c === '"' || c === "'") { q = c; continue; }
97
+ if (c === '{') depth++;
98
+ else if (c === '}') { depth--; if (depth === 0) return i; }
99
+ }
100
+ return -1;
101
+ }
102
+
103
  /** strip comments (respecting strings), newlines preserved so lines survive */
104
  function stripComments(src) {
105
  let out = '', q = null;
 
148
  const h = header.trim();
149
  if (h === 'tokens') return { kind: 'tokens' };
150
  if (h === 'pin') return { kind: 'pin' };
151
+ if (h === 'raw' || h === '@css') return { kind: 'raw' };
152
  if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
153
  if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
154
  if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
 
156
  if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
157
  if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
158
  if (h.startsWith('@')) {
159
+ // v0.8.0 β€” unknown at-rules are skipped WHOLE (balanced braces). They
160
+ // used to error and then keep parsing inside their block, so the inner
161
+ // rules leaked out unconditionally: `@supports (display:grid) { .a { … } }
162
+ // applied .a everywhere. The block is now dropped in its entirety β€”
163
+ // and raw { } is the documented escape hatch for CSS DSSL has no sugar
164
+ // for yet (@font-face, @keyframes, @supports, @import…).
165
+ err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'the whole block is skipped β€” for verbatim CSS like @font-face / @keyframes / @supports, wrap it in raw { }');
166
  return { kind: 'bogus' };
167
  }
168
  if (h.startsWith('::')) {
 
213
  const meta = classify(realHeader, line);
214
  const node = { ...meta, line, children: [] };
215
  stack[stack.length - 1].children.push(node);
216
+ const after = openBlock(meta, node, rest, open, line);
217
+ if (after === null) { rest = rest.slice(open + 1); if (meta.kind !== 'bogus') stack.push(node); continue; }
218
+ rest = after;
219
  continue;
220
  }
221
  }
 
223
  const meta = classify(header, line);
224
  const node = { ...meta, line, children: [] };
225
  stack[stack.length - 1].children.push(node);
226
+ const after = openBlock(meta, node, rest, open, line);
227
+ if (after === null) { rest = rest.slice(open + 1); if (meta.kind !== 'bogus') stack.push(node); continue; }
228
+ rest = after;
229
  continue;
230
  }
231
 
 
234
  }
235
  };
236
 
237
+ /** v0.8.0 β€” what happens right after a block header opens.
238
+ * - raw { } (top level): capture the block body VERBATIM as node.css and
239
+ * return the remainder of the chunk β€” or enter multi-line capture mode
240
+ * (returns '' with rawCapture armed).
241
+ * - bogus (unknown at-rule / pseudo-element): skip the whole balanced
242
+ * block so its content can never leak into the parent.
243
+ * - anything else: return null so the caller pushes the node and keeps
244
+ * parsing normally. */
245
+ let rawCapture = null; // { node, depth } while a multi-line raw { } is open
246
+ const openBlock = (meta, node, rest, open, line) => {
247
+ if (meta.kind === 'raw') {
248
+ if (stack.length > 1) {
249
+ err(line, 'raw { } must be top-level β€” it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
250
+ const close = matchBrace(rest, open + 1);
251
+ return close === -1 ? '' : rest.slice(close + 1);
252
+ }
253
+ const close = matchBrace(rest, open + 1);
254
+ if (close !== -1) {
255
+ node.css = rest.slice(open + 1, close);
256
+ return rest.slice(close + 1);
257
+ }
258
+ rawCapture = { node, depth: 1 };
259
+ return '';
260
+ }
261
+ if (meta.kind === 'bogus') {
262
+ const close = matchBrace(rest, open + 1);
263
+ if (close === -1) {
264
+ err(line, `the "${(meta.sel || '').slice(0, 30) || 'block'}" block was never closed with "}"`, 'every "{" needs a matching "}"');
265
+ return '';
266
+ }
267
+ return rest.slice(close + 1);
268
+ }
269
+ return null;
270
+ };
271
+
272
+ /** consume one source line while a multi-line raw { } is being captured */
273
+ const feedRawCapture = (raw, line) => {
274
+ let q = null;
275
+ for (let i = 0; i < raw.length; i++) {
276
+ const c = raw[i];
277
+ if (q) { if (c === q && raw[i - 1] !== '\\') q = null; continue; }
278
+ if (c === '"' || c === "'") { q = c; continue; }
279
+ if (c === '{') rawCapture.depth++;
280
+ else if (c === '}') {
281
+ rawCapture.depth--;
282
+ if (rawCapture.depth === 0) {
283
+ rawCapture.node.css += raw.slice(0, i);
284
+ const rest = raw.slice(i + 1);
285
+ rawCapture = null;
286
+ if (rest.trim()) processChunk(rest, line);
287
+ return;
288
+ }
289
+ }
290
+ }
291
+ rawCapture.node.css += raw + '\n';
292
+ };
293
+
294
  const processContent = (content, line) => {
295
  for (const piece of splitTop(content, ';')) {
296
  const part = piece.trim();
 
364
  lines.forEach((raw, idx) => {
365
  const line = idx + 1;
366
  const trimmed = raw.trim();
367
+ if (!trimmed) { if (rawCapture) rawCapture.node.css += raw + '\n'; return; } // blank lines never split a pending chunk
368
+ if (rawCapture) { feedRawCapture(raw, line); return; }
369
  const candidate = buf ? buf + ' ' + trimmed : raw;
370
  if (buf && needsMore(candidate)) { buf = candidate; return; }
371
  if (buf) { processChunk(candidate, bufLine || line); buf = ''; return; }
 
373
  processChunk(candidate, line);
374
  });
375
  if (buf.trim()) processChunk(buf, bufLine);
376
+ if (rawCapture) {
377
+ err(lines.length, 'the raw { } block was never closed with "}"', 'every "{" needs a matching "}" β€” the CSS captured so far is discarded');
378
+ }
379
 
380
  if (stack.length > 1) {
381
  err(lines.length, `${stack.length - 1} block(s) were never closed with "}"`, 'every "{" needs a matching "}"');
public/dssl/resolver.js CHANGED
@@ -45,6 +45,10 @@ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,
45
  'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
46
  'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
47
  'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
 
 
 
 
48
  'contain,cover,fill,fit-content,min-content,max-content,minmax,repeat,clamp,calc,var,url,env,attr,' +
49
  'auto-fill,auto-fit,subgrid,dense,stretch,span,' +
50
  'scaleX,scaleY,translateX,translateY,translate,rotate,skew,skewX,skewY,matrix,' +
@@ -79,6 +83,10 @@ const UNITLESS_PROPS = new Set([
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)
@@ -216,6 +224,15 @@ function resolveValue(raw, ctx) {
216
  }
217
  const inner = raw.slice(i + 1, j - 1);
218
  const fname = ident.toLowerCase();
 
 
 
 
 
 
 
 
 
219
  // v0.3.2 fix: repeat(COUNT, tracks) β€” the leading count is an integer,
220
  // never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
221
  // which the browser rejected and the whole grid silently collapsed.
@@ -487,7 +504,21 @@ function buildGradient(v, ctx) {
487
 
488
  function parseFlexWords(value, ctx, dir) {
489
  const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  const atoms = value.split(/\s+/).filter(Boolean);
 
491
  for (let k = 0; k < atoms.length; k++) {
492
  const a = atoms[k];
493
  if (a === 'gap') {
@@ -500,9 +531,36 @@ function parseFlexWords(value, ctx, dir) {
500
  if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; }
501
  if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; }
502
  if (a === 'inline') { out[0].value = 'inline-flex'; continue; }
503
- if (PLACE_WORDS[a]) {
504
- out.push({ prop: 'align-items', value: PLACE_WORDS[a][0] });
505
- out.push({ prop: 'justify-content', value: PLACE_WORDS[a][1] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  continue;
507
  }
508
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown word "${a}" in ${dir}`, hint: didYouMean(a, ['gap', 'center', 'start', 'end', 'between', 'around', 'evenly', 'stretch', 'baseline', 'wrap', 'nowrap', 'reverse', 'inline']) });
@@ -613,17 +671,7 @@ const SEMANTIC = {
613
  return out;
614
  },
615
  fluid(v, ctx) {
616
- const parts = v.split(',').map(s => s.trim());
617
- const px = (s) => {
618
- const m = String(s).match(/^([0-9.]+)\s*(px|rem|em|%)?$/);
619
- if (!m) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" is not a size` }); return '0px'; }
620
- return m[2] ? `${m[1]}${m[2]}` : `${m[1]}px`;
621
- };
622
- const min = px(parts[0]); const max = px(parts[1]);
623
- const vmin = px(parts[2] || '360'); const vmax = px(parts[3] || '1200');
624
- const mv = parseFloat(min), xv = parseFloat(max);
625
- const slope = ((xv - mv) / (parseFloat(vmax) - parseFloat(vmin))) * 100;
626
- return [{ prop: 'font-size', value: `clamp(${min}, calc(${min} + ${slope.toFixed(3)}vw), ${max})` }];
627
  },
628
  shadow(v, ctx) {
629
  const t = v.trim();
@@ -697,6 +745,22 @@ const SEMANTIC = {
697
  const atoms = part.split(/\s+/);
698
  const dur = atoms[0];
699
  if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration Β· easing Β· property β€” try 200 or 0.2s' });
701
  continue;
702
  }
@@ -876,13 +940,51 @@ const SEMANTIC = {
876
  },
877
  };
878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879
  /* ------------------------------------------------------------------ *
880
  * resolveDecl β€” one declaration β†’ zero or more engine decls
881
  * ------------------------------------------------------------------ */
882
 
883
  function resolveDecl(prop, value, ctx) {
884
  const errors = ctx.errors;
885
- const ctx2 = { ...ctx, prop, errors };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
886
 
887
  if (prop.startsWith('--')) {
888
  // v0.4.0 β€” scoped tokens. Dotted names are token re-declarations:
@@ -899,6 +1001,22 @@ function resolveDecl(prop, value, ctx) {
899
  const sem = SEMANTIC[prop];
900
  if (sem) return sem(value, ctx2);
901
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
  if (!KNOWN_PROPS.has(prop)) {
903
  errors.push({
904
  file: ctx.file, line: ctx.line,
@@ -910,6 +1028,19 @@ function resolveDecl(prop, value, ctx) {
910
  return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
911
  }
912
 
 
 
 
 
 
 
 
 
 
 
 
 
 
913
  /* ------------------------------------------------------------------ *
914
  * Media / container / signal normalization
915
  * ------------------------------------------------------------------ */
@@ -960,6 +1091,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
960
  const errors = [];
961
  const rules = [];
962
  const shadowRules = [];
 
963
  const motions = {};
964
  const behaviors = {};
965
  // v0.7.0 β€” compile-time warnings: things that compile and run but are
@@ -999,7 +1131,10 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
999
  } else if (node.kind === 'motion') {
1000
  motions[node.name] = node;
1001
  } else {
1002
- for (const child of node.children) if (child.kind !== 'decl') prepass([child]);
 
 
 
1003
  }
1004
  }
1005
  };
@@ -1018,7 +1153,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1018
  const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
1019
  return m ? parseFloat(m[1]) : null;
1020
  };
1021
- const ctxBase = { file, line: node.line, errors, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
1022
  for (const child of node.children) {
1023
  if (child.kind === 'decl' && child.prop === 'essential') {
1024
  node._parsed.essential = child.value === '' || child.value === 'true';
@@ -1075,7 +1210,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1075
 
1076
  const resolveTokenDecls = (keys, conds) => {
1077
  const decls = [];
1078
- const ctxBase = { file, line: 0, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false };
1079
  for (const key of keys) {
1080
  const raw = tokenMap.get(key);
1081
  const prop = '--dssl-' + key.replace(/\./g, '-');
@@ -1130,7 +1265,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1130
  bh._active = false;
1131
  break;
1132
  }
1133
- const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
1134
  for (const r of resolved) {
1135
  // v0.7.0 β€” remember WHICH source property wrote a display value,
1136
  // so the ordering linter can tell an explicit "display:" from a
@@ -1223,7 +1358,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1223
  for (const d of node.children) {
1224
  if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1225
  if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1226
- const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1227
  // v0.5.0 β€” engine-native properties have no pseudo-element story:
1228
  // there is no DOM handle to nudge, animate, stack or stagger. They
1229
  // used to leak into the shadow sheet as junk CSS the browser
@@ -1249,6 +1384,14 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1249
  if (tr) rules.push(tr);
1250
  break;
1251
  }
 
 
 
 
 
 
 
 
1252
  case 'behavior':
1253
  case 'motion':
1254
  if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` });
@@ -1366,7 +1509,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1366
  flush(rootRule, topCtx, 0);
1367
 
1368
  // parse all motions that were referenced
1369
- for (const r of rules) {
1370
  for (const d of r.decls) {
1371
  if (d.prop === '@animate' && d.value && d.value.motion) {
1372
  const m = parseMotion(d.value.motion);
@@ -1374,7 +1517,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1374
  errors.push({
1375
  file, line: d.line,
1376
  message: `unknown motion "${d.value.motion}"`,
1377
- hint: didYouMean(d.value.motion, Object.keys(motions)),
1378
  });
1379
  d.value.broken = true;
1380
  } else {
@@ -1382,13 +1525,36 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1382
  d.value.essential = m.essential;
1383
  }
1384
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1385
  }
1386
  }
1387
 
 
 
 
 
 
 
 
 
1388
  const mediaQueries = new Set();
1389
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1390
 
1391
- return { rules, shadowRules, motions, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1392
  }
1393
 
1394
  /** naive `&` substitution + descendant join */
 
45
  'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
46
  'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
47
  'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
48
+ // v0.8.0 β€” the background repeat/attachment words the url() fix exposed:
49
+ // `background: url(a.png) no-repeat` was a false error because "no-repeat"
50
+ // was missing while "repeat" existed
51
+ 'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' +
52
  'contain,cover,fill,fit-content,min-content,max-content,minmax,repeat,clamp,calc,var,url,env,attr,' +
53
  'auto-fill,auto-fit,subgrid,dense,stretch,span,' +
54
  'scaleX,scaleY,translateX,translateY,translate,rotate,skew,skewX,skewY,matrix,' +
 
83
  // why `transition-delay: 200` shipped as invalid unitless 200.
84
  const TIME_PROPS = new Set([
85
  'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
86
+ // v0.8.0 β€” the animation shorthand takes bare numbers as ms too:
87
+ // `animation: spin 1s infinite` is fine, but `animation: ghost 400` used
88
+ // to become "ghost 400px" (invalid). 400 β†’ 400ms, matching animate:.
89
+ 'animation',
90
  ]);
91
 
92
  // A practical whitelist of standard CSS properties (loud errors beat silent ones)
 
224
  }
225
  const inner = raw.slice(i + 1, j - 1);
226
  const fname = ident.toLowerCase();
227
+ // v0.8.0 β€” url() is atomic. The validator used to tokenize INSIDE it,
228
+ // so `url(a.png)` raised "unknown value a.png" (quoted urls worked
229
+ // only because strings were already consumed whole). CSS allows the
230
+ // unquoted form; DSSL passes it through untouched now.
231
+ if (fname === 'url') {
232
+ out += 'url(' + inner.trim() + ')';
233
+ i = j;
234
+ continue;
235
+ }
236
  // v0.3.2 fix: repeat(COUNT, tracks) β€” the leading count is an integer,
237
  // never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
238
  // which the browser rejected and the whole grid silently collapsed.
 
504
 
505
  function parseFlexWords(value, ctx, dir) {
506
  const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
507
+ // v0.8.0 β€” axis roles. Every place word used to write BOTH axes, so
508
+ // `row: gap 8 between center` (the guide's own example) compiled to
509
+ // justify-content: space-between followed by justify-content: center β€”
510
+ // and center won, bunching everything in the middle. Words now declare
511
+ // the axis they are actually about: distribution words (between/around/
512
+ // evenly) own the main axis, stretch/baseline own the cross axis, and
513
+ // center/start/end fill whichever axes are still free. A word whose axes
514
+ // are all taken is a loud warning instead of a silent overwrite.
515
+ const FLEX_AXIS = {
516
+ between: 'main', around: 'main', evenly: 'main',
517
+ stretch: 'cross', baseline: 'cross',
518
+ center: 'both', start: 'both', end: 'both',
519
+ };
520
  const atoms = value.split(/\s+/).filter(Boolean);
521
+ let mainSet = null, crossSet = null;
522
  for (let k = 0; k < atoms.length; k++) {
523
  const a = atoms[k];
524
  if (a === 'gap') {
 
531
  if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; }
532
  if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; }
533
  if (a === 'inline') { out[0].value = 'inline-flex'; continue; }
534
+ if (FLEX_AXIS[a]) {
535
+ const role = FLEX_AXIS[a];
536
+ const pc = PLACE_WORDS[a]; // [cross, main]
537
+ const axisWarn = (prop, prev) => {
538
+ ctx.warnings = ctx.warnings || [];
539
+ ctx.warnings.push(prev === a
540
+ ? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir} β€” the second copy wins`, hint: `drop the duplicate; one distribution word + one alignment word is the whole grammar` }
541
+ : { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win β€” drop one, or reorder so the distribution word comes first` });
542
+ };
543
+ let wrote = false;
544
+ if (role === 'main' || (role === 'both' && !mainSet)) {
545
+ if (mainSet) axisWarn('justify-content', mainSet);
546
+ out.push({ prop: 'justify-content', value: pc[1] });
547
+ mainSet = a; wrote = true;
548
+ }
549
+ if (role === 'cross' || (role === 'both' && !crossSet)) {
550
+ if (crossSet) axisWarn('align-items', crossSet);
551
+ out.push({ prop: 'align-items', value: pc[0] });
552
+ crossSet = a; wrote = true;
553
+ }
554
+ if (!wrote) {
555
+ ctx.warnings = ctx.warnings || [];
556
+ ctx.warnings.push({
557
+ file: ctx.file, line: ctx.line,
558
+ message: mainSet === a || crossSet === a
559
+ ? `"${a}" is written twice in ${dir} β€” both axes already came from it`
560
+ : `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`,
561
+ hint: 'one distribution word + one alignment word is the whole grammar β€” the extra word is dropped',
562
+ });
563
+ }
564
  continue;
565
  }
566
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown word "${a}" in ${dir}`, hint: didYouMean(a, ['gap', 'center', 'start', 'end', 'between', 'around', 'evenly', 'stretch', 'baseline', 'wrap', 'nowrap', 'reverse', 'inline']) });
 
671
  return out;
672
  },
673
  fluid(v, ctx) {
674
+ return buildFluidParts(v.split(',').map(s => s.trim()).filter(Boolean), ctx);
 
 
 
 
 
 
 
 
 
 
675
  },
676
  shadow(v, ctx) {
677
  const t = v.trim();
 
745
  const atoms = part.split(/\s+/);
746
  const dur = atoms[0];
747
  if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
748
+ // v0.8.0 β€” the CSS habit is property-first (`transition: color .2s
749
+ // ease`). That used to be a generic "not a time" error and the part
750
+ // silently dropped. The error now REWRITES the value into the DSSL
751
+ // order (time Β· easing Β· property) so the fix is a copy-paste.
752
+ const timeAtom = atoms.find(a => /^\d*\.?\d+(ms|s)?$/.test(a));
753
+ const easeAtom = atoms.find(a => EASINGS[a]);
754
+ const propAtom = atoms.find(a => a !== timeAtom && a !== easeAtom && (KNOWN_PROPS.has(a) || DEFORM_TO_TRANSFORM[a]));
755
+ if (timeAtom) {
756
+ const msOut = /^\d+$/.test(timeAtom) ? timeAtom : /ms$/.test(timeAtom) ? timeAtom.replace(/ms$/, '') : String(parseFloat(timeAtom) * 1000);
757
+ ctx.errors.push({
758
+ file: ctx.file, line: ctx.line,
759
+ message: `transition: the CSS form (property first) is not DSSL`,
760
+ hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''} β€” time first, easing, property last`,
761
+ });
762
+ continue;
763
+ }
764
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration Β· easing Β· property β€” try 200 or 0.2s' });
765
  continue;
766
  }
 
940
  },
941
  };
942
 
943
+ /* v0.8.0 β€” fluid sizing sugar. `fluid: 16, 24` keeps working (font-size),
944
+ * and now any length-ish property takes it inline: `font-size: fluid 16 24`.
945
+ * Both compile to a clamp() that scales linearly between the viewport bounds
946
+ * (360px and 1200px by default) β€” the formula everyone writes by hand and
947
+ * gets wrong. */
948
+ const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' +
949
+ 'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left').split(','));
950
+
951
+ function buildFluidParts(parts, ctx, prop = 'font-size') {
952
+ const px = (s) => {
953
+ const m = String(s).match(/^([0-9.]+)\s*(px|rem|em|%)?$/);
954
+ if (!m) {
955
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" is not a size`, hint: 'fluid takes two sizes and optionally the viewport bounds β€” fluid 16 24, or fluid 16 24 360 1200' });
956
+ return '0px';
957
+ }
958
+ return m[2] ? `${m[1]}${m[2]}` : `${m[1]}px`;
959
+ };
960
+ const min = px(parts[0]); const max = px(parts[1]);
961
+ const vmin = px(parts[2] || '360'); const vmax = px(parts[3] || '1200');
962
+ const mv = parseFloat(min), xv = parseFloat(max);
963
+ const slope = ((xv - mv) / (parseFloat(vmax) - parseFloat(vmin))) * 100;
964
+ return [{ prop, value: `clamp(${min}, calc(${min} + ${slope.toFixed(3)}vw), ${max})` }];
965
+ }
966
+
967
  /* ------------------------------------------------------------------ *
968
  * resolveDecl β€” one declaration β†’ zero or more engine decls
969
  * ------------------------------------------------------------------ */
970
 
971
  function resolveDecl(prop, value, ctx) {
972
  const errors = ctx.errors;
973
+ const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings };
974
+
975
+ // v0.8.0 β€” !important does not exist in DSSL. It used to fall through as a
976
+ // value: the compiler said "unknown value important" AND still emitted
977
+ // `red !important`, which the browser then silently dropped as an invalid
978
+ // inline value. Now: one loud error, nothing emitted, and the pin { }
979
+ // answer to the actual question.
980
+ if (hasImportant(value)) {
981
+ errors.push({
982
+ file: ctx.file, line: ctx.line,
983
+ message: `!important does not exist in DSSL β€” "${prop}: ${String(value).replace(/\s*!\s*important\s*/i, '')}" was NOT applied`,
984
+ hint: 'the cascade is (layer, order) and later wins β€” put the rule in pin { } when it genuinely must win',
985
+ });
986
+ return [];
987
+ }
988
 
989
  if (prop.startsWith('--')) {
990
  // v0.4.0 β€” scoped tokens. Dotted names are token re-declarations:
 
1001
  const sem = SEMANTIC[prop];
1002
  if (sem) return sem(value, ctx2);
1003
 
1004
+ // v0.8.0 β€” the animation shorthand passes through (it is legal CSS), but
1005
+ // bare motion names must not pay the generic unknown-value tax: "spin"
1006
+ // used to error with the surreal suggestion "did you mean span?". Motion
1007
+ // names pass silently here and a dedicated warning in the final loop says
1008
+ // the DSSL way (motion + animate) when the name matches nothing.
1009
+ if (prop === 'animation' || prop === 'animation-name') {
1010
+ return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }];
1011
+ }
1012
+
1013
+ // v0.8.0 β€” inline fluid sugar on any length-ish property:
1014
+ // font-size: fluid 16 24 β†’ clamp(16px, calc(16px + 0.952vw), 24px)
1015
+ const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim());
1016
+ if (fluidM && FLUID_PROPS.has(prop)) {
1017
+ return buildFluidParts(fluidM[1].split(/[\s,]+/).filter(Boolean), ctx2, prop);
1018
+ }
1019
+
1020
  if (!KNOWN_PROPS.has(prop)) {
1021
  errors.push({
1022
  file: ctx.file, line: ctx.line,
 
1028
  return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
1029
  }
1030
 
1031
+ /** string-aware !important scan β€” quoted content: "!important" must not trip it */
1032
+ function hasImportant(v) {
1033
+ const s = String(v);
1034
+ let q = null;
1035
+ for (let i = 0; i < s.length; i++) {
1036
+ const c = s[i];
1037
+ if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
1038
+ if (c === '"' || c === "'") { q = c; continue; }
1039
+ if (c === '!' && /\s*important/i.test(s.slice(i + 1))) return true;
1040
+ }
1041
+ return false;
1042
+ }
1043
+
1044
  /* ------------------------------------------------------------------ *
1045
  * Media / container / signal normalization
1046
  * ------------------------------------------------------------------ */
 
1091
  const errors = [];
1092
  const rules = [];
1093
  const shadowRules = [];
1094
+ const raws = []; // v0.8.0 β€” raw { } / @css { } verbatim passthrough blocks
1095
  const motions = {};
1096
  const behaviors = {};
1097
  // v0.7.0 β€” compile-time warnings: things that compile and run but are
 
1131
  } else if (node.kind === 'motion') {
1132
  motions[node.name] = node;
1133
  } else {
1134
+ // v0.8.0 β€” defensive `|| []`: a malformed tree can throw from here
1135
+ // and take the whole stylesheet down (the @font-face crash report).
1136
+ // The parser now skips unknown at-rules whole, but the guard stays.
1137
+ for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]);
1138
  }
1139
  }
1140
  };
 
1153
  const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
1154
  return m ? parseFloat(m[1]) : null;
1155
  };
1156
+ const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
1157
  for (const child of node.children) {
1158
  if (child.kind === 'decl' && child.prop === 'essential') {
1159
  node._parsed.essential = child.value === '' || child.value === 'true';
 
1210
 
1211
  const resolveTokenDecls = (keys, conds) => {
1212
  const decls = [];
1213
+ const ctxBase = { file, line: 0, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false };
1214
  for (const key of keys) {
1215
  const raw = tokenMap.get(key);
1216
  const prop = '--dssl-' + key.replace(/\./g, '-');
 
1265
  bh._active = false;
1266
  break;
1267
  }
1268
+ const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
1269
  for (const r of resolved) {
1270
  // v0.7.0 β€” remember WHICH source property wrote a display value,
1271
  // so the ordering linter can tell an explicit "display:" from a
 
1358
  for (const d of node.children) {
1359
  if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1360
  if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1361
+ const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1362
  // v0.5.0 β€” engine-native properties have no pseudo-element story:
1363
  // there is no DOM handle to nudge, animate, stack or stagger. They
1364
  // used to leak into the shadow sheet as junk CSS the browser
 
1384
  if (tr) rules.push(tr);
1385
  break;
1386
  }
1387
+ case 'raw': {
1388
+ // v0.8.0 β€” verbatim CSS escape hatch (@font-face, @keyframes,
1389
+ // @supports, @import, @property…). The parser captured the body
1390
+ // untouched; it rides the same document stylesheet as the
1391
+ // pseudo-element rules and survives `dssl build` verbatim.
1392
+ if ((node.css || '').trim()) raws.push({ css: node.css.trim(), file, line: node.line, order: order++ });
1393
+ break;
1394
+ }
1395
  case 'behavior':
1396
  case 'motion':
1397
  if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` });
 
1509
  flush(rootRule, topCtx, 0);
1510
 
1511
  // parse all motions that were referenced
1512
+ for (const r of [...rules, ...shadowRules]) {
1513
  for (const d of r.decls) {
1514
  if (d.prop === '@animate' && d.value && d.value.motion) {
1515
  const m = parseMotion(d.value.motion);
 
1517
  errors.push({
1518
  file, line: d.line,
1519
  message: `unknown motion "${d.value.motion}"`,
1520
+ hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } } β€” then "animate: ${d.value.motion} 400" runs it`,
1521
  });
1522
  d.value.broken = true;
1523
  } else {
 
1525
  d.value.essential = m.essential;
1526
  }
1527
  }
1528
+ // v0.8.0 β€” the animation shorthand is CSS passthrough, so a motion
1529
+ // name that matches nothing used to be a SILENT no-op (no @keyframes
1530
+ // can exist in DSSL). Warn with the whole story; if the keyframes come
1531
+ // from raw { } or another stylesheet, the warning says it can be ignored.
1532
+ if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') {
1533
+ const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w));
1534
+ const named = words.find(w => motions[w]);
1535
+ if (words.length && !named) {
1536
+ warnings.push({
1537
+ file, line: d.line,
1538
+ message: `animation "${d.value}" references no motion defined in this sheet β€” it will not run`,
1539
+ hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400 β€” @keyframes does not exist in DSSL; raw { } can carry verbatim keyframes`,
1540
+ });
1541
+ }
1542
+ }
1543
  }
1544
  }
1545
 
1546
+ // v0.8.0 β€” pre-parse EVERY motion (not just referenced ones) so the static
1547
+ // emitter can turn the whole motion vocabulary into real @keyframes.
1548
+ const motionFrames = {};
1549
+ for (const name of Object.keys(motions)) {
1550
+ const m = parseMotion(name);
1551
+ if (m && m.frames.length) motionFrames[name] = m;
1552
+ }
1553
+
1554
  const mediaQueries = new Set();
1555
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1556
 
1557
+ return { rules, shadowRules, raws, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1558
  }
1559
 
1560
  /** naive `&` substitution + descendant join */
public/dssl/static.js ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL β€” static.js β€” the exit lane
3
+ *
4
+ * toCSS(doc) compiles a resolved document into a real CSS stylesheet:
5
+ * tokens β†’ :root custom properties (inside @layer tokens)
6
+ * states β†’ native pseudo-classes (:hover, :focus-visible, …)
7
+ * @media β†’ as-is Β· @container β†’ as-is
8
+ * layers β†’ native @layer (tokens < base < components < utilities < pin)
9
+ * pin { } β†’ !important (the one must-win block, same as the engine)
10
+ * motions β†’ @keyframes (so `animation: name` shorthand works too)
11
+ * raw { } β†’ verbatim
12
+ * ::before … β†’ shadow-rule output, as-is
13
+ * seen β†’ applied unconditionally (graceful no-JS state)
14
+ *
15
+ * What stays engine-only (skipped, and counted in the report):
16
+ * @signal rules (the JS bridge), `animate:`/engine-native properties
17
+ * (WAAPI lives in the runtime).
18
+ *
19
+ * The point: the SAME source can ship as a .css file when a page needs
20
+ * no-JS, a strict CSP, first-paint-before-JS, or 4.5x smaller HTML.
21
+ * ========================================================================= */
22
+
23
+ const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
24
+ const LAYER_NAMES = ['tokens', 'base', 'components', 'utilities', 'pin'];
25
+
26
+ const kebab = (p) => p.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
27
+
28
+ const declLine = (d, pin) => {
29
+ const value = pin && !/^--/.test(d.prop) ? d.value + ' !important' : d.value;
30
+ return ` ${d.prop}: ${value};`;
31
+ };
32
+
33
+ const isEngineProp = (p) => p && p.startsWith('@');
34
+
35
+ function emitRule(r, { pin = false, pseudo = null } = {}) {
36
+ let sel = r.sel;
37
+ if (pseudo) sel = sel + '::' + pseudo;
38
+ let out = `${sel} {\n${r.decls.map(d => declLine(d, pin)).join('\n')}\n}`;
39
+ for (const q of [...(r.containers || [])].reverse()) out = `@container ${q} {\n${out}\n}`;
40
+ for (const q of [...(r.media || [])].reverse()) out = `@media ${q} {\n${out}\n}`;
41
+ return out;
42
+ }
43
+
44
+ /** split one rule into { static, engineProps } β€” static decls survive */
45
+ function splitDecls(decls) {
46
+ const keep = [];
47
+ let engineProps = 0;
48
+ for (const d of decls) {
49
+ if (isEngineProp(d.prop)) { engineProps++; continue; }
50
+ keep.push(d);
51
+ }
52
+ return { keep, engineProps };
53
+ }
54
+
55
+ export function toCSS(doc, opts = {}) {
56
+ const file = opts.file || 'styles.dssl';
57
+ const report = {
58
+ file,
59
+ rules: 0, shadowRules: 0, keyframes: 0, raws: 0,
60
+ skippedSignals: 0, skippedEngine: 0, seenStripped: 0,
61
+ layers: [],
62
+ };
63
+
64
+ const chunks = [];
65
+ const header = opts.banner !== false
66
+ ? `/* generated by dssl build from ${file} β€” the static subset of DSSL v0.8.0.\n @signal rules and animate:/engine-native properties stay in the runtime. */\n`
67
+ : '';
68
+ let out = header;
69
+
70
+ const allRules = [...(doc.registryRule ? [doc.registryRule] : []), ...(doc.rules || [])];
71
+
72
+ // ---- keyframes from motions (name kept, so `animation: name` works) ----
73
+ const motionFrames = doc.motionFrames || {};
74
+ for (const [name, m] of Object.entries(motionFrames)) {
75
+ const frames = m.frames.map(f => {
76
+ const off = f.offset === 0 ? '0%' : f.offset === 100 ? '100%' : `${f.offset}%`;
77
+ const body = Object.entries(f.props).map(([p, v]) => ` ${kebab(p)}: ${v};`).join('\n');
78
+ return `${off} {\n${body}\n}`;
79
+ });
80
+ if (frames.length) {
81
+ chunks.push({ layer: null, css: `@keyframes ${name} {\n${frames.join('\n')}\n}` });
82
+ report.keyframes++;
83
+ }
84
+ }
85
+
86
+ // ---- regular rules ----
87
+ for (const r of allRules) {
88
+ if (r.signals && r.signals.length) { report.skippedSignals++; continue; }
89
+ const { keep, engineProps } = splitDecls(r.decls || []);
90
+ if (!keep.length) { if (engineProps) report.skippedEngine++; continue; }
91
+ const rule = { ...r, decls: keep };
92
+ // seen β†’ the no-JS graceful state: apply the final style unconditionally
93
+ if (/:seen\b/.test(rule.sel)) {
94
+ rule.sel = rule.sel.replace(/:seen\b/g, '').replace(/\s{2,}/g, ' ').trim();
95
+ report.seenStripped++;
96
+ if (!rule.sel) rule.sel = '*';
97
+ }
98
+ const pin = rule.layer === 'pin';
99
+ chunks.push({ layer: rule.layer, css: emitRule(rule, { pin }), order: rule.order });
100
+ report.rules++;
101
+ }
102
+
103
+ // ---- pseudo-element rules (the shadow-CSS lane, native in a build) ----
104
+ for (const r of doc.shadowRules || []) {
105
+ if (r.signals && r.signals.length) { report.skippedSignals++; continue; }
106
+ const { keep, engineProps } = splitDecls(r.decls || []);
107
+ if (!keep.length) { if (engineProps) report.skippedEngine++; continue; }
108
+ chunks.push({ layer: r.layer, css: emitRule({ ...r, decls: keep }, { pin: r.layer === 'pin', pseudo: r.pseudo }), order: r.order });
109
+ report.shadowRules++;
110
+ }
111
+
112
+ // ---- layers: emit the order statement once, then group ----
113
+ const usedLayers = [...new Set(chunks.map(c => c.layer).filter(Boolean))]
114
+ .sort((a, b) => (LAYER_RANK[a] ?? 2) - (LAYER_RANK[b] ?? 2));
115
+ if (usedLayers.length) {
116
+ report.layers = usedLayers;
117
+ out += `@layer ${LAYER_NAMES.join(', ')};\n\n`;
118
+ for (const layer of usedLayers) {
119
+ const inLayer = chunks.filter(c => c.layer === layer).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
120
+ out += `@layer ${layer} {\n${inLayer.map(c => c.css).join('\n\n')}\n}\n\n`;
121
+ }
122
+ }
123
+ for (const c of chunks.filter(x => x.layer === null)) out += c.css + '\n\n';
124
+
125
+ // ---- raw { } verbatim ----
126
+ for (const r of doc.raws || []) {
127
+ if (!r.css) continue;
128
+ out += `/* raw @ ${r.file || file}:${r.line} */\n` + r.css + '\n\n';
129
+ report.raws++;
130
+ }
131
+
132
+ return { css: out.trimEnd() + '\n', report };
133
+ }
public/llms.txt CHANGED
@@ -1,28 +1,28 @@
1
  # DSSL
2
 
3
- > DSSL is a style language for the web that replaces CSS. 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.1 (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">`. The engine auto-boots and 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. `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.1)
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`.
@@ -30,6 +30,10 @@
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
  - 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.
34
 
35
  ## JS API
@@ -38,14 +42,24 @@
38
  DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
39
  DSSL.apply(src, { scope, name }) // scoped engines for widgets
40
  DSSL.compile(src) // parse + resolve, no DOM (powers editors)
 
41
  DSSL.signal('menuOpen', true) // the JS -> styles bridge
42
  DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
43
  DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
44
  DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
45
  ```
46
 
 
 
 
 
 
 
 
 
 
47
  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.
48
 
49
  ## Editor support
50
 
51
- 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.
 
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. 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 at runtime β€” but v0.8.0 adds the exit lane: `dssl build` compiles the static subset to a real .css file for no-JS, strict-CSP or size-critical pages. 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.8.0 (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 β€” unless you build the static subset: `dssl build styles.dssl -o styles.css` emits real CSS (tokens β†’ custom properties, states β†’ :hover, layers β†’ @layer, motions β†’ @keyframes, `raw { }` verbatim, `seen` applied as its no-JS final state) and the report counts what stays runtime-only (`@signal` rules, `animate:`). For content pages that must not flash, use the build output or choose another tool.
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.8.0)
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`. v0.8.0 axis roles: distribution words (`between`/`around`/`evenly`) own the main axis, `stretch`/`baseline` own the cross axis, `center`/`start`/`end` fill free axes, and a word with nowhere to go warns instead of overwriting silently.
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`.
 
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
+ - Verbatim escape hatch (v0.8.0): `raw { }` / `@css { }` drops real CSS (@font-face, @keyframes, @supports, @import, @property) into the page stylesheet untouched; unknown at-rules skip their whole block with one clean error.
34
+ - Fluid sugar (v0.8.0): `font-size: fluid 16 24` (and any length-ish property) compiles to the linear clamp() between 360px and 1200px viewports; `fluid: 16, 24` still works.
35
+ - v0.8.0 review-driven fixes, all regression-tested incl. headless-Chromium computed-style tests of the docs: row/column axis roles (the documented `between center` example laid out wrong before); @font-face no longer crashes the compiler (unknown at-rules skip balanced); @supports inner rules no longer leak unconditionally; `url(...)` is atomic (unquoted form no longer false-errors); `!important` is a loud error that emits nothing; the animation shorthand warns when its motion is undefined instead of suggesting "span"; CSS-order transition values get the exact DSSL conversion in the error hint.
36
+ - 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.
37
  - 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.
38
 
39
  ## JS API
 
42
  DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
43
  DSSL.apply(src, { scope, name }) // scoped engines for widgets
44
  DSSL.compile(src) // parse + resolve, no DOM (powers editors)
45
+ DSSL.toCSS(src) // v0.8.0: the exit lane -> { css, report }
46
  DSSL.signal('menuOpen', true) // the JS -> styles bridge
47
  DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
48
  DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
49
  DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
50
  ```
51
 
52
+ ## CLI (v0.8.0)
53
+
54
+ ```sh
55
+ node cli/dssl.mjs check styles.dssl --strict # CI: exit 1 on errors, --json for machines
56
+ node cli/dssl.mjs build styles.dssl -o styles.css # the static subset as real CSS
57
+ ```
58
+
59
+ `check` runs the real compiler with file:line diagnostics and did-you-mean hints. `build` compiles the static subset (tokens β†’ custom properties, states β†’ :hover, layers β†’ @layer, motions β†’ @keyframes, raw { } verbatim, pin β†’ !important, seen β†’ its no-JS final state) and prints a report of what was emitted and what stays runtime-only.
60
+
61
  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.
62
 
63
  ## Editor support
64
 
65
+ 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. Install `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files. Source lives in `dssl-vscode/` in the repo.
public/styles/bench.dssl CHANGED
File without changes
public/styles/site.dssl CHANGED
@@ -118,7 +118,8 @@ select { use focus-ring }
118
  [data-view="cheat"] .nl-cheat,
119
  [data-view="spec"] .nl-spec,
120
  [data-view="playground"] .nl-playground,
121
- [data-view="changelog"] .nl-changelog {
 
122
  color: color.accent
123
  border-bottom-color: color.accent
124
  }
 
118
  [data-view="cheat"] .nl-cheat,
119
  [data-view="spec"] .nl-spec,
120
  [data-view="playground"] .nl-playground,
121
+ [data-view="changelog"] .nl-changelog,
122
+ [data-view="bench"] .nl-bench {
123
  color: color.accent
124
  border-bottom-color: color.accent
125
  }
scripts/build-engine-package.sh CHANGED
@@ -6,10 +6,11 @@ ROOT=/home/z/my-project
6
  STAGE=$(mktemp -d)
7
  trap 'rm -rf "$STAGE"' EXIT
8
 
9
- mkdir -p "$STAGE/dssl-engine/dssl" "$STAGE/dssl-engine/styles" "$STAGE/dssl-engine/examples"
10
  cp "$ROOT"/public/dssl/boot.js "$ROOT"/public/dssl/index.js "$ROOT"/public/dssl/engine.js \
11
- "$ROOT"/public/dssl/parser.js "$ROOT"/public/dssl/resolver.js "$ROOT"/public/dssl/utils.js \
12
  "$STAGE/dssl-engine/dssl/"
 
13
  cp "$ROOT/public/styles/tokens.dssl" "$STAGE/dssl-engine/styles/"
14
  cp "$ROOT/engine-package/USAGE.md" "$ROOT/engine-package/install.sh" "$STAGE/dssl-engine/"
15
  cp "$ROOT/engine-package/hello.dssl" "$STAGE/dssl-engine/examples/hello.dssl"
 
6
  STAGE=$(mktemp -d)
7
  trap 'rm -rf "$STAGE"' EXIT
8
 
9
+ mkdir -p "$STAGE/dssl-engine/dssl" "$STAGE/dssl-engine/styles" "$STAGE/dssl-engine/examples" "$STAGE/dssl-engine/cli"
10
  cp "$ROOT"/public/dssl/boot.js "$ROOT"/public/dssl/index.js "$ROOT"/public/dssl/engine.js \
11
+ "$ROOT"/public/dssl/parser.js "$ROOT"/public/dssl/resolver.js "$ROOT"/public/dssl/static.js "$ROOT"/public/dssl/utils.js \
12
  "$STAGE/dssl-engine/dssl/"
13
+ cp "$ROOT/cli/dssl.mjs" "$STAGE/dssl-engine/cli/"
14
  cp "$ROOT/public/styles/tokens.dssl" "$STAGE/dssl-engine/styles/"
15
  cp "$ROOT/engine-package/USAGE.md" "$ROOT/engine-package/install.sh" "$STAGE/dssl-engine/"
16
  cp "$ROOT/engine-package/hello.dssl" "$STAGE/dssl-engine/examples/hello.dssl"
scripts/repro-v072.mjs CHANGED
File without changes
scripts/repro-v073.mjs ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* repro-v073.mjs β€” reproduce every claim from the 2026-10-23 Claude review
2
+ * of v0.7.2 before touching any code. Green = the bug exists as reported. */
3
+ import { parse, STATE_WORDS } from '../public/dssl/parser.js';
4
+ import { resolve } from '../public/dssl/resolver.js';
5
+
6
+ const compileDoc = (src, file = 'repro.dssl') => {
7
+ const { ast, errors } = parse(src, file);
8
+ const doc = resolve(ast, file);
9
+ return { parseErrors: errors, doc };
10
+ };
11
+
12
+ let found = 0;
13
+ const repro = (name, cond) => {
14
+ if (cond) { found++; console.log(`REPRODUCED ${name}`); }
15
+ else console.log(`not repro'd ${name}`);
16
+ };
17
+
18
+ // --- bug 1: row: gap 8 between center loses "between" --------------------
19
+ {
20
+ const { doc } = compileDoc('toolbar { row: gap 8 between center }');
21
+ const decls = doc.rules[0]?.decls || [];
22
+ const jc = decls.filter(d => d.prop === 'justify-content');
23
+ const ai = decls.filter(d => d.prop === 'align-items');
24
+ console.log(' decls:', decls.map(d => `${d.prop}:${d.value}`).join(' | '));
25
+ repro('bug1: justify-content written twice (last wins = center)', jc.length === 2 && jc[1].value === 'center');
26
+ repro('bug1: bare center also writes align-items (both axes)', ai.length === 1);
27
+ }
28
+
29
+ // --- bug 2: @font-face crashes the compiler -------------------------------
30
+ {
31
+ let threw = null, out = '';
32
+ try {
33
+ const r = compileDoc('@font-face { font-family: X; src: url(x.woff2) }');
34
+ out = JSON.stringify(r.doc.rules || []);
35
+ } catch (e) { threw = e; }
36
+ repro('bug2: @font-face THROWS instead of compile error', !!threw);
37
+ if (threw) console.log(' throw:', threw.message);
38
+ }
39
+
40
+ // --- bug 3: @supports leaks inner rule unconditionally --------------------
41
+ {
42
+ const { parseErrors, doc } = compileDoc('@supports (display:grid) { .a { display: grid } }');
43
+ const leaked = (doc.rules || []).some(r => (r.sel || '').includes('a'));
44
+ const atErr = parseErrors.some(e => /@supports/.test(e.message));
45
+ console.log(' parse errors:', parseErrors.map(e => e.message).join(' ; ').slice(0, 120));
46
+ repro('bug3: unknown at-rule errors reported', atErr);
47
+ repro('bug3: inner rule STILL APPLIES unconditionally', leaked);
48
+ }
49
+
50
+ // --- bug 4: unquoted url() gives false errors ------------------------------
51
+ {
52
+ const { parseErrors, doc } = compileDoc('.a { background: url(a.png) no-repeat }');
53
+ const msgs = parseErrors.map(e => e.message).join(' ; ');
54
+ const badValue = /unknown value "(a\.png|no-repeat)"/.test(msgs);
55
+ const emitted = (doc.rules[0]?.decls || []).some(d => /url\(a\.png\)/.test(d.value));
56
+ console.log(' errors:', msgs.slice(0, 140) || '(none)');
57
+ repro('bug4: false unknown-value errors for unquoted url parts', badValue);
58
+ repro('bug4: declaration still emitted (error + emit confusion)', emitted);
59
+ // control: quoted form is fine
60
+ const q = compileDoc('.a { background: url("a.png") no-repeat }');
61
+ console.log(' quoted errors:', q.parseErrors.map(e => e.message).join(';').slice(0, 100) || '(none)');
62
+ }
63
+
64
+ // --- bug 5a: animation: spin 1s infinite -> did you mean "span" -----------
65
+ {
66
+ const { parseErrors } = compileDoc('.a { animation: spin 1s infinite }');
67
+ const silly = parseErrors.some(e => /span/.test(e.message));
68
+ console.log(' errors:', parseErrors.map(e => `${e.message}${e.hint ? ' [' + e.hint + ']' : ''}`).join(' ; ').slice(0, 160));
69
+ repro('bug5a: silly "span" suggestion instead of motion guidance', silly);
70
+ }
71
+
72
+ // --- bug 5b: !important errors AND still emits ----------------------------
73
+ {
74
+ const { parseErrors, doc } = compileDoc('.a { color: red !important }');
75
+ const wrongErr = parseErrors.some(e => /unknown value "important"/.test(e.message));
76
+ const emitted = (doc.rules[0]?.decls || []).some(d => /important/.test(d.value));
77
+ console.log(' errors:', parseErrors.map(e => e.message).join(';').slice(0, 120));
78
+ console.log(' emitted value:', doc.rules[0]?.decls.map(d => d.value).join('|'));
79
+ repro('bug5b: !important reported as unknown value "important"', wrongErr);
80
+ repro('bug5b: !important still emitted in the value', emitted);
81
+ }
82
+
83
+ // --- bug 5c: transition CSS form gives no conversion hint ------------------
84
+ {
85
+ const { parseErrors } = compileDoc('.a { transition: color .2s ease }');
86
+ const hint = parseErrors.some(e => /200/.test(e.hint || '') && /transition/.test(e.hint || ''));
87
+ console.log(' errors:', parseErrors.map(e => `${e.message}${e.hint ? ' [' + e.hint + ']' : ''}`).join(' ; ').slice(0, 200) || '(none)');
88
+ repro('bug5c: CSS transition form rejected with no DSSL-form hint', parseErrors.length > 0 && !hint);
89
+ }
90
+
91
+ console.log(`\n${found} claim(s) reproduced`);
scripts/test-extension-lib.mjs ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* test-extension-lib.mjs β€” functional smoke for the editor extension lib.
2
+ * Confirms the bundled engine copy (lib/engine) + analysis.js still agree:
3
+ * diagnostics, did-you-mean, completions, and the v0.7.2 cascade fix all
4
+ * behave through the exact code the .vsix ships. Run before packaging. */
5
+ import { resolve } from '../dssl-vscode/lib/engine/resolver.js';
6
+ import { parse, STATE_WORDS } from '../dssl-vscode/lib/engine/parser.js';
7
+ import { analyzeWorkspace, diagnosticsFor } from '../dssl-vscode/lib/analysis.js';
8
+
9
+ let pass = 0, fail = 0;
10
+ const ok = (name, cond) => { if (cond) { pass++; console.log(` ok ${name}`); } else { fail++; console.log(`FAIL ${name}`); } };
11
+ const compileDoc = (src, file) => resolve(parse(src, file).ast, file);
12
+
13
+ // 1. the fixed quickstart pattern: nested hover must override base bg
14
+ const quickstart = 'card {\n background: #fff\n hover { background: #f5f0e8 }\n}\n';
15
+ const doc = compileDoc(quickstart, 'test.dssl');
16
+ const base = doc.rules.find(r => r.sel === 'card');
17
+ const hov = doc.rules.find(r => r.sel === 'card:hover');
18
+ ok('nested hover rule exists', !!hov);
19
+ ok('hover overrides base bg', hov && hov.decls.some(d => d.prop === 'background' && d.value === '#f5f0e8'));
20
+ ok('hover sorts AFTER base (same layer)', base && hov && base.layer === hov.layer && hov.order > base.order);
21
+ ok('no invalid hex-alpha in output', JSON.stringify(doc).includes('#f5f0e8') && !JSON.stringify(doc).includes('/14px'));
22
+
23
+ // 2. diagnostics: typo caught with did-you-mean, no false positive on valid code
24
+ const ws = analyzeWorkspace(new Map([['test.dssl', 'card {\n bakground: red\n}\n']]));
25
+ const typoDiags = diagnosticsFor(ws, 'test.dssl');
26
+ ok('typo surfaces a diagnostic', typoDiags.some(d => /bakground|unknown property/i.test(d.message)));
27
+ ok('did-you-mean suggests background', typoDiags.some(d => /background/i.test(d.message)));
28
+ const cleanWs = analyzeWorkspace(new Map([['test.dssl', quickstart]]));
29
+ const cleanDiags = diagnosticsFor(cleanWs, 'test.dssl');
30
+ ok('valid quickstart produces zero errors', !cleanDiags.some(d => d.severity === 'error'));
31
+
32
+ // 3. raw hex alpha now legal (was the /50px bug)
33
+ const hexDoc = compileDoc('card { color: #ff0000/50 }', 'a.dssl');
34
+ ok('hex /50 compiles to color-mix, not /50px', hexDoc.rules[0].decls[0].value.includes('color-mix'));
35
+
36
+ // 4. STATE_WORDS export exists (used for the did-you-mean warning)
37
+ ok('STATE_WORDS exported by bundled parser', STATE_WORDS instanceof Set && STATE_WORDS.has('hover'));
38
+
39
+ // 5. TIME_PROPS fix reachable through the bundle: bare delay becomes ms
40
+ const tDoc = compileDoc('card { transition-delay: 200 }', 't.dssl');
41
+ ok('bare transition-delay becomes 200ms', /200\s*ms|200ms/.test(JSON.stringify(tDoc.rules[0].decls)));
42
+
43
+ console.log(`\n${pass} passed, ${fail} failed`);
44
+ process.exit(fail ? 1 : 0);
scripts/test-guide-browser.mjs ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* test-guide-browser.mjs β€” DOM-level tests for the docs (review item 3).
3
+ *
4
+ * The between-center bug compiled clean and laid out wrong, which is exactly
5
+ * the gap "it compiles" cannot cover. This harness serves the repo over a
6
+ * local port, boots the REAL engine in headless Chromium, applies every
7
+ * GUIDE.md example via the documented <script type="text/dssl"> boot path,
8
+ * and asserts the computed styles the guide promises. Run before any release. */
9
+ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
10
+ import { createServer } from 'node:http';
11
+ import { extname, join } from 'node:path';
12
+ import { chromium } from 'playwright';
13
+
14
+ const ROOT = '/home/z/my-project';
15
+ const PORT = 8931 + Math.floor(Math.random() * 200);
16
+
17
+ const MIME = { '.js': 'text/javascript', '.html': 'text/html', '.css': 'text/css', '.json': 'application/json' };
18
+ const server = createServer((req, res) => {
19
+ const p = req.url.split('?')[0];
20
+ if (p === '/favicon.ico') { res.writeHead(204); return res.end(); }
21
+ // the site serves /dssl/* out of public/ β€” mirror that mapping
22
+ const file = p.startsWith('/dssl/')
23
+ ? join(ROOT, 'public', p.slice(1))
24
+ : join(ROOT, p === '/' ? 'index.html' : p.slice(1));
25
+ try {
26
+ const body = readFileSync(file);
27
+ res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream', 'access-control-allow-origin': '*' });
28
+ res.end(body);
29
+ } catch {
30
+ res.writeHead(404); res.end('nope');
31
+ }
32
+ });
33
+ await new Promise(r => server.listen(PORT, '127.0.0.1', r));
34
+
35
+ // the five GUIDE.md blocks, verbatim, applied through the auto-boot path
36
+ const guide = readFileSync(join(ROOT, 'GUIDE.md'), 'utf8');
37
+ const blocks = [...guide.matchAll(/```dssl\n([\s\S]*?)```/g)].map(m => m[1]);
38
+ if (blocks.length < 5) { console.error('expected 5 guide blocks, found ' + blocks.length); process.exit(1); }
39
+
40
+ const html = `<!doctype html><html><head><meta charset="utf8">
41
+ <style>body { margin: 0 } .board { width: 600px; container-type: inline-size } .gallery { width: 400px }</style>
42
+ </head><body>
43
+ <div class="card">card</div>
44
+ <div class="toolbar"><span>a</span><span>b</span></div>
45
+ <div class="sidebar"><span>a</span><span>b</span></div>
46
+ <div class="board"><span>1</span><span>2</span><span>3</span><span>4</span></div>
47
+ <div class="gallery"><span>1</span><span>2</span><span>3</span></div>
48
+ <div class="modal"><span>mid</span></div>
49
+ <div class="hero"><span>hero</span></div>
50
+ ${blocks.map(b => `<script type="text/dssl">\n${b}\n</script>`).join('\n')}
51
+ <script type="module">
52
+ import '/dssl/index.js';
53
+ </script>
54
+ </body></html>`;
55
+
56
+ let pass = 0, fail = 0;
57
+ const ok = (name, cond, extra) => {
58
+ if (cond) { pass++; console.log(` ok ${name}`); }
59
+ else { fail++; console.log(`FAIL ${name}${extra ? ' β€” ' + extra : ''}`); }
60
+ };
61
+
62
+ // served over the local server so module imports are same-origin
63
+ const harnessPath = join(ROOT, '.guide-harness.html');
64
+ writeFileSync(harnessPath, html);
65
+ const browser = await chromium.launch();
66
+ const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
67
+ const consoleErrors = [];
68
+ page.on('console', m => { if (m.type() === 'error' || m.type() === 'warning') consoleErrors.push(m.type() + ': ' + m.text()); });
69
+ page.on('pageerror', e => consoleErrors.push('pageerror: ' + e.message));
70
+
71
+ await page.goto(`http://127.0.0.1:${PORT}/.guide-harness.html`, { waitUntil: 'domcontentloaded' });
72
+ await page.waitForFunction(() => window.DSSL && window.__dsslBooted, { timeout: 8000 }).catch(() => {});
73
+ await page.waitForTimeout(600); // IntersectionObserver + animations settle
74
+
75
+ const cs = (sel, prop, pseudo) => page.evaluate(([sel, prop, pseudo]) => {
76
+ const el = document.querySelector(sel);
77
+ if (!el) return null;
78
+ return getComputedStyle(el, pseudo || null).getPropertyValue(prop).trim();
79
+ }, [sel, prop, pseudo]);
80
+
81
+ /* block 1 β€” tokens + card base. Note: the guide composes β€” block 4 adds
82
+ * @media 768 { padding: 24 }, which legitimately wins at 1200px wide. */
83
+ ok('B1 card background is the token ink value', (await cs('.card', 'background-color')) === 'rgb(22, 22, 29)', await cs('.card', 'background-color'));
84
+ ok('B1 card padding at 1200px is block 4\'s @media 768 value (24px)', (await cs('.card', 'padding')) === '24px', await cs('.card', 'padding'));
85
+ ok('B1 card radius is radius.md (14px)', (await cs('.card', 'border-radius')) === '14px', await cs('.card', 'border-radius'));
86
+ ok('B1 card shadow resolves the md token (0 2 4 + alpha)', (await cs('.card', 'box-shadow')).includes('0px 2px 4px'), await cs('.card', 'box-shadow'));
87
+
88
+ /* block 2 β€” the layout words (the between-center bug lives here) */
89
+ ok('B2 toolbar justify-content space-between (the review bug)', (await cs('.toolbar', 'justify-content')) === 'space-between', await cs('.toolbar', 'justify-content'));
90
+ ok('B2 toolbar align-items center survives the same line', (await cs('.toolbar', 'align-items')) === 'center', await cs('.toolbar', 'align-items'));
91
+ ok('B2 toolbar is flex row with 8px gap', (await cs('.toolbar', 'display')) === 'flex' && (await cs('.toolbar', 'gap')) === '8px');
92
+ ok('B2 sidebar is a column with 16px gap', (await cs('.sidebar', 'flex-direction')) === 'column' && (await cs('.sidebar', 'gap')) === '16px');
93
+ const boardCols = await cs('.board', 'grid-template-columns');
94
+ ok('B2 board is a grid (auto-fill 220 form wins as the later rule)', (await cs('.board', 'display')) === 'grid' && boardCols.split(' ').length >= 2, boardCols);
95
+ ok('B2 gallery grid: 2 cols, rows 3, gap 8', (await cs('.gallery', 'grid-template-columns')).split(' ').length === 2 && (await cs('.gallery', 'gap')) === '8px', `${await cs('.gallery', 'grid-template-columns')} / ${await cs('.gallery', 'gap')}`);
96
+ const modalAI = await cs('.modal', 'align-items'), modalJI = await cs('.modal', 'justify-items');
97
+ ok('B2 modal stack centers both axes', modalAI === 'center' && modalJI === 'center', `${modalAI} / ${modalJI}`);
98
+ const heroPlace = await cs('.hero', 'place-items');
99
+ ok('B2 hero center: both centers everything', (heroPlace === 'center center') || (await cs('.hero', 'display')) === 'grid', heroPlace);
100
+
101
+ /* block 3 β€” hover beats base, by order (the 0.7.2 headline fix) */
102
+ const before = await cs('.card', 'background-color');
103
+ await page.hover('.card');
104
+ await page.waitForTimeout(120);
105
+ const after = await cs('.card', 'background-color');
106
+ ok('B3 hover changes the card background (real mouse)', before !== after && after === 'rgb(58, 47, 38)', `${before} -> ${after}`);
107
+ await page.mouse.move(0, 0);
108
+ await page.waitForTimeout(120);
109
+ ok('B3 un-hoving restores the base', (await cs('.card', 'background-color')) === before);
110
+
111
+ /* block 4 β€” media conditions */
112
+ await page.emulateMedia({ colorScheme: 'dark' });
113
+ await page.waitForTimeout(150);
114
+ ok('B4 dark mode applies (#181310)', (await cs('.card', 'background-color')) === 'rgb(24, 19, 16)', await cs('.card', 'background-color'));
115
+ await page.setViewportSize({ width: 700, height: 900 });
116
+ await page.waitForTimeout(150);
117
+ ok('B4 below 768 the wide padding is gone', (await cs('.card', 'padding')) === '16px', await cs('.card', 'padding'));
118
+ await page.setViewportSize({ width: 900, height: 900 });
119
+ await page.waitForTimeout(150);
120
+ ok('B4 at 900px padding: 24 applies', (await cs('.card', 'padding')) === '24px', await cs('.card', 'padding'));
121
+ await page.emulateMedia({ colorScheme: 'light' });
122
+ await page.setViewportSize({ width: 1200, height: 900 });
123
+ await page.waitForTimeout(150);
124
+
125
+ /* block 5 β€” seen + hover animations (WAAPI) */
126
+ const animCount = await page.evaluate(() => {
127
+ const el = document.querySelector('.card');
128
+ el.scrollIntoView();
129
+ return new Promise(res => setTimeout(() => res(el.getAnimations().length), 400));
130
+ });
131
+ ok('B5 seen fires a real animation on the card', animCount > 0, 'animations: ' + animCount);
132
+ await page.hover('.card');
133
+ await page.waitForTimeout(120);
134
+ const liftAnims = await page.evaluate(() => document.querySelector('.card').getAnimations().length);
135
+ ok('B5 hover plays the lift motion', liftAnims > 0, 'animations on hover: ' + liftAnims);
136
+
137
+ ok('no console errors or warnings during the whole run', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | '));
138
+
139
+ await browser.close();
140
+ server.close();
141
+ console.log(`\n${pass} passed, ${fail} failed`);
142
+ process.exit(fail ? 1 : 0);
scripts/test-guide-examples.mjs CHANGED
File without changes
scripts/test-v072.mjs CHANGED
@@ -131,7 +131,10 @@ const declsOf = (doc, sel) => doc.rules.filter(r => r.sel === sel).flatMap(r =>
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
 
 
131
  }
132
  /* 12 β€” version */
133
  {
134
+ // v0.8.0 β€” pin loosened the same way v070's was: the suite must survive
135
+ // later releases, but catch an accidental DOWNGRADE below 0.7.2.
136
+ const [maj, min, pat] = String(VERSION).split('.').map(Number);
137
+ ok(maj === 0 && (min > 7 || (min === 7 && pat >= 2)), 'engine VERSION is >= 0.7.2 (got ' + VERSION + ')');
138
  ok(STATE_WORDS.has('hover') && STATE_WORDS.has('seen'), 'STATE_WORDS exported');
139
  }
140
 
scripts/test-v073.mjs ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* test-v073.mjs β€” one assertion per fix in the 0.8.0 "the exit drop".
2
+ * Source: the 2026-10-23 Claude review (5 bugs) + wishlist items 1/3/4/6/8.
3
+ * Every claim was reproduced first (scripts/repro-v073.mjs), these assert the
4
+ * fixes and the new features against the real compiler. */
5
+ import { parse } from '../public/dssl/parser.js';
6
+ import { resolve } from '../public/dssl/resolver.js';
7
+ import { toCSS } from '../public/dssl/static.js';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ let pass = 0, fail = 0;
14
+ const ok = (name, cond, extra) => {
15
+ if (cond) { pass++; console.log(` ok ${name}`); }
16
+ else { fail++; console.log(`FAIL ${name}${extra ? ' β€” ' + extra : ''}`); }
17
+ };
18
+
19
+ const compileDoc = (src, file = 't.dssl') => {
20
+ const { ast, errors } = parse(src, file);
21
+ const doc = resolve(ast, file);
22
+ return { doc, errors: [...errors, ...doc.errors] };
23
+ };
24
+ const declsOf = (doc, i = 0) => doc.rules[i]?.decls || [];
25
+ const count = (decls, prop) => decls.filter(d => d.prop === prop).length;
26
+ const val = (decls, prop) => decls.find(d => d.prop === prop)?.value;
27
+ const lastVal = (decls, prop) => decls.filter(d => d.prop === prop).pop()?.value;
28
+
29
+ /* ---- bug 1: row/column axis roles ---- */
30
+ {
31
+ const { doc, errors } = compileDoc('toolbar { row: gap 8 between center }');
32
+ const d = declsOf(doc);
33
+ ok('1a. between center: justify-content is space-between, written once', count(d, 'justify-content') === 1 && val(d, 'justify-content') === 'space-between');
34
+ ok('1b. between center: align-items is center', val(d, 'align-items') === 'center');
35
+ ok('1c. between center: zero errors', errors.length === 0);
36
+ const w = compileDoc('toolbar { row: gap 8 center between }');
37
+ ok('1d. center between: warns about the justify-content override', w.doc.warnings.some(x => /overrides "center"/.test(x.message)));
38
+ ok('1e. center between: space-between still wins (later word, cascade takes last)', lastVal(declsOf(w.doc), 'justify-content') === 'space-between');
39
+ const dupe = compileDoc('toolbar { row: center center }');
40
+ ok('1f. center center: duplicate-word warning', dupe.doc.warnings.some(x => /written twice/.test(x.message)));
41
+ const nowhere = compileDoc('toolbar { row: start end }');
42
+ ok('1g. start end: "end" has nowhere to go, loudly', nowhere.doc.warnings.some(x => /nowhere to go/.test(x.message)));
43
+ const stretch = compileDoc('toolbar { row: gap 8 stretch }');
44
+ ok('1h. row: stretch sets align-items only (no justify-content filler)', count(declsOf(stretch.doc), 'align-items') === 1 && count(declsOf(stretch.doc), 'justify-content') === 0);
45
+ const stack = compileDoc('.wrap { stack: center }');
46
+ ok('1i. stack: center unchanged (place-items center center)', val(declsOf(stack.doc), 'place-items') === 'center center');
47
+ const col = compileDoc('.list { column: gap 2 evenly stretch }');
48
+ const cd = declsOf(col.doc);
49
+ ok('1j. column evenly stretch: main space-evenly + cross stretch', val(cd, 'justify-content') === 'space-evenly' && val(cd, 'align-items') === 'stretch');
50
+ }
51
+
52
+ /* ---- bug 2: @font-face must not throw ---- */
53
+ {
54
+ let threw = null, doc = null, errors = [];
55
+ try {
56
+ const r = compileDoc('@font-face { font-family: X; src: url(x.woff2) }');
57
+ doc = r.doc; errors = r.errors;
58
+ } catch (e) { threw = e; }
59
+ ok('2a. @font-face does not throw', !threw, threw && threw.message);
60
+ ok('2b. @font-face reports exactly one clean error', errors.length === 1 && /@font-face/.test(errors[0].message));
61
+ ok('2c. @font-face inner decls leak nowhere', !doc.rules.some(r => r.decls?.some(d => d.prop === 'src' || d.prop === 'font-family')));
62
+ }
63
+
64
+ /* ---- bug 3: unknown at-rules skip their whole block ---- */
65
+ {
66
+ const { doc, errors } = compileDoc('@supports (display:grid) { .a { display: grid } }\n.b { color: blue }');
67
+ ok('3a. @supports inner rule does NOT apply unconditionally', !doc.rules.some(r => r.sel === 'a' || r.sel === '.a'));
68
+ ok('3b. one clean error for @supports (no stray unexpected })', errors.length === 1 && /@supports/.test(errors[0].message));
69
+ ok('3c. the rule after the block still parses', doc.rules.some(r => (r.sel || '').includes('b')));
70
+ ok('3d. the hint points at raw { }', /raw \{ \}/.test(errors[0].hint || ''));
71
+ const unclosed = compileDoc('@font-face { font-family: X');
72
+ ok('3e. unclosed unknown block says so instead of eating the file', unclosed.errors.some(e => /never closed/.test(e.message)));
73
+ }
74
+
75
+ /* ---- bug 4: url() is atomic ---- */
76
+ {
77
+ const { doc, errors } = compileDoc('.a { background: url(a.png) no-repeat }');
78
+ ok('4a. unquoted url() + no-repeat: zero errors', errors.length === 0);
79
+ ok('4b. value passes through untouched', val(declsOf(doc), 'background') === 'url(a.png) no-repeat');
80
+ const q = compileDoc('.a { background: url("a.png") no-repeat center / cover }');
81
+ ok('4c. quoted url + position/size also clean', q.errors.length === 0 && /url\("a\.png"\) no-repeat center \/ cover/.test(val(declsOf(q.doc), 'background')));
82
+ const s = compileDoc('.a { background-image: url(img/hero.png), linear-gradient(90, red -> blue) }');
83
+ ok('4d. unquoted url inside a list with a gradient: clean', s.errors.length === 0);
84
+ }
85
+
86
+ /* ---- bug 5a: animation gets motion guidance, not "span" ---- */
87
+ {
88
+ const defined = compileDoc('motion spin { from { opacity: 0 } to { opacity: 1 } }\n.a { animation: spin 1s infinite }');
89
+ ok('5a-1. animation with a defined motion: silent', defined.errors.length === 0 && defined.doc.warnings.length === 0);
90
+ const missing = compileDoc('.a { animation: ghost 1s infinite }');
91
+ ok('5a-2. animation with an undefined motion: warning names the DSSL way', missing.doc.warnings.some(w => /motion/.test(w.message) && /animate/.test(w.hint || '')));
92
+ ok('5a-3. no "span" suggestion anywhere', !JSON.stringify(missing.errors).includes('span'));
93
+ ok('5a-4. bare duration becomes ms, not px', /400ms/.test((() => { const r = compileDoc('.a { animation: ghost 400 }'); return val(declsOf(r.doc), 'animation'); })()));
94
+ const animateHint = compileDoc('.a { animate: spin 400 }');
95
+ ok('5a-5. unknown motion hint teaches the motion block', /motion spin \{/.test(animateHint.errors[0].hint || ''));
96
+ }
97
+
98
+ /* ---- bug 5b: !important is loud and NOT emitted ---- */
99
+ {
100
+ const { doc, errors } = compileDoc('.a { color: red !important }');
101
+ ok('5b-1. !important errors', errors.length === 1 && /!important does not exist/.test(errors[0].message));
102
+ ok('5b-2. the error names pin { } as the answer', /pin \{ \}/.test(errors[0].hint || ''));
103
+ ok('5b-3. nothing is emitted', declsOf(doc).length === 0);
104
+ const quoted = compileDoc('.a { content: "!important" }');
105
+ ok('5b-4. quoted "!important" is just text, no false trip', quoted.errors.length === 0 && val(declsOf(quoted.doc), 'content') === '"!important"');
106
+ }
107
+
108
+ /* ---- bug 5c: CSS transition form teaches the DSSL form ---- */
109
+ {
110
+ const { errors } = compileDoc('.a { transition: color .2s ease }');
111
+ ok('5c-1. CSS transition form errors (not silently dropped)', errors.length === 1);
112
+ ok('5c-2. the hint shows the exact conversion', /transition: 200 ease color/.test(errors[0].hint || ''));
113
+ const dssl = compileDoc('.a { transition: 200 ease color }');
114
+ ok('5c-3. the DSSL form still compiles clean', dssl.errors.length === 0 && val(declsOf(dssl.doc), 'transition') === 'color 200ms ease');
115
+ }
116
+
117
+ /* ---- feature: raw { } / @css { } verbatim passthrough ---- */
118
+ {
119
+ const one = compileDoc('raw { @font-face { font-family: X; src: url(x.woff2) } }');
120
+ ok('6a. raw { } captured verbatim', one.doc.raws.length === 1 && one.doc.raws[0].css.includes('@font-face') && one.doc.raws[0].css.includes('url(x.woff2)'));
121
+ const alias = compileDoc('@css { @keyframes x { from { opacity: 0 } } }');
122
+ ok('6b. @css alias works', alias.doc.raws.length === 1 && alias.doc.raws[0].css.includes('@keyframes x'));
123
+ const multi = compileDoc('raw {\n @supports (display: grid) {\n .a { display: grid }\n }\n}\n.b { color: red }');
124
+ ok('6c. multi-line raw captured whole, parsing resumes after', multi.doc.raws.length === 1 && multi.doc.raws[0].css.includes('@supports') && multi.doc.rules.some(r => (r.sel || '').includes('b')));
125
+ const nested = compileDoc('.card { raw { a } }');
126
+ ok('6d. nested raw is a loud error', nested.errors.some(e => /raw \{ \} must be top-level/.test(e.message)));
127
+ const unclosed = compileDoc('raw { @font-face {');
128
+ ok('6e. unclosed raw is a loud error', unclosed.errors.some(e => /raw \{ \} block was never closed/.test(e.message)));
129
+ }
130
+
131
+ /* ---- feature: fluid sugar ---- */
132
+ {
133
+ const inline = compileDoc('.a { font-size: fluid 16 24 }');
134
+ ok('7a. font-size: fluid 16 24 β†’ clamp', val(declsOf(inline.doc), 'font-size') === 'clamp(16px, calc(16px + 0.952vw), 24px)');
135
+ const bounds = compileDoc('.a { font-size: fluid 16 24 400 800 }');
136
+ ok('7b. custom viewport bounds change the slope', val(declsOf(bounds.doc), 'font-size') === 'clamp(16px, calc(16px + 2.000vw), 24px)');
137
+ const gap = compileDoc('.a { gap: fluid 8 16 }');
138
+ ok('7c. gap takes inline fluid too', val(declsOf(gap.doc), 'gap') === 'clamp(8px, calc(8px + 0.952vw), 16px)');
139
+ const legacy = compileDoc('.a { fluid: 16, 24 }');
140
+ ok('7d. the fluid: 16, 24 form still works', val(declsOf(legacy.doc), 'font-size') === 'clamp(16px, calc(16px + 0.952vw), 24px)');
141
+ }
142
+
143
+ /* ---- feature: toCSS β€” the exit lane ---- */
144
+ {
145
+ const src = `tokens { color { ink: #16161d } }
146
+ @layer utilities { .toolbar { row: gap 8 between center } }
147
+ .card {
148
+ background: color.card
149
+ hover { background: #f5f0e8 }
150
+ seen { opacity: 1 }
151
+ display: grid
152
+ animate: rise 400
153
+ ::before { content: "β†’" }
154
+ }
155
+ pin { .legal { color: red } }
156
+ @media dark { .card { background: #111 } }
157
+ @signal theme == dark { .card { background: #000 } }
158
+ motion rise { from { opacity: 0 } to { opacity: 1 } }
159
+ raw { @font-face { font-family: X; src: url(x.woff2) } }`;
160
+ const { ast, errors } = parse(src, 'build.dssl');
161
+ const doc = resolve(ast, 'build.dssl');
162
+ const built = toCSS(doc, { file: 'build.dssl', banner: false });
163
+ const css = built.css;
164
+ ok('8a. @layer statement declares the engine order', css.includes('@layer tokens, base, components, utilities, pin;'));
165
+ ok('8b. tokens become :root custom properties', css.includes('--dssl-color-ink: #16161d'));
166
+ ok('8c. nested hover becomes .card:hover', /\.card:hover\s*\{[^}]*#f5f0e8/.test(css));
167
+ ok('8d. seen applies unconditionally in the build', /\.card\s*\{[^}]*opacity: 1/.test(css) && !/\.card:seen/.test(css));
168
+ ok('8e. ::before is native CSS in the build', /\.card::before\s*\{[^}]*content: "β†’"/.test(css));
169
+ ok('8f. pin becomes !important', /\.legal\s*\{[^}]*color: red !important/.test(css));
170
+ ok('8g. @media dark wraps the rule', css.includes('@media (prefers-color-scheme: dark)'));
171
+ ok('8h. motion becomes @keyframes rise', css.includes('@keyframes rise'));
172
+ ok('8i. raw { } lands verbatim', css.includes('@font-face { font-family: X; src: url(x.woff2) }'));
173
+ ok('8i-2. animate-only decls are dropped, static ones kept', /\.card\s*\{[^}]*display: grid/.test(css) && !css.includes('@animate'));
174
+ ok('8j. @signal rules are skipped and counted', built.report.skippedSignals === 1 && !css.includes('theme == dark'));
175
+ ok('8k. report counts what shipped', built.report.rules >= 5 && built.report.keyframes === 1 && built.report.raws === 1 && built.report.seenStripped === 1);
176
+ }
177
+
178
+ /* ---- feature: the CLI ---- */
179
+ {
180
+ const dir = mkdtempSync(join(tmpdir(), 'dssl-cli-'));
181
+ const cleanFile = join(dir, 'clean.dssl');
182
+ const badFile = join(dir, 'bad.dssl');
183
+ const warnFile = join(dir, 'warn.dssl');
184
+ writeFileSync(cleanFile, '.a { color: red\n hover { color: blue } }\n');
185
+ writeFileSync(badFile, '.a { color: red !important }\n');
186
+ writeFileSync(warnFile, '.a { animation: ghost 400 }\n');
187
+ const cli = join(import.meta.dirname, '..', 'cli', 'dssl.mjs');
188
+ const run = (args) => {
189
+ try { return { out: execFileSync('node', [cli, ...args], { encoding: 'utf8' }), code: 0 }; }
190
+ catch (e) { return { out: (e.stdout || '') + (e.stderr || ''), code: e.status }; }
191
+ };
192
+ const c0 = run(['check', cleanFile]);
193
+ ok('9a. check clean file exits 0', c0.code === 0);
194
+ const c1 = run(['check', badFile]);
195
+ ok('9b. check file with errors exits 1', c1.code === 1 && /!important/.test(c1.out));
196
+ const cj = JSON.parse(run(['check', badFile, '--json']).out);
197
+ ok('9c. check --json is machine-readable', cj.files[0].ok === false && cj.files[0].errors[0].line === 1);
198
+ const cs = run(['check', warnFile, '--strict']);
199
+ ok('9d. check --strict makes warnings fatal', cs.code === 1);
200
+ const cw = run(['check', warnFile]);
201
+ ok('9e. check without --strict: warnings are not fatal', cw.code === 0);
202
+ const cssOut = join(dir, 'out.css');
203
+ const b = run(['build', cleanFile, '-o', cssOut]);
204
+ ok('9f. build writes a .css file and reports', b.code === 0 && /\.css/.test(b.out));
205
+ const built = readFileSync(cssOut, 'utf8');
206
+ ok('9g. built CSS contains the hover rule', /\.a:hover\s*\{[^}]*blue/.test(built));
207
+ const bj = JSON.parse(run(['build', cleanFile, '--json']).out);
208
+ ok('9h. build --json carries css + report', typeof bj.css === 'string' && bj.report.rules >= 1);
209
+ const missing = run(['check', join(dir, 'nope.dssl')]);
210
+ ok('9i. missing file exits 2', missing.code === 2);
211
+ const v = run(['--version']);
212
+ ok('9j. --version prints 0.8.0', v.out.trim() === '0.8.0');
213
+ rmSync(dir, { recursive: true, force: true });
214
+ }
215
+
216
+ console.log(`\n${pass} passed, ${fail} failed`);
217
+ process.exit(fail ? 1 : 0);
src/components/dssl/SiteRoot.tsx CHANGED
@@ -122,7 +122,7 @@ export default function SiteRoot() {
122
  </nav>
123
 
124
  <div className="nav-side">
125
- <a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.7.1</a>
126
  <button type="button" className="theme-btn" onClick={cycleTheme} aria-label="Toggle color theme" title="Theme: auto / light / dark">
127
  <span className="icon-sun"><SunIcon /></span>
128
  <span className="icon-moon"><MoonIcon /></span>
@@ -151,7 +151,7 @@ export default function SiteRoot() {
151
  <div className="wrap footer-inner">
152
  <div className="footer-col">
153
  <div className="footer-note">
154
- <strong>DSSL</strong> β€” domain specific style language. Built v0.1 β†’ v0.7.1 while
155
  building this site; every feature it needed, it grew, and every bug you sent got
156
  fixed for real. This page runs on its own engine, no CSS files attached β€”
157
  the engine styles this DOM directly.
 
122
  </nav>
123
 
124
  <div className="nav-side">
125
+ <a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.8.0</a>
126
  <button type="button" className="theme-btn" onClick={cycleTheme} aria-label="Toggle color theme" title="Theme: auto / light / dark">
127
  <span className="icon-sun"><SunIcon /></span>
128
  <span className="icon-moon"><MoonIcon /></span>
 
151
  <div className="wrap footer-inner">
152
  <div className="footer-col">
153
  <div className="footer-note">
154
+ <strong>DSSL</strong> β€” domain specific style language. Built v0.1 β†’ v0.8.0 while
155
  building this site; every feature it needed, it grew, and every bug you sent got
156
  fixed for real. This page runs on its own engine, no CSS files attached β€”
157
  the engine styles this DOM directly.
src/components/dssl/views/BenchView.tsx CHANGED
File without changes
src/components/dssl/views/ChangelogView.tsx CHANGED
@@ -11,6 +11,50 @@ const ENTRIES: {
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  {
15
  version: "v0.7.1",
16
  date: "growth phase Β· the credibility drop",
 
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
14
+ {
15
+ version: "v0.8.0",
16
+ date: "growth phase Β· the exit drop",
17
+ title: "A second review found five bugs. The answer fixes them and adds the exit lane.",
18
+ why: "another external review landed with repro snippets: a layout word bug that compiled clean and laid out wrong, an at-rule that crashed the compiler, an @supports leak, url() false errors, and a wishlist that said build-a-CSS-exit first. Every claim was reproduced against the real compiler before anything moved, the same discipline as last time",
19
+ kind: "spec",
20
+ body:
21
+ "The layout bug was the worst kind: silent and documented. row: gap 8 between center β€” the guide's own example β€” compiled with zero errors and lost the between, because every place word wrote BOTH axes and the later center overwrote justify-content. Words now own their axis: between/around/evenly are main-axis words, stretch/baseline are cross-axis words, center/start/end fill whichever axes are still free, and a word with nowhere to go is a loud warning instead of a silent overwrite. @font-face used to crash the whole compiler (node.children is not iterable β€” an uncaught throw, the entire stylesheet dead); unknown at-rules now skip their whole balanced block with one clean error, which also kills the @supports leak where the inner rule applied unconditionally after an error. url() is atomic now, so url(a.png) no-repeat stopped raising two false errors. !important errors loudly and emits NOTHING (it used to error AND still emit red !important, which the browser then silently dropped). The animation shorthand stopped suggesting did you mean \"span\" for motion names and warns the DSSL way instead (motion name { } + animate:), and the CSS transition form now teaches the conversion in the error: transition: color .2s ease β†’ write transition: 200 ease color. Then the wishlist's top four, because they fix tested problems: raw { } / @css { } drops verbatim CSS (@font-face, @keyframes, @supports, @import) into the page stylesheet; DSSL.toCSS() and dssl build compile the static subset to a real .css file β€” tokens to custom properties, states to :hover, layers to @layer, motions to @keyframes, pin to !important, seen to its no-JS final state, with a report that counts what stays in the runtime; dssl check puts the real compiler in CI with exit codes and --json; and the docs examples are now rendered in headless Chromium with computed-style assertions (real mouse hover, real IntersectionObserver, dark mode, viewport widths) because \"it compiles\" is what let the layout bug ship. Bonus sugar: font-size: fluid 16 24 compiles to the clamp() everyone writes wrong by hand.",
22
+ code: `# the exit lane: same source, real CSS, no runtime required
23
+ $ dssl check styles.dssl # CI: exit 1 on errors, --json for editors
24
+ $ dssl build styles.dssl -o styles.css
25
+ built styles.css from styles.dssl
26
+ 6 rule(s), 1 pseudo-element rule(s), 1 @keyframes, 1 raw block(s)
27
+ layers: tokens < components < utilities
28
+ 1 seen rule(s) applied unconditionally (the no-JS state)
29
+
30
+ /* tokens β†’ custom properties Β· states β†’ :hover Β· layers β†’ @layer
31
+ motions β†’ @keyframes Β· raw { } β†’ verbatim Β· pin β†’ !important */
32
+ .toolbar {
33
+ display: flex;
34
+ justify-content: space-between; /* the guide example, now true */
35
+ align-items: center;
36
+ font-size: clamp(16px, calc(16px + 0.952vw), 24px);
37
+ }`,
38
+ },
39
+ {
40
+ version: "v0.7.2",
41
+ date: "growth phase Β· the review drop",
42
+ title: "An external review reproduced twelve bugs. This release is the answer.",
43
+ why: "a thorough third-party agent review landed with repro cases instead of vibes: nested-state cascade loss, dead nested @media, broken docs examples, unitless delays, silent hex alpha, page-wide hover recomputes. The only honest response was to reproduce every claim, fix what was real, and make the docs examples test-verified",
44
+ kind: "spec",
45
+ body:
46
+ "The headline bug: a nested hover block sorted BEFORE its parent\u2019s own declarations, so background: red on the parent always beat hover { background: red } nested inside it \u2014 the exact pattern the docs and the starter teach. Same walk, worse: nested @media, @signal and @layer blocks flushed with the parent context, so their condition was dropped entirely and they sorted before base. A nested dark mode was silently dead. Both fixed at the resolver walk: nested blocks now flush after everything the parent said so far, and every block flushes with the context it was actually walked with. The parser got its own fix: declarations and a nested block sharing one line (background: red; hover { ... }) used to swallow the declaration into the block\u2019s selector, so it vanished. Raw hex alpha is real now: #ff0000/50 compiles to color-mix instead of the invalid #ff0000/50px. transition-delay and friends take bare numbers as ms (they used to ship unitless-invalid or px). grid: 3 gap 12 \u2014 the documented form \u2014 actually works, and rows N joined it. seen { opacity: 0 -> 1 } now fails LOUD with the two real patterns in the hint. A nested hovr typo warns did-you-mean instead of silently becoming a descendant selector. fill, stroke, text-wrap and content-visibility joined the vocabulary. ::part() rides the shadow pipeline; ::slotted() gets a loud explanation of why it cannot work from the light DOM. The engine caught up too: the gate prefilter actually runs before native matches() now (the review caught it in the wrong order), hover recomputes are scoped to the hovered subtree and its ancestors instead of the whole page (the review measured 33,060 recomputes for hovering 8 cards in a 1k list \u2014 the candidate collection that caused it is gone), and checkbox states finally hear change and toggle events, so unchecking with Space unstyles immediately. And the feature everyone asked about shipped as documentation: DSSL has been embeddable in HTML all along via <style type=\"text/dssl\">, and now <script type=\"text/dssl\"> works too. One distribution note the release has to carry: the editor extension bundles this same engine, but the packaged 0.7.1 vsix was built before these fixes landed, so anyone who installed it was still getting the old compiler's squiggles. The 0.7.2 vsix replaces it in the repo files and is the first package that actually contains this changelog.",
47
+ code: `// the pattern the docs teach, now actually true:
48
+ .card {
49
+ background: color.card
50
+ shadow: sm
51
+ hover { background: color.card-hover; shadow: lg }
52
+
53
+ @media dark { background: color.card-dark }
54
+
55
+ seen { animate: rise-in 400 snap }
56
+ }`,
57
+ },
58
  {
59
  version: "v0.7.1",
60
  date: "growth phase Β· the credibility drop",
src/components/dssl/views/SpecView.tsx CHANGED
@@ -11,7 +11,7 @@ export default function SpecView() {
11
  <p className="page-sub">
12
  The v0.1 draft was put to a seven-point vote. This document is the ratified
13
  v0.2 result β€” every decision below is implemented by the engine serving this
14
- page. The engine itself has kept growing since: it is now at v0.7.1 (see the
15
  changelog for what the site demanded).
16
  </p>
17
  </div>
 
11
  <p className="page-sub">
12
  The v0.1 draft was put to a seven-point vote. This document is the ratified
13
  v0.2 result β€” every decision below is implemented by the engine serving this
14
+ page. The engine itself has kept growing since: it is now at v0.8.0 (see the
15
  changelog for what the site demanded).
16
  </p>
17
  </div>