EHS-Studio Claude Sonnet 5.5 commited on
Commit
6859477
·
1 Parent(s): 7de9251

v0.15.0: the second game review

Browse files

Aura follows moving things under reduced motion, skips invisible emitters (always keeps an anchor), z N
layers. dssl check, the console and DSSL.lint name rules a later rule always beats. Typed and scoped
signals that restyle only what their rules select; DSSL.flush/settled/computed; pauseMotion/stepMotion.
animate lists, DSSL.play on pseudo-elements, outline-shape, rotate: 5, states inside ::after, a clear
error for a CSS font shorthand, --engine for the CLI, quieter DSSL.lint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

GUIDE.md CHANGED
@@ -461,7 +461,7 @@ motion boing { 0%, 100% { translate: 0 0 } 40% { translate: 0 -18 } essential }
461
 
462
  ```dssl
463
  tokens { shadow { pop: 0 4 0 #000 } radius { pill: 99 } }
464
- .tomato { gradient: linear 180, #f55 -> #b11 + radial-gradient(circle, #fff6 18%, transparent 20%) tile 24 offset 4 4 }
465
  .title { text-stroke: 6 color.ink; font-size: 48 } // an outline OUTSIDE the letters
466
  .badge { shadow: pop; radius: pill } // your own shadow and radius tokens, by bare name
467
  .ring { background: conic-gradient(from 0deg, red, blue) } // from, at, in... all accepted
@@ -508,6 +508,89 @@ tokens { shadow { pop: 0 4 0 #000 } radius { pill: 99 } }
508
  - The "rule aligns without display" check moved from `dssl check` (it cannot see another rule giving the same element `display: flex`,
509
  so it cried wolf) to the build report and to `DSSL.lint`, which know.
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  ### Fonts
512
 
513
  ```dssl
@@ -532,7 +615,9 @@ A family line is `Family: weights [italic] [display swap|block|fallback|optional
532
  .sheen { gradient: radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 }
533
  ```
534
 
535
- `repeating` turns any kind into its repeating form. `in oklch` (or `oklab`, `hsl`, `lab`, `srgb`, with `longer hue` and friends) picks the colour space the blend happens in. A lone position between two stops is a midpoint. Two positions make a hard band. Bare numbers in a stop position are px (degrees on `conic`). `mesh` takes a base colour and blobs: `color at x y [size n]` (default size 60%). `+` joins layers, first on top. A direction is optional: `linear #f00 -> #00f` works.
 
 
536
 
537
  ### Inspector and hot reload
538
 
@@ -791,13 +876,17 @@ and listed, so the file you get always compiles.
791
  ```js
792
  import { DSSL } from '/dssl/index.js' // or just use window.DSSL after boot
793
 
794
- DSSL.version // "0.14.0": a string, not a function
795
  DSSL.compile(source) // { ok, errors, warnings, stats, doc }: parse and resolve, no DOM
796
  DSSL.apply(source, { name, scope, replace })
797
  await DSSL.load(['a.dssl', 'b.dssl'])
798
  DSSL.toCSS(source | [sources], opts) // the exit lane: { css, map, report } (see `dssl build`)
799
- DSSL.signal('theme', 'dark') // drives @signal rules
800
- DSSL.getSignal('theme') // the current value
 
 
 
 
801
  DSSL.refresh() / DSSL.refresh(el) // recompute the page, or one subtree (even a static one)
802
  DSSL.replay(selector) // run entrance motions again
803
  DSSL.errors() // a function: the errors of the last compile
@@ -822,7 +911,7 @@ Three ways to get it:
822
 
823
  1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
824
  https://open-vsx.org/extension/ehs-studio/dssl-vscode
825
- 2. VS Code: download `dssl-vscode/dssl-vscode-0.14.0.vsix` from the repo files, then
826
  Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
827
  3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
828
 
@@ -979,6 +1068,7 @@ node scripts/test-static-parity.mjs # 0.13.0: every page of this site, built
979
  node scripts/test-v0130.mjs # 0.13.0: build fidelity, source maps, strict CSP, server rendering
980
  node scripts/bench-compiled.mjs # 0.13.0: compiled DSSL vs hand-written CSS, startup and churn (takes minutes)
981
  node scripts/test-v0140.mjs # 0.14.0: the game-review fixes, and that the docs match the code
 
982
  node scripts/test-v0100.mjs # 0.10.0: glass, tilt, named-colour alpha, playground examples, HTML filter
983
  node scripts/test-designer.mjs # the designer's state to DSSL and back, plus a random sweep
984
  node scripts/test-designer-browser.mjs # the designer panel, driven like a person would
 
461
 
462
  ```dssl
463
  tokens { shadow { pop: 0 4 0 #000 } radius { pill: 99 } }
464
+ .tomato { gradient: radial-gradient(circle, #fff6 18%, transparent 20%) tile 24 offset 4 4 + linear 180, #f55 -> #b11 } // the dots come FIRST, so they sit ON TOP of the red
465
  .title { text-stroke: 6 color.ink; font-size: 48 } // an outline OUTSIDE the letters
466
  .badge { shadow: pop; radius: pill } // your own shadow and radius tokens, by bare name
467
  .ring { background: conic-gradient(from 0deg, red, blue) } // from, at, in... all accepted
 
508
  - The "rule aligns without display" check moved from `dssl check` (it cannot see another rule giving the same element `display: flex`,
509
  so it cried wolf) to the build report and to `DSSL.lint`, which know.
510
 
511
+ ### The second game review (0.15.0)
512
+
513
+ The same builder came back with a second list after a real game. All of it is in.
514
+
515
+ **Aura**
516
+
517
+ - Under reduced motion the aura used to redraw twice a second, so a glow on something that moves lagged behind it. Now it takes a quick
518
+ look (a few rectangle reads, no drawing) every 100ms while nothing moves, and draws every frame for as long as anything it lights
519
+ moves, resizes or fades.
520
+ - An emitter that cannot be seen no longer emits: `opacity: 0` on it or on any ancestor, `display: none`, `visibility: hidden`. What it
521
+ already let go fades with it. A hidden menu with a smoking icon stops smoking. An invisible anchor you WANT smoke to come out of
522
+ (`opacity: 0` on purpose) says `always`: `aura-particle: #888 -> transparent 40 up always`.
523
+ - `z N` puts a layer on its own canvas at that z-index: `aura-glow: #f80 40 z 5` goes under a hill with `z-index: 10`, while the
524
+ default layer stays above everything (or wherever `DSSL.auraZ()` put it). The canvas sits in the page's root stacking context, so it
525
+ interleaves with the page's top-level positioned elements; something inside a transformed parent is a stacking context of its own.
526
+
527
+ **Rules that never win.** There is no specificity, so the LATER rule wins. That means `.prop.broc { z-index: 1 }` in one file loses to a
528
+ plain `.prop { z-index: 2 }` in a file loaded after it, and the first line does nothing. `dssl check` (with all the files, or the page),
529
+ the browser console and `DSSL.lint` now say so, with both places:
530
+
531
+ ```
532
+ a.dssl:1 - warning - z-index in ".prop.broc" can never apply: ".prop" (b.dssl:1) comes later, matches every element this rule matches...
533
+ ```
534
+
535
+ It reports a rule only when the later one matches everything the earlier one does, sets the same property to a different value, and is
536
+ unconditional in the same way (same `@media`, state, signal) and in the same or a higher layer. The very same selector twice is left
537
+ alone: that is an override, on purpose. Fix it by moving the specific rule below the general one.
538
+
539
+ **Signals**
540
+
541
+ - They keep their type: `DSSL.signal('toasting', false)` then `DSSL.getSignal('toasting')` is `false`, a number is a number. The
542
+ `data-dssl-signal-*` attribute is text. A bare `@signal name { }` is off for `false`, `0`, `"0"` and `"false"`.
543
+ - `DSSL.signal('screen', 'shop', el)` sets a signal for `el` and what is inside it. The nearest value wins, the page-wide one is the
544
+ fallback, `DSSL.signal('screen', null, el)` clears it, and `DSSL.getSignal('screen', el)` is the value an element sees. `::before`
545
+ and `::after` rules behind a signal follow it too.
546
+ - A signal restyles only the elements its rules select (inside the scope, if you gave one), not the whole page, and several in one
547
+ task are one pass. A signal that no rule mentions costs nothing.
548
+
549
+ **Styling is asynchronous, so there is a way to wait for it.** The engine styles new elements in a microtask. `await DSSL.settled()`
550
+ (or the synchronous `DSSL.flush()`) styles everything that is waiting, right now, so `offsetTop`, `getBoundingClientRect()` and computed
551
+ styles are final on the next line. `DSSL.computed(el, 'translate')` does a flush and then reads the computed value.
552
+
553
+ **Several motions on one element**
554
+
555
+ ```dssl
556
+ motion blink { 0%, 90%, 100% { opacity: 1 } 95% { opacity: .2 } }
557
+ motion waddle { from { rotate: -4 } to { rotate: 4 } }
558
+ motion glint { from { opacity: .3 } to { opacity: 1 } }
559
+ motion bob2 { from { translate: 0 0 } to { translate: 0 6 } }
560
+ .duck { animate: blink 300 infinite, waddle 900 infinite alternate }
561
+ .duck::after { content: ""; animate: glint 700 infinite, bob2 1200 infinite essential } // also on pseudo-elements, and `dssl build` writes them
562
+ ```
563
+
564
+ `await DSSL.play(el, 'pop', { pseudo: '::after' })` plays a motion on a pseudo-element.
565
+
566
+ **An outline that follows the shape**
567
+
568
+ ```dssl
569
+ .sprite { outline-shape: 3 color.ink } // follows the picture's alpha, not its box; outline-shape: none clears it
570
+ ```
571
+
572
+ It is four hard drop-shadows, one in each direction, which is what you wrote by hand. Corners come out square. It uses `filter`, so a
573
+ `filter:` on the same rule replaces it.
574
+
575
+ **Small syntax**
576
+
577
+ - `rotate: 5` is 5deg (`5deg`, `0.1turn`, `x 30` still work).
578
+ - A state inside a pseudo-element block is that pseudo-element in that state:
579
+ `.btn { ::after { content: ""; opacity: 0; hover { opacity: 1 } } }`. This is the same rule as `.btn:hover::after`.
580
+ - `font:` is size, line height and colour (`font: 16/1.5 color.ink`), not the CSS shorthand. Writing `font: bold 16px Arial` used to make
581
+ a 700px font; it is now an error that says so. Weight and family are `font-weight:` and `font-family:`.
582
+ - In `gradient:` the FIRST layer is the one on top. The example in the 0.14.0 chapter had the dots last, behind the colour; it is fixed.
583
+
584
+ **Checking**
585
+
586
+ - `DSSL.lint` no longer measures invisible text (a closed menu at `opacity: 0`) and counts a stroke of 1px or more, or a tight shadow
587
+ (blur up to 4px), that sets the letters apart from it as readable. A soft glow does not rescue unreadable text.
588
+ - `dssl check`, `build` and the rest take `--engine ./dssl` (or `DSSL_ENGINE=./dssl`) to use the engine in that folder, so the CLI file
589
+ can live anywhere.
590
+
591
+ **For tests of a game**: `DSSL.pauseMotion()` freezes every animation on the page (and the ones started afterwards),
592
+ `DSSL.stepMotion(250)` moves them all forward by exactly 250ms, `DSSL.resumeMotion()` lets them run.
593
+
594
  ### Fonts
595
 
596
  ```dssl
 
615
  .sheen { gradient: radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 }
616
  ```
617
 
618
+ `repeating` turns any kind into its repeating form. `in oklch` (or `oklab`, `hsl`, `lab`, `srgb`, with `longer hue` and friends) picks the colour space the blend happens in. A lone position between two stops is a midpoint. Two positions make a hard band. Bare numbers in a stop position are px (degrees on `conic`). `mesh` takes a base colour and blobs: `color at x y [size n]` (default size 60%). `+` joins layers, and the FIRST one you write is the one on top (so the dots or the shine go first, the base colour last). A direction is optional: `linear #f00 -> #00f` works.
619
+
620
+ Two look-alikes: `#f00 -> 30% -> #00f` puts the halfway point of the blend at 30% (a midpoint, no colour is placed there), while `#f00 30%, #00f` puts the colour red AT 30% (a stop; with two positions on one colour, `#f00 0 30%`, it holds red from 0 to 30%).
621
 
622
  ### Inspector and hot reload
623
 
 
876
  ```js
877
  import { DSSL } from '/dssl/index.js' // or just use window.DSSL after boot
878
 
879
+ DSSL.version // "0.15.0": a string, not a function
880
  DSSL.compile(source) // { ok, errors, warnings, stats, doc }: parse and resolve, no DOM
881
  DSSL.apply(source, { name, scope, replace })
882
  await DSSL.load(['a.dssl', 'b.dssl'])
883
  DSSL.toCSS(source | [sources], opts) // the exit lane: { css, map, report } (see `dssl build`)
884
+ DSSL.signal('theme', 'dark') // drives @signal rules (booleans and numbers keep their type)
885
+ DSSL.signal('hp', 2, el) // only for el and what is inside it; the nearest wins
886
+ DSSL.getSignal('theme') // the current value (DSSL.getSignal('hp', el) for a scope)
887
+ DSSL.flush() / await DSSL.settled() // style everything waiting NOW: offsetTop and computed styles are final after it
888
+ DSSL.computed(el, 'translate') // the final computed value, nothing left waiting
889
+ DSSL.pauseMotion() / DSSL.stepMotion(250) / DSSL.resumeMotion() // freeze, step and release every animation (for tests)
890
  DSSL.refresh() / DSSL.refresh(el) // recompute the page, or one subtree (even a static one)
891
  DSSL.replay(selector) // run entrance motions again
892
  DSSL.errors() // a function: the errors of the last compile
 
911
 
912
  1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
913
  https://open-vsx.org/extension/ehs-studio/dssl-vscode
914
+ 2. VS Code: download `dssl-vscode/dssl-vscode-0.15.0.vsix` from the repo files, then
915
  Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
916
  3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
917
 
 
1068
  node scripts/test-v0130.mjs # 0.13.0: build fidelity, source maps, strict CSP, server rendering
1069
  node scripts/bench-compiled.mjs # 0.13.0: compiled DSSL vs hand-written CSS, startup and churn (takes minutes)
1070
  node scripts/test-v0140.mjs # 0.14.0: the game-review fixes, and that the docs match the code
1071
+ node scripts/test-v0150.mjs # 0.15.0: the second game review
1072
  node scripts/test-v0100.mjs # 0.10.0: glass, tilt, named-colour alpha, playground examples, HTML filter
1073
  node scripts/test-designer.mjs # the designer's state to DSSL and back, plus a random sweep
1074
  node scripts/test-designer-browser.mjs # the designer panel, driven like a person would
PARITY.md CHANGED
@@ -62,6 +62,28 @@ so the boundary is explicit, not folklore.
62
  - loud errors with did-you-mean, and a warnings channel for cascade-order footguns
63
  - `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  ## v0.14.0: made for games
66
 
67
  From a review by an agent that built a game on DSSL. Twelve problems and ten wishes; each has an assertion in `scripts/test-v0140.mjs`.
 
62
  - loud errors with did-you-mean, and a warnings channel for cascade-order footguns
63
  - `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
64
 
65
+ ## v0.15.0: the second game review
66
+
67
+ | Review item | Status |
68
+ |---|---|
69
+ | Aura redraws 1.5 times a second under reduced motion | done: follows what moves, rests when nothing does |
70
+ | Aura emitters linger when hidden | done: invisible emitters (opacity 0, display none, visibility hidden, on it or an ancestor) stop |
71
+ | One canvas on `<body>`, never behind a hill | done as `z N` per layer; interleaves with the root stacking context only |
72
+ | Silent file-order losses | done: `dssl check`, console and `DSSL.lint` name rules a later rule always beats |
73
+ | Styling is asynchronous | done: `DSSL.flush()`, `await DSSL.settled()`, `DSSL.computed()` |
74
+ | Signals typed | done |
75
+ | `DSSL.lint` noisy | done: invisible text skipped, outline and tight shadow count |
76
+ | `DSSL.signal` costs 50 ms | done: only the elements its rules select are restyled |
77
+ | Several `animate:` per element | done |
78
+ | Scoped signals | done (`DSSL.signal(name, value, el)`) |
79
+ | `outline-shape` | done (square corners, uses `filter`) |
80
+ | `DSSL.play` on pseudo-elements | done |
81
+ | CLI `--engine` | done |
82
+ | Test helpers (final value, pause, step) | done |
83
+ | `rotate: 5`, nested state in `::after`, `font:` trap, gradient docs | done |
84
+
85
+ Not verified: Firefox and Safari. Scoped signals on `::before`/`::after` use a marker attribute the engine keeps on the element.
86
+
87
  ## v0.14.0: made for games
88
 
89
  From a review by an agent that built a game on DSSL. Twelve problems and ten wishes; each has an assertion in `scripts/test-v0140.mjs`.
README.md CHANGED
@@ -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). In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.14.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. See `dssl-vscode/PUBLISHING.md` for publishing.
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
 
@@ -454,8 +454,12 @@ DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
454
  DSSL.apply(source, { scope, name }) // scoped engines for widgets
455
  DSSL.compile(source) // parse + resolve, no DOM (powers the playground)
456
  DSSL.toCSS(source | [sources], opts) // the exit lane: plain CSS, source map, report
457
- DSSL.signal('theme', 'dark') // the JS → styles bridge
458
- DSSL.getSignal('theme') // read it back
 
 
 
 
459
  DSSL.replay(sel) // re-run animations
460
  DSSL.refresh() / DSSL.refresh(el) // full recompute, or one subtree
461
  DSSL.errors() // a function: structured errors from the last apply
@@ -466,7 +470,7 @@ await DSSL.lint(root) // contrast against the real background,
466
  DSSL.play(el, 'boing', opts) // a one-shot motion from JS, as a promise
467
  DSSL.motion('full') // run every motion whatever the system asks for
468
  DSSL.auraZ(20) // where the aura canvas sits in the stack
469
- DSSL.version // "0.14.0", a string
470
  ```
471
 
472
  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.
@@ -542,7 +546,7 @@ const rep = DSSL.explain(document.querySelector('.doc-card'))
542
 
543
  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.
544
 
545
- ## What works today (v0.14.0)
546
 
547
  - the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
548
  - 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
@@ -582,7 +586,7 @@ The matcher also pre-filters every rule through a cheap class/tag/id gate extrac
582
 
583
  ## Status
584
 
585
- v0.14.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.
586
 
587
  ## License
588
 
 
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). In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.15.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. See `dssl-vscode/PUBLISHING.md` for publishing.
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
 
 
454
  DSSL.apply(source, { scope, name }) // scoped engines for widgets
455
  DSSL.compile(source) // parse + resolve, no DOM (powers the playground)
456
  DSSL.toCSS(source | [sources], opts) // the exit lane: plain CSS, source map, report
457
+ DSSL.signal('theme', 'dark') // the JS → styles bridge (booleans and numbers stay as they are)
458
+ DSSL.signal('hp', 2, el) // only for el and what is inside it
459
+ DSSL.getSignal('theme') // read it back (DSSL.getSignal('hp', el) for a scope)
460
+ DSSL.flush() / await DSSL.settled() // style everything waiting, now, so offsetTop and friends are final
461
+ DSSL.computed(el, 'translate') // the final computed value, with nothing left waiting
462
+ DSSL.pauseMotion() / DSSL.stepMotion(250) / DSSL.resumeMotion() // freeze and step every animation (tests)
463
  DSSL.replay(sel) // re-run animations
464
  DSSL.refresh() / DSSL.refresh(el) // full recompute, or one subtree
465
  DSSL.errors() // a function: structured errors from the last apply
 
470
  DSSL.play(el, 'boing', opts) // a one-shot motion from JS, as a promise
471
  DSSL.motion('full') // run every motion whatever the system asks for
472
  DSSL.auraZ(20) // where the aura canvas sits in the stack
473
+ DSSL.version // "0.15.0", a string
474
  ```
475
 
476
  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.
 
546
 
547
  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.
548
 
549
+ ## What works today (v0.15.0)
550
 
551
  - the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
552
  - 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
 
586
 
587
  ## Status
588
 
589
+ v0.15.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.
590
 
591
  ## License
592
 
cli/dssl.mjs CHANGED
@@ -36,12 +36,24 @@ import { fileURLToPath } from 'node:url';
36
  // next to cli/ as dssl/. 0.8.0 only knew the first layout, so the packaged CLI
37
  // crashed with "module not found" for everyone who installed it.
38
  const here = pathResolve(dirname(fileURLToPath(import.meta.url)), '..');
39
- const engineDir = existsSync(join(here, 'public/dssl/parser.js')) ? join(here, 'public/dssl') : join(here, 'dssl');
 
 
 
 
 
 
 
 
 
 
 
40
  const { parse } = await import('file://' + join(engineDir, 'parser.js'));
41
  const { resolve } = await import('file://' + join(engineDir, 'resolver.js'));
42
  const { toCSS } = await import('file://' + join(engineDir, 'static.js'));
43
  const { VERSION } = await import('file://' + join(engineDir, 'index.js'));
44
  const { importCss } = await import('file://' + join(engineDir, 'import.js'));
 
45
 
46
  const USAGE = `dssl v${VERSION} - the DSSL style language
47
 
@@ -69,7 +81,9 @@ const USAGE = `dssl v${VERSION} - the DSSL style language
69
  dssl dev [dir] serve a folder with hot reload: edit a .dssl file and the page restyles with no reload
70
  --port <n> port (default 3000)
71
  --inspect also open the in-page inspector (hover an element to see its rules)
72
- dssl --version print the version`;
 
 
73
 
74
  const readArg = (args, flag) => {
75
  const i = args.indexOf(flag);
@@ -147,6 +161,16 @@ function compileAll(files) {
147
  tokenBase = { ...(tokenBase || {}), ...doc.tokens };
148
  motionBase = { ...motionBase, ...(doc.motionFrames || {}) };
149
  }
 
 
 
 
 
 
 
 
 
 
150
  return out;
151
  }
152
 
 
36
  // next to cli/ as dssl/. 0.8.0 only knew the first layout, so the packaged CLI
37
  // crashed with "module not found" for everyone who installed it.
38
  const here = pathResolve(dirname(fileURLToPath(import.meta.url)), '..');
39
+ // 0.15.0: `--engine ./dssl` (or DSSL_ENGINE=./dssl) points at the engine folder wherever it is, so the CLI file can live anywhere
40
+ let engineDir = existsSync(join(here, 'public/dssl/parser.js')) ? join(here, 'public/dssl') : join(here, 'dssl');
41
+ {
42
+ const i = process.argv.indexOf('--engine');
43
+ let want = process.env.DSSL_ENGINE || null;
44
+ if (i !== -1) { want = process.argv[i + 1] || null; process.argv.splice(i, want === null ? 1 : 2); if (want === null) { console.error('dssl: --engine needs a folder, e.g. --engine ./dssl'); process.exit(2); } }
45
+ if (want) {
46
+ const dir = pathResolve(want);
47
+ if (!existsSync(join(dir, 'parser.js'))) { console.error(`dssl: ${dir} has no parser.js, so it is not the engine folder (the folder that holds parser.js, resolver.js, index.js...)`); process.exit(2); }
48
+ engineDir = dir;
49
+ }
50
+ }
51
  const { parse } = await import('file://' + join(engineDir, 'parser.js'));
52
  const { resolve } = await import('file://' + join(engineDir, 'resolver.js'));
53
  const { toCSS } = await import('file://' + join(engineDir, 'static.js'));
54
  const { VERSION } = await import('file://' + join(engineDir, 'index.js'));
55
  const { importCss } = await import('file://' + join(engineDir, 'import.js'));
56
+ const { shadowWarnings } = await import('file://' + join(engineDir, 'lint.js'));
57
 
58
  const USAGE = `dssl v${VERSION} - the DSSL style language
59
 
 
81
  dssl dev [dir] serve a folder with hot reload: edit a .dssl file and the page restyles with no reload
82
  --port <n> port (default 3000)
83
  --inspect also open the in-page inspector (hover an element to see its rules)
84
+ dssl --version print the version
85
+
86
+ every command: --engine <dir> uses the engine in that folder (default: the one next to this file; or set DSSL_ENGINE)`;
87
 
88
  const readArg = (args, flag) => {
89
  const i = args.indexOf(flag);
 
161
  tokenBase = { ...(tokenBase || {}), ...doc.tokens };
162
  motionBase = { ...motionBase, ...(doc.motionFrames || {}) };
163
  }
164
+ // 0.15.0: a rule that can never win because a rule in a later file (or later in the same one) matches all it matches and sets the same thing.
165
+ // Per file this is invisible; across the page's files it is the commonest silent loss.
166
+ try {
167
+ const all = [];
168
+ out.forEach((x, i) => { for (const r of x.doc.rules || []) all.push({ ...r, file: r.file || x.name, order: i * 1e6 + (r.order || 0) }); });
169
+ for (const w of shadowWarnings(all)) {
170
+ const owner = out.find((x) => x.name === w.file) || out[0];
171
+ owner.warnings = [...owner.warnings, w];
172
+ }
173
+ } catch { /* a lint must never break a compile */ }
174
  return out;
175
  }
176
 
dssl-vscode/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
3
  ## 0.14.0
4
 
5
  - Bundled engine updated to 0.14.0 (made for games). Editor: `@signal name in (a, b)`, `not in (...)` and `!=` are understood and
 
1
  # Changelog
2
 
3
+ ## 0.15.0
4
+
5
+ - Bundled engine updated to 0.15.0 (the second game review). In the editor: `outline-shape`, `animate:` lists
6
+ (`animate: a 300, b 500`), `rotate: 5` (bare number = degrees), a state inside a pseudo-element block
7
+ (`::after { hover { } }`), the aura word `z N`, and a clear error for a CSS-style `font:` shorthand. Hovers document them.
8
+ - The editor still checks one file at a time. `dssl check index.html` also finds rules that a later rule always beats.
9
+
10
  ## 0.14.0
11
 
12
  - Bundled engine updated to 0.14.0 (made for games). Editor: `@signal name in (a, b)`, `not in (...)` and `!=` are understood and
dssl-vscode/lib/engine/aura.js CHANGED
@@ -49,6 +49,8 @@ export const GLOW_KEYS = {
49
  texture: { kind: 'texture', words: 1, doc: 'texture(grain scale 2): break the light up with a texture' },
50
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
51
  unclipped: { kind: 'flag', words: 0, doc: 'let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself' },
 
 
52
  };
53
 
54
  export const PARTICLE_KEYS = {
@@ -72,6 +74,8 @@ export const PARTICLE_KEYS = {
72
  texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
73
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
74
  unclipped: { kind: 'flag', words: 0, doc: 'let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge' },
 
 
75
  };
76
 
77
  /* ------------------------------------------------------------------ *
@@ -382,6 +386,7 @@ export class AuraManager {
382
  // reduced motion draws a still picture, so it only needs a new frame when something could have moved
383
  this._onScroll = () => { if (this.reduced && !this.running && !this.paused && this.emitters.size) this.start(); };
384
  this._poll = null;
 
385
  this._frame = (t) => this.frame(t);
386
  }
387
 
@@ -418,11 +423,62 @@ export class AuraManager {
418
  if (!this.canvas) return;
419
  this.dpr = Math.min(2, window.devicePixelRatio || 1);
420
  this.vw = window.innerWidth; this.vh = window.innerHeight;
421
- this.canvas.width = Math.max(1, Math.round(this.vw * this.dpr));
422
- this.canvas.height = Math.max(1, Math.round(this.vh * this.dpr));
 
 
423
  this.dirty = true;
424
  }
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  /* ---- the engine's interface ---- */
427
 
428
  /** Set (or change) the aura of an element. glow/particles are arrays of resolved layers. */
@@ -458,7 +514,7 @@ export class AuraManager {
458
  this.start();
459
  }
460
 
461
- pause() { this.paused = true; this.running = false; if (this._poll) { clearTimeout(this._poll); this._poll = null; } if (this.ctx) this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); }
462
  resume() { this.paused = false; this.start(); }
463
 
464
  destroy() {
@@ -467,6 +523,8 @@ export class AuraManager {
467
  window.removeEventListener('scroll', this._onScroll, { capture: true });
468
  if (this._poll) { clearTimeout(this._poll); this._poll = null; }
469
  if (this.io) this.io.disconnect();
 
 
470
  if (this.canvas) this.canvas.remove();
471
  this.canvas = null; this.ctx = null; this.emitters.clear();
472
  }
@@ -486,11 +544,15 @@ export class AuraManager {
486
  const dt = this.last ? Math.min(0.05, (now - this.last) / 1000) : 0.016;
487
  this.last = now;
488
  const reduced = this.reduced;
489
- const ctx = this.ctx, dpr = this.dpr;
490
 
491
- ctx.setTransform(1, 0, 0, 1, 0, 0);
492
- ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
493
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
 
 
 
 
494
 
495
  // global budget: if everyone asks for more than the page can afford, everyone gets a share
496
  let want = 0;
@@ -498,6 +560,7 @@ export class AuraManager {
498
  const share = want > this.budget ? this.budget / want : 1;
499
 
500
  let alive = 0, anyActive = false;
 
501
  for (const [el, em] of this.emitters) {
502
  if (!el.isConnected) { this.emitters.delete(el); if (this.io) this.io.unobserve(el); continue; }
503
  // read geometry once per frame for visible emitters
@@ -507,6 +570,12 @@ export class AuraManager {
507
  if (rect.width === 0 && rect.height === 0) rect = null;
508
  }
509
  em.rect = rect;
 
 
 
 
 
 
510
  // 0.14.0: a stage scaled with transform: scale() has a rect the size you SEE, so the light follows it, and so must its size
511
  // and speed. The scale is how much bigger (or smaller) the box is than the box CSS says it is.
512
  em.scale = 1;
@@ -518,11 +587,13 @@ export class AuraManager {
518
 
519
  // 0.14.0: cut the light at the edge of any parent that clips, so a stage with overflow: hidden keeps its fire inside
520
  const clipped = rect && em.clips && em.clips.length ? this.clipRect(em.clips) : null;
521
- if (clipped) { ctx.save(); ctx.beginPath(); ctx.rect(clipped.x, clipped.y, clipped.w, clipped.h); ctx.clip(); }
 
 
522
 
523
  // glow
524
- if (rect && em.fade > 0 && (em.visible || em.dying)) {
525
- for (const g of em.glow) { this.drawGlow(ctx, rect, em, g, now, reduced); }
526
  if (em.glow.length) anyActive = true;
527
  }
528
 
@@ -531,12 +602,12 @@ export class AuraManager {
531
  const cfg = L.cfg;
532
  const moving = !reduced || cfg.essential;
533
  L.scale = em.scale;
534
- if (rect && em.visible && !em.dying && moving) this.emit(L, rect, em, dt, share, el);
535
- if (L.parts.length) this.stepAndDraw(ctx, L, dt, moving, cfg.behind && rect ? { rect, radius: em.radius } : null);
 
536
  alive += L.parts.length;
537
- if (L.parts.length || (rect && em.visible && !em.dying && moving)) anyActive = true;
538
  }
539
- if (clipped) ctx.restore();
540
  if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
541
  this.emitters.delete(el);
542
  if (this.io) this.io.unobserve(el);
@@ -550,12 +621,14 @@ export class AuraManager {
550
  this._fpsN++;
551
  if (now - this._fpsT > 500) { this.stats.fps = Math.round((this._fpsN * 1000) / Math.max(1, now - this._fpsT)); this._fpsT = now; this._fpsN = 0; }
552
 
553
- if (this.emitters.size === 0) { this.running = false; ctx.clearRect(0, 0, this.vw, this.vh); return; }
554
  // Normally the loop runs every frame (the glow follows the element, particles move). With reduced
555
  // motion nothing moves, so one frame is a complete picture; it is redrawn on scroll, resize, a
556
- // style change, and a slow poll for layout shifts. Essential layers and fading-out emitters still loop.
 
557
  let needLoop = !reduced;
558
  if (reduced) {
 
559
  for (const em of this.emitters.values()) {
560
  if (em.dying) needLoop = true;
561
  if (em.glow.some((g) => g.cfg.essential) || em.layers.some((l) => l.cfg.essential && (l.parts.length || (em.visible && !em.dying)))) needLoop = true;
@@ -563,7 +636,17 @@ export class AuraManager {
563
  }
564
  if (needLoop) { requestAnimationFrame(this._frame); return; }
565
  this.running = false;
566
- if (!this._poll) this._poll = setTimeout(() => { this._poll = null; this.start(); }, 600);
 
 
 
 
 
 
 
 
 
 
567
  }
568
 
569
  /** the ancestors that clip their children (overflow other than visible), unless this emitter said `unclipped` */
@@ -604,7 +687,7 @@ export class AuraManager {
604
  drawGlow(ctx, rect, em, g, now, reduced) {
605
  const cfg = g.cfg;
606
  const moving = !reduced || cfg.essential;
607
- let a = cfg.intensity * em.fade;
608
  if (moving && cfg.pulse > 0) {
609
  const ph = ((now - g.t0) / cfg.pulse) * TAU;
610
  a *= 1 - cfg.depth * 0.5 * (1 + Math.sin(ph)) ;
@@ -819,7 +902,7 @@ export class AuraManager {
819
  }
820
  const t = p.age / p.life;
821
  const col = colorAt(c.stops, t);
822
- const alpha = clamp01(col.a);
823
  if (alpha <= 0.003) continue;
824
  const size = p.size * lerp(1, c.grow, t);
825
  this.drawParticle(ctx, c, p, col, alpha, size);
 
49
  texture: { kind: 'texture', words: 1, doc: 'texture(grain scale 2): break the light up with a texture' },
50
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
51
  unclipped: { kind: 'flag', words: 0, doc: 'let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself' },
52
+ always: { kind: 'flag', words: 0, doc: 'keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of' },
53
+ z: num(-1000000, 2147483000, { int: true, doc: 'the z-index of the layer this light is drawn on: `z 5` puts it under a hill with z-index 10. It meets elements in the page\'s root stacking context' }),
54
  };
55
 
56
  export const PARTICLE_KEYS = {
 
74
  texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
75
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
76
  unclipped: { kind: 'flag', words: 0, doc: 'let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge' },
77
+ always: { kind: 'flag', words: 0, doc: 'keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of' },
78
+ z: num(-1000000, 2147483000, { int: true, doc: 'the z-index of the layer these particles are drawn on: `z 5` puts them under a hill with z-index 10. It meets elements in the page\'s root stacking context' }),
79
  };
80
 
81
  /* ------------------------------------------------------------------ *
 
386
  // reduced motion draws a still picture, so it only needs a new frame when something could have moved
387
  this._onScroll = () => { if (this.reduced && !this.running && !this.paused && this.emitters.size) this.start(); };
388
  this._poll = null;
389
+ this.extra = new Map(); // z-index -> { canvas, ctx }: layers asked for with `z N`
390
  this._frame = (t) => this.frame(t);
391
  }
392
 
 
423
  if (!this.canvas) return;
424
  this.dpr = Math.min(2, window.devicePixelRatio || 1);
425
  this.vw = window.innerWidth; this.vh = window.innerHeight;
426
+ for (const c of this.allCanvases()) {
427
+ c.width = Math.max(1, Math.round(this.vw * this.dpr));
428
+ c.height = Math.max(1, Math.round(this.vh * this.dpr));
429
+ }
430
  this.dirty = true;
431
  }
432
 
433
+ allCanvases() { return [this.canvas, ...[...this.extra.values()].map((e) => e.canvas)]; }
434
+
435
+ /** the drawing context for a layer: the default canvas, or the one at the z-index the layer asked for (`z 5`) */
436
+ ctxFor(z) {
437
+ if (z === undefined || z === null) return this.ctx;
438
+ let e = this.extra.get(z);
439
+ if (!e) {
440
+ const c = document.createElement('canvas');
441
+ c.setAttribute('aria-hidden', 'true');
442
+ c.setAttribute('data-dssl-aura', String(z));
443
+ c.style.cssText = `position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:${z};`;
444
+ c.width = this.canvas.width; c.height = this.canvas.height;
445
+ (document.body || document.documentElement).appendChild(c);
446
+ e = { canvas: c, ctx: c.getContext('2d') };
447
+ this.extra.set(z, e);
448
+ }
449
+ return e.ctx;
450
+ }
451
+
452
+ /** how visible an element really is: its opacity times every ancestor's; 0 for display: none or visibility: hidden */
453
+ effectiveOpacity(el) {
454
+ try {
455
+ let o = 1;
456
+ if (getComputedStyle(el).visibility !== 'visible') return 0;
457
+ for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
458
+ const cs = getComputedStyle(a);
459
+ if (cs.display === 'none') return 0;
460
+ o *= parseFloat(cs.opacity);
461
+ if (!(o > 0.004)) return 0;
462
+ }
463
+ return o;
464
+ } catch { return 1; }
465
+ }
466
+
467
+ /** what a frame would draw for this emitter, as a string: the same string twice means nothing needs redrawing */
468
+ stateKey(el, eo) {
469
+ const r = el.getBoundingClientRect();
470
+ return `${r.left.toFixed(1)},${r.top.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)},${eo.toFixed(2)}`;
471
+ }
472
+
473
+ /** reduced motion: has anything moved, resized or faded since the last frame? (cheap: no drawing) */
474
+ peek() {
475
+ for (const [el, em] of this.emitters) {
476
+ if (!el.isConnected) return true;
477
+ if (this.stateKey(el, this.effectiveOpacity(el)) !== em.key) return true;
478
+ }
479
+ return false;
480
+ }
481
+
482
  /* ---- the engine's interface ---- */
483
 
484
  /** Set (or change) the aura of an element. glow/particles are arrays of resolved layers. */
 
514
  this.start();
515
  }
516
 
517
+ pause() { this.paused = true; this.running = false; if (this._poll) { clearTimeout(this._poll); this._poll = null; } if (this.ctx) for (const c of this.allCanvases()) c.getContext('2d').clearRect(0, 0, c.width, c.height); }
518
  resume() { this.paused = false; this.start(); }
519
 
520
  destroy() {
 
523
  window.removeEventListener('scroll', this._onScroll, { capture: true });
524
  if (this._poll) { clearTimeout(this._poll); this._poll = null; }
525
  if (this.io) this.io.disconnect();
526
+ for (const e of this.extra.values()) e.canvas.remove();
527
+ this.extra.clear();
528
  if (this.canvas) this.canvas.remove();
529
  this.canvas = null; this.ctx = null; this.emitters.clear();
530
  }
 
544
  const dt = this.last ? Math.min(0.05, (now - this.last) / 1000) : 0.016;
545
  this.last = now;
546
  const reduced = this.reduced;
547
+ const dpr = this.dpr;
548
 
549
+ for (const c of this.allCanvases()) {
550
+ const x = c.getContext('2d');
551
+ x.setTransform(1, 0, 0, 1, 0, 0);
552
+ x.clearRect(0, 0, c.width, c.height);
553
+ x.setTransform(dpr, 0, 0, dpr, 0, 0);
554
+ }
555
+ let changed = false;
556
 
557
  // global budget: if everyone asks for more than the page can afford, everyone gets a share
558
  let want = 0;
 
560
  const share = want > this.budget ? this.budget / want : 1;
561
 
562
  let alive = 0, anyActive = false;
563
+ this._n = (this._n || 0) + 1;
564
  for (const [el, em] of this.emitters) {
565
  if (!el.isConnected) { this.emitters.delete(el); if (this.io) this.io.unobserve(el); continue; }
566
  // read geometry once per frame for visible emitters
 
570
  if (rect.width === 0 && rect.height === 0) rect = null;
571
  }
572
  em.rect = rect;
573
+ // 0.15.0: an emitter that cannot be seen (opacity 0 on it or any ancestor, display: none, visibility: hidden) does not emit, and
574
+ // what it already let go fades with it. Looked up every third frame, or every frame in reduced motion where frames are rare.
575
+ if (em.eo === undefined || reduced || this._n % 3 === 0) em.eo = this.effectiveOpacity(el);
576
+ if (em.layers.some((l) => l.cfg.always) || em.glow.some((g) => g.cfg.always)) em.eo = 1; // an invisible anchor on purpose
577
+ const key = this.stateKey(el, em.eo);
578
+ if (key !== em.key) { em.key = key; changed = true; }
579
  // 0.14.0: a stage scaled with transform: scale() has a rect the size you SEE, so the light follows it, and so must its size
580
  // and speed. The scale is how much bigger (or smaller) the box is than the box CSS says it is.
581
  em.scale = 1;
 
587
 
588
  // 0.14.0: cut the light at the edge of any parent that clips, so a stage with overflow: hidden keeps its fire inside
589
  const clipped = rect && em.clips && em.clips.length ? this.clipRect(em.clips) : null;
590
+ const open = (ctx) => { if (clipped) { ctx.save(); ctx.beginPath(); ctx.rect(clipped.x, clipped.y, clipped.w, clipped.h); ctx.clip(); } };
591
+ const close = (ctx) => { if (clipped) ctx.restore(); };
592
+ const seen = em.eo > 0.004;
593
 
594
  // glow
595
+ if (rect && em.fade > 0 && (em.visible || em.dying) && seen) {
596
+ for (const g of em.glow) { const ctx = this.ctxFor(g.cfg.z); open(ctx); this.drawGlow(ctx, rect, em, g, now, reduced); close(ctx); }
597
  if (em.glow.length) anyActive = true;
598
  }
599
 
 
602
  const cfg = L.cfg;
603
  const moving = !reduced || cfg.essential;
604
  L.scale = em.scale;
605
+ L.eo = em.eo;
606
+ if (rect && em.visible && !em.dying && moving && seen) this.emit(L, rect, em, dt, share, el);
607
+ if (L.parts.length) { const ctx = this.ctxFor(cfg.z); open(ctx); this.stepAndDraw(ctx, L, dt, moving, cfg.behind && rect ? { rect, radius: em.radius } : null); close(ctx); }
608
  alive += L.parts.length;
609
+ if (L.parts.length || (rect && em.visible && !em.dying && moving && seen)) anyActive = true;
610
  }
 
611
  if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
612
  this.emitters.delete(el);
613
  if (this.io) this.io.unobserve(el);
 
621
  this._fpsN++;
622
  if (now - this._fpsT > 500) { this.stats.fps = Math.round((this._fpsN * 1000) / Math.max(1, now - this._fpsT)); this._fpsT = now; this._fpsN = 0; }
623
 
624
+ if (this.emitters.size === 0) { this.running = false; for (const c of this.allCanvases()) c.getContext('2d').clearRect(0, 0, c.width, c.height); return; }
625
  // Normally the loop runs every frame (the glow follows the element, particles move). With reduced
626
  // motion nothing moves, so one frame is a complete picture; it is redrawn on scroll, resize, a
627
+ // style change, and a quick look (peek) for anything that moved. A frame that saw something move, resize or fade asks for the next one,
628
+ // so a glow on a thing that moves under reduced motion follows it every frame instead of lagging. Essential layers and fading-out emitters still loop.
629
  let needLoop = !reduced;
630
  if (reduced) {
631
+ if (changed) needLoop = true;
632
  for (const em of this.emitters.values()) {
633
  if (em.dying) needLoop = true;
634
  if (em.glow.some((g) => g.cfg.essential) || em.layers.some((l) => l.cfg.essential && (l.parts.length || (em.visible && !em.dying)))) needLoop = true;
 
636
  }
637
  if (needLoop) { requestAnimationFrame(this._frame); return; }
638
  this.running = false;
639
+ this.watchStill();
640
+ }
641
+
642
+ /** reduced motion, nothing moving: look every 100ms (a few rect reads, no drawing) and draw again the moment something changed */
643
+ watchStill() {
644
+ if (this._poll || this.paused) return;
645
+ this._poll = setTimeout(() => {
646
+ this._poll = null;
647
+ if (this.running || !this.canvas) return;
648
+ if (this.peek()) this.start(); else this.watchStill();
649
+ }, 100);
650
  }
651
 
652
  /** the ancestors that clip their children (overflow other than visible), unless this emitter said `unclipped` */
 
687
  drawGlow(ctx, rect, em, g, now, reduced) {
688
  const cfg = g.cfg;
689
  const moving = !reduced || cfg.essential;
690
+ let a = cfg.intensity * em.fade * (em.eo === undefined ? 1 : em.eo);
691
  if (moving && cfg.pulse > 0) {
692
  const ph = ((now - g.t0) / cfg.pulse) * TAU;
693
  a *= 1 - cfg.depth * 0.5 * (1 + Math.sin(ph)) ;
 
902
  }
903
  const t = p.age / p.life;
904
  const col = colorAt(c.stops, t);
905
+ const alpha = clamp01(col.a) * (L.eo === undefined ? 1 : L.eo);
906
  if (alpha <= 0.003) continue;
907
  const size = p.size * lerp(1, c.grow, t);
908
  this.drawParticle(ctx, c, p, col, alpha, size);
dssl-vscode/lib/engine/lint.js CHANGED
@@ -163,3 +163,110 @@ export function lintRules({ rules, tokenMap, file }) {
163
  }
164
  return warnings;
165
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  }
164
  return warnings;
165
  }
166
+
167
+ /* ------------------------------------------------------------------ *
168
+ * rules that can never win (0.15.0)
169
+ * ------------------------------------------------------------------ */
170
+
171
+ const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
172
+
173
+ /** split a selector at top-level spaces and > + ~ (outside brackets and parentheses); null when it holds a top-level comma */
174
+ function selectorParts(sel) {
175
+ const parts = []; let depth = 0, cur = '', q = null;
176
+ for (const ch of String(sel).trim()) {
177
+ if (q) { cur += ch; if (ch === q) q = null; continue; }
178
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
179
+ if (ch === '(' || ch === '[') depth++;
180
+ if (ch === ')' || ch === ']') depth--;
181
+ if (depth === 0 && ch === ',') return null;
182
+ if (depth === 0 && (ch === ' ' || ch === '>' || ch === '+' || ch === '~')) { if (cur) { parts.push(cur); cur = ''; } parts.push(ch === ' ' ? ' ' : ch); continue; }
183
+ cur += ch;
184
+ }
185
+ if (cur) parts.push(cur);
186
+ return parts;
187
+ }
188
+
189
+ /** the simple selectors of one compound: `div.a#b[x]:not(.c)` is div, .a, #b, [x], :not(.c) */
190
+ function simpleSelectors(compound) {
191
+ const out = []; let i = 0; const n = compound.length;
192
+ const take = (start) => {
193
+ let j = start + 1, depth = 0;
194
+ while (j < n) {
195
+ const c = compound[j];
196
+ if (c === '(' || c === '[') depth++;
197
+ else if (c === ')' || c === ']') depth--;
198
+ else if (depth === 0 && /[.#:\[]/.test(c)) break;
199
+ j++;
200
+ }
201
+ return j;
202
+ };
203
+ if (/^[a-zA-Z*]/.test(compound)) { let j = 0; while (j < n && /[\w*-]/.test(compound[j])) j++; out.push(compound.slice(0, j).toLowerCase()); i = j; }
204
+ while (i < n) {
205
+ const j = compound[i] === ':' && compound[i + 1] === ':' ? take(i + 1) : take(i);
206
+ out.push(compound.slice(i, j)); i = j;
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * A rule whose every declaration of a property is beaten by a LATER rule that matches everything it matches. There is no specificity
213
+ * in DSSL, so `.prop.broc { z-index: 1 }` in one file loses to a plain `.prop { z-index: 2 }` in a file that comes after it, and the
214
+ * line does nothing at all. (The very same selector again is left alone: that is how a later file overrides an earlier one.) Both rules must be unconditional alike (same @media, @signal, container, state) and the later one must be
215
+ * in the same layer or a higher one. Pure; rules from several files must already carry one global `order`.
216
+ * @returns {Array<{a:{sel,file,line,props:string[]}, b:{sel,file,line}}>}
217
+ */
218
+ export function shadowedRules(rules) {
219
+ const info = [];
220
+ for (const r of rules) {
221
+ if (!r || r.pseudo || !r.sel || !r.decls || !r.decls.length) continue;
222
+ const parts = selectorParts(r.sel);
223
+ if (!parts || !parts.length) continue;
224
+ const comp = parts[parts.length - 1];
225
+ if (/^[ >+~]$/.test(comp)) continue;
226
+ if (/^:root$|::/.test(r.sel)) continue;
227
+ const toks = simpleSelectors(comp);
228
+ if (!toks.length) continue;
229
+ const cond = JSON.stringify([r.media || [], (r.signals || []).map((s) => [s.name, s.op, s.value, s.values]), r.containers || [], [...(r.states || [])].sort()]);
230
+ info.push({ idx: info.length, r, toks, set: new Set(toks), single: parts.length === 1, cond, rank: LAYER_RANK[r.layer] ?? 2, order: r.order ?? 0 });
231
+ }
232
+ // the later rules, found by the first simple selector they need
233
+ const byTok = new Map();
234
+ for (const b of info) if (b.single) { const k = b.toks[0]; if (!byTok.has(k)) byTok.set(k, []); byTok.get(k).push(b); }
235
+ const pairs = new Map();
236
+ for (const a of info) {
237
+ const seenProps = new Set();
238
+ const cands = [];
239
+ for (const t of a.toks) for (const b of byTok.get(t) || []) cands.push(b);
240
+ if (byTok.has('*')) for (const b of byTok.get('*')) cands.push(b);
241
+ cands.sort((x, y) => x.order - y.order);
242
+ for (const b of cands) {
243
+ if (b === a || b.cond !== a.cond) continue;
244
+ if (b.r.sel.replace(/\s+/g, ' ').trim() === a.r.sel.replace(/\s+/g, ' ').trim()) continue; // the same selector again is an override on purpose (a theme file, a later tweak)
245
+ if (b.rank < a.rank || (b.rank === a.rank && b.order <= a.order)) continue;
246
+ if (b.toks.some((t) => t !== '*' && !a.set.has(t))) continue;
247
+ const bProps = new Map();
248
+ for (const d of b.r.decls) if (d.prop && !String(d.prop).startsWith('@')) bProps.set(d.prop, d.value);
249
+ const hit = [];
250
+ for (const d of a.r.decls) {
251
+ const p = d.prop;
252
+ if (!p || String(p).startsWith('@') || seenProps.has(p) || !bProps.has(p)) continue;
253
+ if (String(bProps.get(p)) === String(d.value)) continue;
254
+ seenProps.add(p); hit.push(p);
255
+ }
256
+ if (hit.length) {
257
+ const key = a.idx + '>' + b.idx;
258
+ pairs.set(key, { a: { sel: a.r.sel, file: a.r.file, line: (a.r.decls.find((d) => d.prop === hit[0]) || a.r).line ?? a.r.line, props: hit }, b: { sel: b.r.sel, file: b.r.file, line: b.r.line } });
259
+ }
260
+ }
261
+ }
262
+ return [...pairs.values()];
263
+ }
264
+
265
+ /** the same, as compiler warnings */
266
+ export function shadowWarnings(rules) {
267
+ return shadowedRules(rules).map((p) => ({
268
+ file: p.a.file, line: p.a.line,
269
+ message: `${p.a.props.join(', ')} in "${p.a.sel}" can never apply: "${p.b.sel}" (${p.b.file || ''}${p.b.file ? ':' : ''}${p.b.line}) comes later, matches every element this rule matches, and sets ${p.a.props.length > 1 ? 'the same properties' : 'the same property'}`,
270
+ hint: 'there is no specificity in DSSL, so the later rule always wins. Move the more specific rule below the general one, or remove the line from the general one',
271
+ }));
272
+ }
dssl-vscode/lib/engine/resolver.js CHANGED
@@ -710,6 +710,38 @@ const DEFORM_TO_TRANSFORM = {
710
  fold: 'transform', stretch: 'transform',
711
  };
712
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  const SEMANTIC = {
714
  size(v, ctx) {
715
  const parts = v.split(/\s+/).filter(Boolean);
@@ -795,6 +827,14 @@ const SEMANTIC = {
795
  const out = [];
796
  const slash = v.split('/');
797
  const head = slash[0].trim().split(/\s+/).filter(Boolean);
 
 
 
 
 
 
 
 
798
  out.push({ prop: 'font-size', value: len(head[0] || '16', ctx) });
799
  if (slash[1]) {
800
  const tail = slash[1].trim().split(/\s+/).filter(Boolean);
@@ -928,34 +968,14 @@ const SEMANTIC = {
928
  animate(v, ctx) {
929
  const t = v.trim();
930
  if (t === 'none') return [{ prop: '@animate', value: null }];
931
- const atoms = t.split(/\s+/);
932
- const spec = { motion: atoms[0], duration: 400, easing: EASINGS.smooth, fill: 'both', delay: 0 };
933
- const toMs = (a) => (a.endsWith('s') && !a.endsWith('ms') ? parseFloat(a) * 1000 : parseFloat(a));
934
- for (let k = 1; k < atoms.length; k++) {
935
- const a = atoms[k];
936
- // 0.14.0: the words that used to be CSS longhands which did nothing here
937
- if (a === 'delay' || a === 'jitter' || a === 'iterations') {
938
- const v = atoms[k + 1];
939
- if (v === undefined || !/^\d+(\.\d+)?(ms|s)?$/.test(v)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `${a} needs a number`, hint: a === 'iterations' ? 'animate: boing 300 iterations 3' : `animate: bob 1200 infinite ${a} 600` }); continue; }
940
- if (a === 'iterations') spec.iterations = parseFloat(v); else spec[a] = toMs(v);
941
- k++;
942
- continue;
943
- }
944
- if (a === 'essential') { spec.essentialHere = true; continue; }
945
- if (/^\d+(ms|s)?$/.test(a)) {
946
- const ms = toMs(a);
947
- if (k === 1) spec.duration = ms; else spec.delay = ms;
948
- continue;
949
- }
950
- if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
951
- if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
952
- // v0.5.0 - looping: `infinite` runs forever, `alternate` ping-pongs.
953
- // animate: pulse 1600 infinite alternate - was impossible before.
954
- if (a === 'infinite') { spec.iterations = Infinity; continue; }
955
- if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
956
- ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate', 'delay', 'jitter', 'iterations', 'essential'])) });
957
  }
958
- return [{ prop: '@animate', value: spec }];
959
  },
960
  // v0.4.0 - choreography: a parent with `stagger: 60` offsets each element
961
  // child's animation delay by its DOM position. Pairs with `seen` + `animate`
@@ -1071,6 +1091,32 @@ const SEMANTIC = {
1071
  if (!inside) out.push({ prop: 'paint-order', value: 'stroke fill' });
1072
  return out;
1073
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
  'gradient-text'(v, ctx) {
1075
  const img = buildGradient(v, ctx);
1076
  if (!img) return [];
@@ -1622,23 +1668,37 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null, baseMotion
1622
  media: ctx.media, signals: ctx.signals, containers: ctx.containers,
1623
  layer: ctx.layer, order: order++, decls: [], file, line: node.line,
1624
  };
1625
- for (const d of node.children) {
1626
- if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1627
- if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1628
- const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1629
- // v0.5.0 - engine-native properties have no pseudo-element story:
1630
- // there is no DOM handle to nudge, animate, stack or stagger. They
1631
- // used to leak into the shadow sheet as junk CSS the browser
1632
- // silently dropped - exactly the silence this language refuses.
1633
- for (const r of resolved) {
1634
- if (r.prop.startsWith('@') && r.prop !== '@animate') { // 0.14.0: animate: becomes a CSS animation on a pseudo-element
1635
- errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo block' });
1636
  continue;
1637
  }
1638
- srule.decls.push({ ...r, line: d.line });
 
 
 
 
 
 
 
 
 
 
 
 
 
1639
  }
1640
- }
 
 
1641
  if (srule.decls.length) shadowRules.push(srule);
 
1642
  break;
1643
  }
1644
  case 'tokens': {
 
710
  fold: 'transform', stretch: 'transform',
711
  };
712
 
713
+ /** one motion of an animate: line: `bob 1200 infinite alternate delay 100 jitter 600 essential` */
714
+ function animateOne(t, ctx) {
715
+ const atoms = t.split(/\s+/);
716
+ const spec = { motion: atoms[0], duration: 400, easing: EASINGS.smooth, fill: 'both', delay: 0 };
717
+ const toMs = (a) => (a.endsWith('s') && !a.endsWith('ms') ? parseFloat(a) * 1000 : parseFloat(a));
718
+ for (let k = 1; k < atoms.length; k++) {
719
+ const a = atoms[k];
720
+ // 0.14.0: the words that used to be CSS longhands which did nothing here
721
+ if (a === 'delay' || a === 'jitter' || a === 'iterations') {
722
+ const v = atoms[k + 1];
723
+ if (v === undefined || !/^\d+(\.\d+)?(ms|s)?$/.test(v)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `${a} needs a number`, hint: a === 'iterations' ? 'animate: boing 300 iterations 3' : `animate: bob 1200 infinite ${a} 600` }); continue; }
724
+ if (a === 'iterations') spec.iterations = parseFloat(v); else spec[a] = toMs(v);
725
+ k++;
726
+ continue;
727
+ }
728
+ if (a === 'essential') { spec.essentialHere = true; continue; }
729
+ if (/^\d+(ms|s)?$/.test(a)) {
730
+ const ms = toMs(a);
731
+ if (k === 1) spec.duration = ms; else spec.delay = ms;
732
+ continue;
733
+ }
734
+ if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
735
+ if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
736
+ // v0.5.0 - looping: `infinite` runs forever, `alternate` ping-pongs.
737
+ // animate: pulse 1600 infinite alternate - was impossible before.
738
+ if (a === 'infinite') { spec.iterations = Infinity; continue; }
739
+ if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
740
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate', 'delay', 'jitter', 'iterations', 'essential'])) });
741
+ }
742
+ return [{ prop: '@animate', value: spec }];
743
+ }
744
+
745
  const SEMANTIC = {
746
  size(v, ctx) {
747
  const parts = v.split(/\s+/).filter(Boolean);
 
827
  const out = [];
828
  const slash = v.split('/');
829
  const head = slash[0].trim().split(/\s+/).filter(Boolean);
830
+ // 0.15.0: DSSL's font: is size/line-height and a colour, NOT the CSS shorthand (weight, style, family). A CSS-looking value used to
831
+ // become a 700px font; say what it is instead.
832
+ const sized = (x) => /^\d*\.?\d+(px|em|rem|%|pt|vw|vh|ch)$/.test(x);
833
+ if (/["',]/.test(v) || /^(italic|oblique|bold|bolder|lighter|normal|small-caps|condensed|expanded|caption|icon|menu|message-box|small-caption|status-bar|system-ui|serif|sans-serif|monospace)$/.test(head[0] || '')
834
+ || (/^[1-9]00$/.test(head[0] || '') && head[1] !== undefined && sized(head[1]))) {
835
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `font: "${v.trim()}" looks like the CSS font shorthand, but DSSL's font: means size, line height and colour`, hint: 'font: 16/1.5 color.ink sets size, line-height and colour. Weight and family are their own lines: font-weight: 700 and font-family: "Fraunces", serif' });
836
+ return [];
837
+ }
838
  out.push({ prop: 'font-size', value: len(head[0] || '16', ctx) });
839
  if (slash[1]) {
840
  const tail = slash[1].trim().split(/\s+/).filter(Boolean);
 
968
  animate(v, ctx) {
969
  const t = v.trim();
970
  if (t === 'none') return [{ prop: '@animate', value: null }];
971
+ // 0.15.0: several motions on one element: animate: blink 300 infinite, waddle 900 infinite alternate
972
+ const parts = topSplit(t, ',').map((x) => x.trim()).filter(Boolean);
973
+ if (parts.length > 1) {
974
+ const out = [];
975
+ for (const part of parts) for (const d of animateOne(part, ctx)) out.push(d);
976
+ return out;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
  }
978
+ return animateOne(t, ctx);
979
  },
980
  // v0.4.0 - choreography: a parent with `stagger: 60` offsets each element
981
  // child's animation delay by its DOM position. Pairs with `seen` + `animate`
 
1091
  if (!inside) out.push({ prop: 'paint-order', value: 'stroke fill' });
1092
  return out;
1093
  },
1094
+ // 0.15.0: an outline that follows the SHAPE's alpha (a sprite, an icon, a cut-out), not its box. It is four drop-shadows with no blur,
1095
+ // one in each direction, which is what people write by hand. The corners come out square. It uses `filter`, so it replaces another filter on the same rule.
1096
+ // outline-shape: 3 color.ink outline-shape: none
1097
+ 'outline-shape'(v, ctx) {
1098
+ const atoms = (v || '').trim().split(/\s+/).filter(Boolean);
1099
+ if (atoms.length === 1 && atoms[0] === 'none') return [{ prop: 'filter', value: 'none' }];
1100
+ if (!atoms.length || !/^\d+(\.\d+)?(px)?$/.test(atoms[0])) {
1101
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'outline-shape needs a width first', hint: 'outline-shape: 3 color.ink' });
1102
+ return [];
1103
+ }
1104
+ const w = parseFloat(atoms[0]);
1105
+ const color = atoms[1] ? resolveValue(atoms.slice(1).join(' '), { ...ctx, unitless: false, bareIdents: false }) : 'currentcolor';
1106
+ const at = (x, y) => `drop-shadow(${x}px ${y}px 0 ${color})`;
1107
+ return [{ prop: 'filter', value: [at(w, 0), at(-w, 0), at(0, w), at(0, -w)].join(' ') }];
1108
+ },
1109
+ // 0.15.0: an angle is a bare number like every other length: rotate: 5 is 5deg (the other forms, 5deg, 0.1turn, x 30, pass through)
1110
+ rotate(v, ctx) {
1111
+ const t = (v || '').trim();
1112
+ const num = '-?\\d*\\.?\\d+';
1113
+ let out;
1114
+ if (new RegExp(`^${num}$`).test(t)) out = t + 'deg';
1115
+ else if (new RegExp(`^[xyz]\\s+${num}$`).test(t)) out = t + 'deg';
1116
+ else if (new RegExp(`^${num}\\s+${num}\\s+${num}\\s+${num}$`).test(t)) out = t + 'deg';
1117
+ else out = resolveValue(t, { ...ctx, unitless: true });
1118
+ return [{ prop: 'rotate', value: out }];
1119
+ },
1120
  'gradient-text'(v, ctx) {
1121
  const img = buildGradient(v, ctx);
1122
  if (!img) return [];
 
1668
  media: ctx.media, signals: ctx.signals, containers: ctx.containers,
1669
  layer: ctx.layer, order: order++, decls: [], file, line: node.line,
1670
  };
1671
+ // 0.15.0: a state inside the pseudo-element block styles that pseudo-element in that state:
1672
+ // .btn { ::after { content: ""; hover { opacity: 1 } } } is .btn:hover::after
1673
+ const fill = (target, kids) => {
1674
+ for (const d of kids) {
1675
+ if (d.kind === 'state' && target === srule) {
1676
+ if (d.name === 'seen') { errors.push({ file, line: d.line, message: '"seen" is engine-native and has no meaning on a pseudo-element', hint: 'put seen { } on the element itself' }); continue; }
1677
+ const st = { id: ruleId++, sel: base + ':' + d.name, pseudo: node.name, media: ctx.media, signals: ctx.signals, containers: ctx.containers, layer: ctx.layer, order: order++, decls: [], file, line: d.line };
1678
+ fill(st, d.children);
1679
+ stateRules.push(st);
 
 
1680
  continue;
1681
  }
1682
+ if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations and states (hover { }) may appear inside a pseudo-element block' }); continue; }
1683
+ if (d.prop === 'content' && d.value === '') { target.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1684
+ const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1685
+ // v0.5.0 - engine-native properties have no pseudo-element story:
1686
+ // there is no DOM handle to nudge, animate, stack or stagger. They
1687
+ // used to leak into the shadow sheet as junk CSS the browser
1688
+ // silently dropped - exactly the silence this language refuses.
1689
+ for (const r of resolved) {
1690
+ if (r.prop.startsWith('@') && r.prop !== '@animate') { // 0.14.0: animate: becomes a CSS animation on a pseudo-element
1691
+ errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo block' });
1692
+ continue;
1693
+ }
1694
+ target.decls.push({ ...r, line: d.line });
1695
+ }
1696
  }
1697
+ };
1698
+ const stateRules = [];
1699
+ fill(srule, node.children);
1700
  if (srule.decls.length) shadowRules.push(srule);
1701
+ for (const st of stateRules) if (st.decls.length) shadowRules.push(st);
1702
  break;
1703
  }
1704
  case 'tokens': {
dssl-vscode/lib/props-docs.js CHANGED
@@ -21,7 +21,8 @@ export const SEMANTIC = {
21
  fold: 'swings up over its top edge like a closing page. `fold: 14`.',
22
  stretch: 'cartoon squash-and-stretch that keeps volume: `stretch: 0.94` squeezes to 0.94 wide and honestly grows to 1/0.94 tall.',
23
  'bend-around': 'curves a container\u2019s CHILDREN along a circular arc instead of deforming one element: `bend-around: -22` opens into a smile. Caps at \u00b1170\u00b0. Children keep their own hover bends.',
24
- animate: 'run a motion: `animate: name duration [easing] [delay N] [jitter N] [iterations N] [infinite] [alternate] [essential]`. Example: `animate: pulse 1600 infinite alternate`. `jitter` gives each element its own random delay, `essential` runs it even when the visitor prefers reduced motion. Works on ::before and ::after too. Run one from JS with `DSSL.play(el, name, opts)`.',
 
25
  'text-stroke': 'an outline round text, outside the letters: `text-stroke: 6 color.ink` (writes -webkit-text-stroke and paint-order: stroke fill). Add `inside` for a centred stroke, `none` to clear.',
26
  stagger: 'choreographed children: `stagger: 70` fires each child\u2019s animation 70ms after its older sibling.',
27
  transition: 'per-property transition: `transition: 200`, or `transition: 200 snap transform`. Honors reduced-motion.',
@@ -34,8 +35,8 @@ export const SEMANTIC = {
34
  tilt: 'follows the pointer in 3D: `tilt: 10`, `tilt: 12 lift 1.04 perspective 700 speed 0.2 invert glare`. Runtime only. `glare` keeps --dssl-gx and --dssl-gy pointing at the pointer so you can draw a shine. Ignores touch, flat under reduced motion.',
35
  texture: 'a generated texture layered ON TOP of the element\u2019s own background: `texture: grain`, `texture: wood scale 6 intensity 0.8`, `texture: grain, scanlines`. 30 presets (grain, paper, marble, wood, carbon, halftone, ...) or build your own from a generator (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex) plus settings (scale, intensity, contrast, octaves, color, color2, blend, stretch, contour, ...). Wood: `texture: oak figure 0.8 knots 2 planks 120` (rings generator, ten species). `symmetrical` mirrors the tile so it never shows a seam. Open the designer to see them all.',
36
  aura: 'light and particles around an element, with NO presets: you describe it. `aura: glow #ff7a00 30 flicker 0.3, particles #fd0 -> #f60 count 60 shape flame glowing behind`. Replaces both slots. `aura: none` clears them.',
37
- 'aura-glow': 'just the glow slot: `aura-glow: color.accent 24 pulse 1600`. Positional colour and radius, then settings (intensity, pulse, depth, flicker, texture(...), seed, essential). `aura-glow: none` clears only the glow.',
38
- 'aura-particle': 'just the particles slot: `aura-particle: #7cf -> transparent 40 up size 6 life 800 speed 30 from bottom`. Settings: count, size, life, speed, dir, spread, gravity, drag, turbulence, from, shape, grow, glowing, behind, blend, texture(...), seed, essential.',
39
  'aura-particles': 'the same as `aura-particle`.',
40
  'glow-particle': 'the particles slot with every particle glowing (additive light): `glow-particle: #fff 30 out`.',
41
  'glow-particles': 'the same as `glow-particle`.',
@@ -66,7 +67,7 @@ export const CSS = {
66
  opacity: 'passthrough CSS. Below 100% the overlap policy wakes up (see `overlap`).',
67
  transform: 'passthrough CSS \u2014 deform (bend/stretch/fold) composes into this channel.',
68
  translate: 'the transform channel the engine itself uses for overlap escapes; animating translate never fights the engine.',
69
- rotate: 'passthrough CSS.',
70
  scale: 'passthrough CSS.',
71
  filter: 'passthrough CSS: blur(10), brightness(1.1).',
72
  'backdrop-filter': 'passthrough CSS: blur(10).',
@@ -116,7 +117,7 @@ export const STATES = {
116
  export const AT_RULES = {
117
  '@media': 'conditions: `@media (max-width: 860)`, sugar forms `@media dark`, `@media light`, `@media reduced-motion`, bare widths, `<=` ranges.',
118
  '@container': 'evaluates against the nearest ancestor (or self) with a container-type: `@container (min-width: 480)`.',
119
- '@signal': 'the JS bridge: `@signal menuOpen { ... }` re-evaluates when DSSL.signal(\u0027menuOpen\u0027, true) fires. Mirrors onto data-dssl-signal-* attributes. Also `@signal screen in (menu, shop)`, `not in (a, b)` and `!=`.',
120
  '@layer': 'cascade layers: `@layer base { }`, `@layer components { }`, `@layer utilities { }`. Cascade is (layer, source order) \u2014 no specificity, no !important.',
121
  };
122
 
 
21
  fold: 'swings up over its top edge like a closing page. `fold: 14`.',
22
  stretch: 'cartoon squash-and-stretch that keeps volume: `stretch: 0.94` squeezes to 0.94 wide and honestly grows to 1/0.94 tall.',
23
  'bend-around': 'curves a container\u2019s CHILDREN along a circular arc instead of deforming one element: `bend-around: -22` opens into a smile. Caps at \u00b1170\u00b0. Children keep their own hover bends.',
24
+ animate: 'run a motion (or several: `animate: blink 300 infinite, waddle 900 infinite alternate`): `animate: name duration [easing] [delay N] [jitter N] [iterations N] [infinite] [alternate] [essential]`. Example: `animate: pulse 1600 infinite alternate`. `jitter` gives each element its own random delay, `essential` runs it even when the visitor prefers reduced motion. Works on ::before and ::after too. Run one from JS with `DSSL.play(el, name, opts)`.',
25
+ 'outline-shape': 'an outline that follows the SHAPE (a sprite\u2019s alpha), not its box: `outline-shape: 3 color.ink`. Four hard drop-shadows, square corners. Uses `filter`, so it replaces another filter on the same rule. `none` clears it.',
26
  'text-stroke': 'an outline round text, outside the letters: `text-stroke: 6 color.ink` (writes -webkit-text-stroke and paint-order: stroke fill). Add `inside` for a centred stroke, `none` to clear.',
27
  stagger: 'choreographed children: `stagger: 70` fires each child\u2019s animation 70ms after its older sibling.',
28
  transition: 'per-property transition: `transition: 200`, or `transition: 200 snap transform`. Honors reduced-motion.',
 
35
  tilt: 'follows the pointer in 3D: `tilt: 10`, `tilt: 12 lift 1.04 perspective 700 speed 0.2 invert glare`. Runtime only. `glare` keeps --dssl-gx and --dssl-gy pointing at the pointer so you can draw a shine. Ignores touch, flat under reduced motion.',
36
  texture: 'a generated texture layered ON TOP of the element\u2019s own background: `texture: grain`, `texture: wood scale 6 intensity 0.8`, `texture: grain, scanlines`. 30 presets (grain, paper, marble, wood, carbon, halftone, ...) or build your own from a generator (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex) plus settings (scale, intensity, contrast, octaves, color, color2, blend, stretch, contour, ...). Wood: `texture: oak figure 0.8 knots 2 planks 120` (rings generator, ten species). `symmetrical` mirrors the tile so it never shows a seam. Open the designer to see them all.',
37
  aura: 'light and particles around an element, with NO presets: you describe it. `aura: glow #ff7a00 30 flicker 0.3, particles #fd0 -> #f60 count 60 shape flame glowing behind`. Replaces both slots. `aura: none` clears them.',
38
+ 'aura-glow': 'just the glow slot: `aura-glow: color.accent 24 pulse 1600`. Positional colour and radius, then settings (z N, always, intensity, pulse, depth, flicker, texture(...), seed, essential). `aura-glow: none` clears only the glow.',
39
+ 'aura-particle': 'just the particles slot: `aura-particle: #7cf -> transparent 40 up size 6 life 800 speed 30 from bottom`. Settings: z N (own canvas layer), count, size, life, speed, dir, spread, gravity, drag, turbulence, from, shape, grow, glowing, behind, blend, texture(...), seed, essential.',
40
  'aura-particles': 'the same as `aura-particle`.',
41
  'glow-particle': 'the particles slot with every particle glowing (additive light): `glow-particle: #fff 30 out`.',
42
  'glow-particles': 'the same as `glow-particle`.',
 
67
  opacity: 'passthrough CSS. Below 100% the overlap policy wakes up (see `overlap`).',
68
  transform: 'passthrough CSS \u2014 deform (bend/stretch/fold) composes into this channel.',
69
  translate: 'the transform channel the engine itself uses for overlap escapes; animating translate never fights the engine.',
70
+ rotate: 'rotation. A bare number is degrees: `rotate: 5` is 5deg. `5deg`, `0.1turn` and `x 30` pass through.',
71
  scale: 'passthrough CSS.',
72
  filter: 'passthrough CSS: blur(10), brightness(1.1).',
73
  'backdrop-filter': 'passthrough CSS: blur(10).',
 
117
  export const AT_RULES = {
118
  '@media': 'conditions: `@media (max-width: 860)`, sugar forms `@media dark`, `@media light`, `@media reduced-motion`, bare widths, `<=` ranges.',
119
  '@container': 'evaluates against the nearest ancestor (or self) with a container-type: `@container (min-width: 480)`.',
120
+ '@signal': 'the JS bridge: `@signal menuOpen { ... }` re-evaluates when DSSL.signal(\u0027menuOpen\u0027, true) fires. Mirrors onto data-dssl-signal-* attributes. Also `@signal screen in (menu, shop)`, `not in (a, b)` and `!=`. `DSSL.signal(name, value, el)` sets it for one subtree.',
121
  '@layer': 'cascade layers: `@layer base { }`, `@layer components { }`, `@layer utilities { }`. Cascade is (layer, source order) \u2014 no specificity, no !important.',
122
  };
123
 
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 and for textures, auras and sliders, hover docs, an interactive designer with sliders and live previews for aura, texture and range sliders, and a CSS to DSSL converter. Works in VS Code and VSCodium.",
5
- "version": "0.14.0",
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 and for textures, auras and sliders, hover docs, an interactive designer with sliders and live previews for aura, texture and range sliders, and a CSS to DSSL converter. Works in VS Code and VSCodium.",
5
+ "version": "0.15.0",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
engine-package/USAGE.md CHANGED
@@ -68,13 +68,15 @@ Bare numbers are pixels. States like `hover` are nested blocks. The engine keeps
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()` a function: the last reported errors (also grouped in the console, with did-you-mean hints)
75
  - `DSSL.version` the engine version, a string (not a function)
76
  - `DSSL.toCSS(source | [sources], opts)` compile to plain CSS (the `dssl build` lane): `{ css, map, report }`
77
- - `DSSL.getSignal(name)` read a signal back
 
 
78
  - `DSSL.refresh(el)` / `DSSL.freeze(el)` / `DSSL.unfreeze(el)` one subtree: restyle it, or style it once and leave it alone
79
  - `DSSL.explain(el)`, `await DSSL.inspect()`, `await DSSL.watch()`, `await DSSL.lint(root)` find out why a style is what it is, hot reload, check the live page
80
  - `DSSL.play(el, 'motion', opts)` run a motion once from JS (a promise), `DSSL.motion('full')` ignore reduced motion, `DSSL.auraZ(n)` aura canvas z-index
 
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`; `DSSL.signal(name, value, el)` for one subtree only. Booleans and numbers stay booleans and numbers
72
  - `DSSL.refresh()` force a full recompute
73
  - `DSSL.replay(selector)` replay entrance motions on matching elements
74
  - `DSSL.errors()` a function: the last reported errors (also grouped in the console, with did-you-mean hints)
75
  - `DSSL.version` the engine version, a string (not a function)
76
  - `DSSL.toCSS(source | [sources], opts)` compile to plain CSS (the `dssl build` lane): `{ css, map, report }`
77
+ - `DSSL.getSignal(name)` read a signal back (`DSSL.getSignal(name, el)` for the value an element sees)
78
+ - `DSSL.flush()` / `await DSSL.settled()` style everything that is waiting right now, so `offsetTop` and computed styles are final; `DSSL.computed(el, prop)` is the final computed value
79
+ - `DSSL.pauseMotion()`, `DSSL.stepMotion(ms)`, `DSSL.resumeMotion()` freeze, step and release every animation, for tests
80
  - `DSSL.refresh(el)` / `DSSL.freeze(el)` / `DSSL.unfreeze(el)` one subtree: restyle it, or style it once and leave it alone
81
  - `DSSL.explain(el)`, `await DSSL.inspect()`, `await DSSL.watch()`, `await DSSL.lint(root)` find out why a style is what it is, hot reload, check the live page
82
  - `DSSL.play(el, 'motion', opts)` run a motion once from JS (a promise), `DSSL.motion('full')` ignore reduced motion, `DSSL.auraZ(n)` aura canvas z-index
llms.txt CHANGED
@@ -5,7 +5,7 @@
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.14.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
 
@@ -43,10 +43,11 @@ 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([el]) / DSSL.errors() (a function) / DSSL.version (a string) / DSSL.getSignal(name) / DSSL.freeze([el]) / DSSL.unfreeze([el]) / await DSSL.inspect() / await DSSL.watch() / await DSSL.lint(root) / DSSL.play(el, motion, opts) / DSSL.motion('full'|'reduced'|'auto') / DSSL.auraZ(n)
50
  ```
51
 
52
  ## 0.9.0: texture, aura, sliders, lint, import
@@ -63,6 +64,15 @@ DSSL.replay(sel) / DSSL.refresh([el]) / DSSL.errors() (a function) / DSSL.versio
63
  - Alpha on a named colour works: `white/12`, `currentColor/20`. It used to compile to `white/12px`.
64
  - The playground has a canvas.html tab, `?example=` links, lint warnings and an "as CSS" tab. The canvas keeps a short allow-list of tags and drops scripts, handlers, styles and ids.
65
 
 
 
 
 
 
 
 
 
 
66
  ## 0.14.0: made for games (from a review by an agent that built one)
67
  - Reduced motion is never silent. A plain `animate:` does not run when the system asks for reduced motion; the console now says so once, and says how to get out: put `essential` in the motion block or on the animate line, or `DSSL.motion('full')` / `<html data-dssl-motion="full">` to run everything (`'reduced'` holds all still, `'auto'` follows the system).
68
  - `animate: name 300 delay 100 jitter 200 iterations 3 essential`. `jitter` gives every element its own random delay, the same on every load. `animation-delay` and friends beside `animate:` now warn, because they do nothing there.
@@ -111,4 +121,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
111
 
112
  ## Editor support
113
 
114
- 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. In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.14.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
 
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.15.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
 
 
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; DSSL.signal(name, value, el) for one subtree
47
+ await DSSL.settled() / DSSL.flush() // style everything waiting now (offsetTop is final after it)
48
  DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
49
  DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
50
+ DSSL.replay(sel) / DSSL.refresh([el]) / DSSL.errors() (a function) / DSSL.version (a string) / DSSL.getSignal(name) / DSSL.freeze([el]) / DSSL.unfreeze([el]) / await DSSL.inspect() / await DSSL.watch() / await DSSL.lint(root) / DSSL.play(el, motion, opts) / DSSL.motion('full'|'reduced'|'auto') / DSSL.auraZ(n) / DSSL.computed(el, prop) / DSSL.pauseMotion() / DSSL.stepMotion(ms) / DSSL.resumeMotion()
51
  ```
52
 
53
  ## 0.9.0: texture, aura, sliders, lint, import
 
64
  - Alpha on a named colour works: `white/12`, `currentColor/20`. It used to compile to `white/12px`.
65
  - The playground has a canvas.html tab, `?example=` links, lint warnings and an "as CSS" tab. The canvas keeps a short allow-list of tags and drops scripts, handlers, styles and ids.
66
 
67
+ ## 0.15.0: the second game review
68
+ - Aura: it follows moving things under reduced motion (a quick look every 100ms, a full frame whenever something moved), skips emitters that cannot be seen (opacity 0 or display: none or visibility: hidden on it or any ancestor, and what they let go fades with them; the word `always` keeps an invisible anchor emitting), and takes `z N` to draw a layer on its own canvas at that z-index (`aura-glow: #f80 40 z 5` goes under a hill with z-index 10; it meets elements in the root stacking context).
69
+ - `dssl check` and the console name rules that can never apply: a rule that a LATER rule matches everywhere and overrides (`.prop.broc { z-index: 1 }` before a plain `.prop { z-index: 2 }`; there is no specificity). The same selector again is left alone, that is an override.
70
+ - Signals keep their type (`getSignal('x')` is `true`, not `"true"`; `0` is off). `DSSL.signal(name, value, el)` scopes one to a subtree (the nearest wins). A signal restyles only the elements its rules select. `await DSSL.settled()` / `DSSL.flush()` make styling final right away.
71
+ - `animate: blink 300 infinite, waddle 900 infinite alternate` runs several motions on one element (also on ::before and ::after, and in `dssl build`). `DSSL.play(el, 'pop', { pseudo: '::after' })`.
72
+ - `outline-shape: 3 color.ink` outlines the shape's alpha, not its box (four hard drop-shadows, square corners; uses `filter`). `rotate: 5` is 5deg. A state inside a pseudo-element block (`::after { hover { opacity: 1 } }`) is that pseudo-element in that state. `font: bold 16px Arial` is now an error that says DSSL's `font:` is size/line-height and colour.
73
+ - `DSSL.lint` skips invisible text and judges text-stroke and tight text-shadow. `dssl ... --engine ./dssl` (or DSSL_ENGINE) finds the engine anywhere. `DSSL.pauseMotion()`, `stepMotion(ms)`, `resumeMotion()` and `DSSL.computed(el, prop)` for tests.
74
+ - Gradients: the first layer is the one on top.
75
+
76
  ## 0.14.0: made for games (from a review by an agent that built one)
77
  - Reduced motion is never silent. A plain `animate:` does not run when the system asks for reduced motion; the console now says so once, and says how to get out: put `essential` in the motion block or on the animate line, or `DSSL.motion('full')` / `<html data-dssl-motion="full">` to run everything (`'reduced'` holds all still, `'auto'` follows the system).
78
  - `animate: name 300 delay 100 jitter 200 iterations 3 essential`. `jitter` gives every element its own random delay, the same on every load. `animation-delay` and friends beside `animate:` now warn, because they do nothing there.
 
121
 
122
  ## Editor support
123
 
124
+ 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. In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.15.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "dssl",
3
- "version": "0.14.0",
4
  "private": true,
5
  "description": "The DSSL style language and the website that is written in it.",
6
  "scripts": {
 
1
  {
2
  "name": "dssl",
3
+ "version": "0.15.0",
4
  "private": true,
5
  "description": "The DSSL style language and the website that is written in it.",
6
  "scripts": {
public/dssl/aura.js CHANGED
@@ -49,6 +49,8 @@ export const GLOW_KEYS = {
49
  texture: { kind: 'texture', words: 1, doc: 'texture(grain scale 2): break the light up with a texture' },
50
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
51
  unclipped: { kind: 'flag', words: 0, doc: 'let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself' },
 
 
52
  };
53
 
54
  export const PARTICLE_KEYS = {
@@ -72,6 +74,8 @@ export const PARTICLE_KEYS = {
72
  texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
73
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
74
  unclipped: { kind: 'flag', words: 0, doc: 'let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge' },
 
 
75
  };
76
 
77
  /* ------------------------------------------------------------------ *
@@ -382,6 +386,7 @@ export class AuraManager {
382
  // reduced motion draws a still picture, so it only needs a new frame when something could have moved
383
  this._onScroll = () => { if (this.reduced && !this.running && !this.paused && this.emitters.size) this.start(); };
384
  this._poll = null;
 
385
  this._frame = (t) => this.frame(t);
386
  }
387
 
@@ -418,11 +423,62 @@ export class AuraManager {
418
  if (!this.canvas) return;
419
  this.dpr = Math.min(2, window.devicePixelRatio || 1);
420
  this.vw = window.innerWidth; this.vh = window.innerHeight;
421
- this.canvas.width = Math.max(1, Math.round(this.vw * this.dpr));
422
- this.canvas.height = Math.max(1, Math.round(this.vh * this.dpr));
 
 
423
  this.dirty = true;
424
  }
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  /* ---- the engine's interface ---- */
427
 
428
  /** Set (or change) the aura of an element. glow/particles are arrays of resolved layers. */
@@ -458,7 +514,7 @@ export class AuraManager {
458
  this.start();
459
  }
460
 
461
- pause() { this.paused = true; this.running = false; if (this._poll) { clearTimeout(this._poll); this._poll = null; } if (this.ctx) this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); }
462
  resume() { this.paused = false; this.start(); }
463
 
464
  destroy() {
@@ -467,6 +523,8 @@ export class AuraManager {
467
  window.removeEventListener('scroll', this._onScroll, { capture: true });
468
  if (this._poll) { clearTimeout(this._poll); this._poll = null; }
469
  if (this.io) this.io.disconnect();
 
 
470
  if (this.canvas) this.canvas.remove();
471
  this.canvas = null; this.ctx = null; this.emitters.clear();
472
  }
@@ -486,11 +544,15 @@ export class AuraManager {
486
  const dt = this.last ? Math.min(0.05, (now - this.last) / 1000) : 0.016;
487
  this.last = now;
488
  const reduced = this.reduced;
489
- const ctx = this.ctx, dpr = this.dpr;
490
 
491
- ctx.setTransform(1, 0, 0, 1, 0, 0);
492
- ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
493
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
 
 
 
 
494
 
495
  // global budget: if everyone asks for more than the page can afford, everyone gets a share
496
  let want = 0;
@@ -498,6 +560,7 @@ export class AuraManager {
498
  const share = want > this.budget ? this.budget / want : 1;
499
 
500
  let alive = 0, anyActive = false;
 
501
  for (const [el, em] of this.emitters) {
502
  if (!el.isConnected) { this.emitters.delete(el); if (this.io) this.io.unobserve(el); continue; }
503
  // read geometry once per frame for visible emitters
@@ -507,6 +570,12 @@ export class AuraManager {
507
  if (rect.width === 0 && rect.height === 0) rect = null;
508
  }
509
  em.rect = rect;
 
 
 
 
 
 
510
  // 0.14.0: a stage scaled with transform: scale() has a rect the size you SEE, so the light follows it, and so must its size
511
  // and speed. The scale is how much bigger (or smaller) the box is than the box CSS says it is.
512
  em.scale = 1;
@@ -518,11 +587,13 @@ export class AuraManager {
518
 
519
  // 0.14.0: cut the light at the edge of any parent that clips, so a stage with overflow: hidden keeps its fire inside
520
  const clipped = rect && em.clips && em.clips.length ? this.clipRect(em.clips) : null;
521
- if (clipped) { ctx.save(); ctx.beginPath(); ctx.rect(clipped.x, clipped.y, clipped.w, clipped.h); ctx.clip(); }
 
 
522
 
523
  // glow
524
- if (rect && em.fade > 0 && (em.visible || em.dying)) {
525
- for (const g of em.glow) { this.drawGlow(ctx, rect, em, g, now, reduced); }
526
  if (em.glow.length) anyActive = true;
527
  }
528
 
@@ -531,12 +602,12 @@ export class AuraManager {
531
  const cfg = L.cfg;
532
  const moving = !reduced || cfg.essential;
533
  L.scale = em.scale;
534
- if (rect && em.visible && !em.dying && moving) this.emit(L, rect, em, dt, share, el);
535
- if (L.parts.length) this.stepAndDraw(ctx, L, dt, moving, cfg.behind && rect ? { rect, radius: em.radius } : null);
 
536
  alive += L.parts.length;
537
- if (L.parts.length || (rect && em.visible && !em.dying && moving)) anyActive = true;
538
  }
539
- if (clipped) ctx.restore();
540
  if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
541
  this.emitters.delete(el);
542
  if (this.io) this.io.unobserve(el);
@@ -550,12 +621,14 @@ export class AuraManager {
550
  this._fpsN++;
551
  if (now - this._fpsT > 500) { this.stats.fps = Math.round((this._fpsN * 1000) / Math.max(1, now - this._fpsT)); this._fpsT = now; this._fpsN = 0; }
552
 
553
- if (this.emitters.size === 0) { this.running = false; ctx.clearRect(0, 0, this.vw, this.vh); return; }
554
  // Normally the loop runs every frame (the glow follows the element, particles move). With reduced
555
  // motion nothing moves, so one frame is a complete picture; it is redrawn on scroll, resize, a
556
- // style change, and a slow poll for layout shifts. Essential layers and fading-out emitters still loop.
 
557
  let needLoop = !reduced;
558
  if (reduced) {
 
559
  for (const em of this.emitters.values()) {
560
  if (em.dying) needLoop = true;
561
  if (em.glow.some((g) => g.cfg.essential) || em.layers.some((l) => l.cfg.essential && (l.parts.length || (em.visible && !em.dying)))) needLoop = true;
@@ -563,7 +636,17 @@ export class AuraManager {
563
  }
564
  if (needLoop) { requestAnimationFrame(this._frame); return; }
565
  this.running = false;
566
- if (!this._poll) this._poll = setTimeout(() => { this._poll = null; this.start(); }, 600);
 
 
 
 
 
 
 
 
 
 
567
  }
568
 
569
  /** the ancestors that clip their children (overflow other than visible), unless this emitter said `unclipped` */
@@ -604,7 +687,7 @@ export class AuraManager {
604
  drawGlow(ctx, rect, em, g, now, reduced) {
605
  const cfg = g.cfg;
606
  const moving = !reduced || cfg.essential;
607
- let a = cfg.intensity * em.fade;
608
  if (moving && cfg.pulse > 0) {
609
  const ph = ((now - g.t0) / cfg.pulse) * TAU;
610
  a *= 1 - cfg.depth * 0.5 * (1 + Math.sin(ph)) ;
@@ -819,7 +902,7 @@ export class AuraManager {
819
  }
820
  const t = p.age / p.life;
821
  const col = colorAt(c.stops, t);
822
- const alpha = clamp01(col.a);
823
  if (alpha <= 0.003) continue;
824
  const size = p.size * lerp(1, c.grow, t);
825
  this.drawParticle(ctx, c, p, col, alpha, size);
 
49
  texture: { kind: 'texture', words: 1, doc: 'texture(grain scale 2): break the light up with a texture' },
50
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
51
  unclipped: { kind: 'flag', words: 0, doc: 'let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself' },
52
+ always: { kind: 'flag', words: 0, doc: 'keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of' },
53
+ z: num(-1000000, 2147483000, { int: true, doc: 'the z-index of the layer this light is drawn on: `z 5` puts it under a hill with z-index 10. It meets elements in the page\'s root stacking context' }),
54
  };
55
 
56
  export const PARTICLE_KEYS = {
 
74
  texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
75
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
76
  unclipped: { kind: 'flag', words: 0, doc: 'let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge' },
77
+ always: { kind: 'flag', words: 0, doc: 'keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of' },
78
+ z: num(-1000000, 2147483000, { int: true, doc: 'the z-index of the layer these particles are drawn on: `z 5` puts them under a hill with z-index 10. It meets elements in the page\'s root stacking context' }),
79
  };
80
 
81
  /* ------------------------------------------------------------------ *
 
386
  // reduced motion draws a still picture, so it only needs a new frame when something could have moved
387
  this._onScroll = () => { if (this.reduced && !this.running && !this.paused && this.emitters.size) this.start(); };
388
  this._poll = null;
389
+ this.extra = new Map(); // z-index -> { canvas, ctx }: layers asked for with `z N`
390
  this._frame = (t) => this.frame(t);
391
  }
392
 
 
423
  if (!this.canvas) return;
424
  this.dpr = Math.min(2, window.devicePixelRatio || 1);
425
  this.vw = window.innerWidth; this.vh = window.innerHeight;
426
+ for (const c of this.allCanvases()) {
427
+ c.width = Math.max(1, Math.round(this.vw * this.dpr));
428
+ c.height = Math.max(1, Math.round(this.vh * this.dpr));
429
+ }
430
  this.dirty = true;
431
  }
432
 
433
+ allCanvases() { return [this.canvas, ...[...this.extra.values()].map((e) => e.canvas)]; }
434
+
435
+ /** the drawing context for a layer: the default canvas, or the one at the z-index the layer asked for (`z 5`) */
436
+ ctxFor(z) {
437
+ if (z === undefined || z === null) return this.ctx;
438
+ let e = this.extra.get(z);
439
+ if (!e) {
440
+ const c = document.createElement('canvas');
441
+ c.setAttribute('aria-hidden', 'true');
442
+ c.setAttribute('data-dssl-aura', String(z));
443
+ c.style.cssText = `position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:${z};`;
444
+ c.width = this.canvas.width; c.height = this.canvas.height;
445
+ (document.body || document.documentElement).appendChild(c);
446
+ e = { canvas: c, ctx: c.getContext('2d') };
447
+ this.extra.set(z, e);
448
+ }
449
+ return e.ctx;
450
+ }
451
+
452
+ /** how visible an element really is: its opacity times every ancestor's; 0 for display: none or visibility: hidden */
453
+ effectiveOpacity(el) {
454
+ try {
455
+ let o = 1;
456
+ if (getComputedStyle(el).visibility !== 'visible') return 0;
457
+ for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
458
+ const cs = getComputedStyle(a);
459
+ if (cs.display === 'none') return 0;
460
+ o *= parseFloat(cs.opacity);
461
+ if (!(o > 0.004)) return 0;
462
+ }
463
+ return o;
464
+ } catch { return 1; }
465
+ }
466
+
467
+ /** what a frame would draw for this emitter, as a string: the same string twice means nothing needs redrawing */
468
+ stateKey(el, eo) {
469
+ const r = el.getBoundingClientRect();
470
+ return `${r.left.toFixed(1)},${r.top.toFixed(1)},${r.width.toFixed(1)},${r.height.toFixed(1)},${eo.toFixed(2)}`;
471
+ }
472
+
473
+ /** reduced motion: has anything moved, resized or faded since the last frame? (cheap: no drawing) */
474
+ peek() {
475
+ for (const [el, em] of this.emitters) {
476
+ if (!el.isConnected) return true;
477
+ if (this.stateKey(el, this.effectiveOpacity(el)) !== em.key) return true;
478
+ }
479
+ return false;
480
+ }
481
+
482
  /* ---- the engine's interface ---- */
483
 
484
  /** Set (or change) the aura of an element. glow/particles are arrays of resolved layers. */
 
514
  this.start();
515
  }
516
 
517
+ pause() { this.paused = true; this.running = false; if (this._poll) { clearTimeout(this._poll); this._poll = null; } if (this.ctx) for (const c of this.allCanvases()) c.getContext('2d').clearRect(0, 0, c.width, c.height); }
518
  resume() { this.paused = false; this.start(); }
519
 
520
  destroy() {
 
523
  window.removeEventListener('scroll', this._onScroll, { capture: true });
524
  if (this._poll) { clearTimeout(this._poll); this._poll = null; }
525
  if (this.io) this.io.disconnect();
526
+ for (const e of this.extra.values()) e.canvas.remove();
527
+ this.extra.clear();
528
  if (this.canvas) this.canvas.remove();
529
  this.canvas = null; this.ctx = null; this.emitters.clear();
530
  }
 
544
  const dt = this.last ? Math.min(0.05, (now - this.last) / 1000) : 0.016;
545
  this.last = now;
546
  const reduced = this.reduced;
547
+ const dpr = this.dpr;
548
 
549
+ for (const c of this.allCanvases()) {
550
+ const x = c.getContext('2d');
551
+ x.setTransform(1, 0, 0, 1, 0, 0);
552
+ x.clearRect(0, 0, c.width, c.height);
553
+ x.setTransform(dpr, 0, 0, dpr, 0, 0);
554
+ }
555
+ let changed = false;
556
 
557
  // global budget: if everyone asks for more than the page can afford, everyone gets a share
558
  let want = 0;
 
560
  const share = want > this.budget ? this.budget / want : 1;
561
 
562
  let alive = 0, anyActive = false;
563
+ this._n = (this._n || 0) + 1;
564
  for (const [el, em] of this.emitters) {
565
  if (!el.isConnected) { this.emitters.delete(el); if (this.io) this.io.unobserve(el); continue; }
566
  // read geometry once per frame for visible emitters
 
570
  if (rect.width === 0 && rect.height === 0) rect = null;
571
  }
572
  em.rect = rect;
573
+ // 0.15.0: an emitter that cannot be seen (opacity 0 on it or any ancestor, display: none, visibility: hidden) does not emit, and
574
+ // what it already let go fades with it. Looked up every third frame, or every frame in reduced motion where frames are rare.
575
+ if (em.eo === undefined || reduced || this._n % 3 === 0) em.eo = this.effectiveOpacity(el);
576
+ if (em.layers.some((l) => l.cfg.always) || em.glow.some((g) => g.cfg.always)) em.eo = 1; // an invisible anchor on purpose
577
+ const key = this.stateKey(el, em.eo);
578
+ if (key !== em.key) { em.key = key; changed = true; }
579
  // 0.14.0: a stage scaled with transform: scale() has a rect the size you SEE, so the light follows it, and so must its size
580
  // and speed. The scale is how much bigger (or smaller) the box is than the box CSS says it is.
581
  em.scale = 1;
 
587
 
588
  // 0.14.0: cut the light at the edge of any parent that clips, so a stage with overflow: hidden keeps its fire inside
589
  const clipped = rect && em.clips && em.clips.length ? this.clipRect(em.clips) : null;
590
+ const open = (ctx) => { if (clipped) { ctx.save(); ctx.beginPath(); ctx.rect(clipped.x, clipped.y, clipped.w, clipped.h); ctx.clip(); } };
591
+ const close = (ctx) => { if (clipped) ctx.restore(); };
592
+ const seen = em.eo > 0.004;
593
 
594
  // glow
595
+ if (rect && em.fade > 0 && (em.visible || em.dying) && seen) {
596
+ for (const g of em.glow) { const ctx = this.ctxFor(g.cfg.z); open(ctx); this.drawGlow(ctx, rect, em, g, now, reduced); close(ctx); }
597
  if (em.glow.length) anyActive = true;
598
  }
599
 
 
602
  const cfg = L.cfg;
603
  const moving = !reduced || cfg.essential;
604
  L.scale = em.scale;
605
+ L.eo = em.eo;
606
+ if (rect && em.visible && !em.dying && moving && seen) this.emit(L, rect, em, dt, share, el);
607
+ if (L.parts.length) { const ctx = this.ctxFor(cfg.z); open(ctx); this.stepAndDraw(ctx, L, dt, moving, cfg.behind && rect ? { rect, radius: em.radius } : null); close(ctx); }
608
  alive += L.parts.length;
609
+ if (L.parts.length || (rect && em.visible && !em.dying && moving && seen)) anyActive = true;
610
  }
 
611
  if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
612
  this.emitters.delete(el);
613
  if (this.io) this.io.unobserve(el);
 
621
  this._fpsN++;
622
  if (now - this._fpsT > 500) { this.stats.fps = Math.round((this._fpsN * 1000) / Math.max(1, now - this._fpsT)); this._fpsT = now; this._fpsN = 0; }
623
 
624
+ if (this.emitters.size === 0) { this.running = false; for (const c of this.allCanvases()) c.getContext('2d').clearRect(0, 0, c.width, c.height); return; }
625
  // Normally the loop runs every frame (the glow follows the element, particles move). With reduced
626
  // motion nothing moves, so one frame is a complete picture; it is redrawn on scroll, resize, a
627
+ // style change, and a quick look (peek) for anything that moved. A frame that saw something move, resize or fade asks for the next one,
628
+ // so a glow on a thing that moves under reduced motion follows it every frame instead of lagging. Essential layers and fading-out emitters still loop.
629
  let needLoop = !reduced;
630
  if (reduced) {
631
+ if (changed) needLoop = true;
632
  for (const em of this.emitters.values()) {
633
  if (em.dying) needLoop = true;
634
  if (em.glow.some((g) => g.cfg.essential) || em.layers.some((l) => l.cfg.essential && (l.parts.length || (em.visible && !em.dying)))) needLoop = true;
 
636
  }
637
  if (needLoop) { requestAnimationFrame(this._frame); return; }
638
  this.running = false;
639
+ this.watchStill();
640
+ }
641
+
642
+ /** reduced motion, nothing moving: look every 100ms (a few rect reads, no drawing) and draw again the moment something changed */
643
+ watchStill() {
644
+ if (this._poll || this.paused) return;
645
+ this._poll = setTimeout(() => {
646
+ this._poll = null;
647
+ if (this.running || !this.canvas) return;
648
+ if (this.peek()) this.start(); else this.watchStill();
649
+ }, 100);
650
  }
651
 
652
  /** the ancestors that clip their children (overflow other than visible), unless this emitter said `unclipped` */
 
687
  drawGlow(ctx, rect, em, g, now, reduced) {
688
  const cfg = g.cfg;
689
  const moving = !reduced || cfg.essential;
690
+ let a = cfg.intensity * em.fade * (em.eo === undefined ? 1 : em.eo);
691
  if (moving && cfg.pulse > 0) {
692
  const ph = ((now - g.t0) / cfg.pulse) * TAU;
693
  a *= 1 - cfg.depth * 0.5 * (1 + Math.sin(ph)) ;
 
902
  }
903
  const t = p.age / p.life;
904
  const col = colorAt(c.stops, t);
905
+ const alpha = clamp01(col.a) * (L.eo === undefined ? 1 : L.eo);
906
  if (alpha <= 0.003) continue;
907
  const size = p.size * lerp(1, c.grow, t);
908
  this.drawParticle(ctx, c, p, col, alpha, size);
public/dssl/dev.js CHANGED
@@ -9,6 +9,7 @@
9
  * ========================================================================= */
10
 
11
  import { parseColor, over, contrast, toHex } from './color.js';
 
12
 
13
  const HOST_ID = 'dssl-dev-host';
14
 
@@ -221,6 +222,24 @@ export function lintDocument(root, { explain, engines }, opts = {}) {
221
  };
222
  const opacityOf = (el) => { let o = 1; for (let n = el; n && n.nodeType === 1; n = n.parentElement) o *= parseFloat(getComputedStyle(n).opacity) || 0; return o; };
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  const seen = new Set();
225
  const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
226
  for (let t = walker.nextNode(); t; t = walker.nextNode()) {
@@ -237,11 +256,13 @@ export function lintDocument(root, { explain, engines }, opts = {}) {
237
  const bg = backdrop(el);
238
  if (!fg || !bg) continue;
239
  const op = opacityOf(el);
 
240
  const solidFg = over({ ...fg, a: fg.a * op }, bg);
241
  const ratio = contrast(solidFg, bg);
242
  const size = parseFloat(cs.fontSize) || 16, bold = (parseInt(cs.fontWeight, 10) || 400) >= 700;
243
  const large = size >= 24 || (size >= 18.66 && bold);
244
  const need = large ? Math.min(min, 3) : min;
 
245
  if (ratio < need) {
246
  out.push({ kind: 'contrast', el, selector: describe(el), ratio: Math.round(ratio * 100) / 100, fg: toHex(solidFg), bg: toHex(bg), message: `text is ${ratio.toFixed(1)}:1 against what is behind it (${toHex(solidFg)} on ${toHex(bg)}), below ${need}:1${large ? ' for large text' : ''}` });
247
  }
@@ -256,6 +277,7 @@ export function lintDocument(root, { explain, engines }, opts = {}) {
256
  out.push({ kind: 'promoted', el, selector: describe(el), message: `the engine made this a ${disp.value} because a rule aligns its children without a display (it was ${natural}); dssl build will not` });
257
  }
258
  }
 
259
  if (eng._reducedTold) out.push({ kind: 'motion', el: null, selector: '(page)', message: 'some animate: motions did not run because reduced motion is on; mark the ones that carry meaning `essential`, or DSSL.motion("full")' });
260
  }
261
  if (opts.log !== false && out.length) {
 
9
  * ========================================================================= */
10
 
11
  import { parseColor, over, contrast, toHex } from './color.js';
12
+ import { shadowWarnings } from './lint.js';
13
 
14
  const HOST_ID = 'dssl-dev-host';
15
 
 
222
  };
223
  const opacityOf = (el) => { let o = 1; for (let n = el; n && n.nodeType === 1; n = n.parentElement) o *= parseFloat(getComputedStyle(n).opacity) || 0; return o; };
224
 
225
+ // text-stroke and text-shadow are how game and poster text stays readable on any background. The letters count as readable when the
226
+ // outline (a stroke of 1px or more) or a tight shadow (blur up to 4px) is a colour the letters themselves stand out against.
227
+ const outlined = (cs, fg, need) => {
228
+ const sw = parseFloat(cs.webkitTextStrokeWidth) || 0;
229
+ if (sw >= 1) { const sc = parseColor(cs.webkitTextStrokeColor); if (sc && sc.a > 0.5 && contrast(over(fg, { ...sc, a: 1 }), { ...sc, a: 1 }) >= need) return true; }
230
+ const ts = cs.textShadow;
231
+ if (ts && ts !== 'none') {
232
+ for (const part of ts.split(/,(?![^(]*\))/)) {
233
+ const cm = /rgba?\([^)]*\)/.exec(part);
234
+ const sc = cm && parseColor(cm[0]);
235
+ const nums = part.replace(/rgba?\([^)]*\)/, '').trim().split(/\s+/).map((x) => parseFloat(x));
236
+ const blur = nums[2] || 0;
237
+ if (sc && sc.a > 0.5 && blur <= 4 && contrast(over(fg, { ...sc, a: 1 }), { ...sc, a: 1 }) >= need) return true;
238
+ }
239
+ }
240
+ return false;
241
+ };
242
+
243
  const seen = new Set();
244
  const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
245
  for (let t = walker.nextNode(); t; t = walker.nextNode()) {
 
256
  const bg = backdrop(el);
257
  if (!fg || !bg) continue;
258
  const op = opacityOf(el);
259
+ if (op < 0.05) continue; // invisible (a closed menu, a hidden toast): judging it against itself is noise
260
  const solidFg = over({ ...fg, a: fg.a * op }, bg);
261
  const ratio = contrast(solidFg, bg);
262
  const size = parseFloat(cs.fontSize) || 16, bold = (parseInt(cs.fontWeight, 10) || 400) >= 700;
263
  const large = size >= 24 || (size >= 18.66 && bold);
264
  const need = large ? Math.min(min, 3) : min;
265
+ if (ratio < need && outlined(cs, fg, need)) continue; // an outline or hard shadow that sets the letters apart carries them
266
  if (ratio < need) {
267
  out.push({ kind: 'contrast', el, selector: describe(el), ratio: Math.round(ratio * 100) / 100, fg: toHex(solidFg), bg: toHex(bg), message: `text is ${ratio.toFixed(1)}:1 against what is behind it (${toHex(solidFg)} on ${toHex(bg)}), below ${need}:1${large ? ' for large text' : ''}` });
268
  }
 
277
  out.push({ kind: 'promoted', el, selector: describe(el), message: `the engine made this a ${disp.value} because a rule aligns its children without a display (it was ${natural}); dssl build will not` });
278
  }
279
  }
280
+ for (const w of shadowWarnings(eng.rules || [])) out.push({ kind: 'shadowed', el: null, selector: `${w.file || ''}:${w.line ?? ''}`, message: w.message });
281
  if (eng._reducedTold) out.push({ kind: 'motion', el: null, selector: '(page)', message: 'some animate: motions did not run because reduced motion is on; mark the ones that carry meaning `essential`, or DSSL.motion("full")' });
282
  }
283
  if (opts.log !== false && out.length) {
public/dssl/engine.js CHANGED
@@ -25,7 +25,8 @@ import { buildTexture, composeTextureInto, splitBgList as splitList } from './te
25
  import { AuraManager, resolveLayer } from './aura.js';
26
  import { parseColor } from './color.js';
27
  import { tiltTransform } from './effects.js';
28
- import { keyframesCss, animationShorthand } from './static.js';
 
29
 
30
  const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
31
 
@@ -180,6 +181,10 @@ export class DsslEngine {
180
  this.rules = [];
181
  this.shadowRules = [];
182
  this.signalStore = new Map();
 
 
 
 
183
  this.mqlCache = new Map();
184
  this.containerEls = new Set();
185
  this.ownMap = new WeakMap(); // element -> [props DSSL owns]
@@ -437,6 +442,19 @@ export class DsslEngine {
437
  this.init();
438
  this.renderShadow();
439
  this.computeAll();
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  }
441
 
442
  /** 0.12.0: fonts { } becomes <link> tags in <head>, one per URL, removed again when no sheet asks for it */
@@ -532,55 +550,7 @@ export class DsslEngine {
532
  }
533
 
534
  // --- DOM mutations ---
535
- const mo = new MutationObserver((muts) => {
536
- if (this.frozen) return; // v0.7.0 - a frozen engine sees a snapshot, not a live page
537
- if (this._applying) return; // our own style writes - ignore
538
- const batch = new Set();
539
- let fullSweep = false;
540
- for (const m of muts) {
541
- if (m.type === 'attributes') {
542
- if (m.attributeName === 'style') continue;
543
- if (m.target === document.documentElement && m.attributeName.startsWith('data-dssl')) continue;
544
- batch.add(m.target);
545
- // class/attr changes can affect descendant rules - include small subtrees.
546
- // v0.3.3: when the changed element owns a HUGE subtree (view root flips
547
- // like data-view on the site shell), don't skip the descendants - a full
548
- // debounced sweep keeps every descendant rule honest. Skipping is what
549
- // left the nav highlight stuck on the boot-time view after SPA routing.
550
- if (m.target.querySelectorAll) {
551
- const kids = m.target.querySelectorAll('*');
552
- if (kids.length <= 300) kids.forEach(k => batch.add(k));
553
- else fullSweep = true;
554
- }
555
- } else {
556
- if (m.addedNodes) m.addedNodes.forEach(n => {
557
- if (n.nodeType !== 1) return;
558
- batch.add(n);
559
- if (n.querySelectorAll) n.querySelectorAll('*').forEach(k => batch.add(k));
560
- });
561
- if (m.removedNodes) m.removedNodes.forEach(n => {
562
- if (n.nodeType !== 1) return;
563
- this.touched.delete(n);
564
- this.overlapEls.delete(n); // v0.5.0 - dropped policies must not pin the pass
565
- this.nudgedEls.delete(n);
566
- if (n.querySelectorAll) n.querySelectorAll('*').forEach(k => {
567
- this.touched.delete(k);
568
- this.overlapEls.delete(k);
569
- this.nudgedEls.delete(k);
570
- });
571
- });
572
- }
573
- }
574
- if (fullSweep) {
575
- // one full recompute per microtask burst, same drain the scheduler uses
576
- if (!this._sweepQueued) {
577
- this._sweepQueued = true;
578
- queueMicrotask(() => { this._sweepQueued = false; this.computeAll(); });
579
- }
580
- return;
581
- }
582
- if (batch.size) this.schedule(batch);
583
- });
584
  mo.observe(doc, { childList: true, subtree: true, attributes: true, attributeFilter: undefined });
585
  this._mo = mo;
586
  this.startOverlapTimer();
@@ -735,17 +705,80 @@ export class DsslEngine {
735
  els.forEach(el => this.pending.add(el));
736
  if (this.scheduled) return;
737
  this.scheduled = true;
738
- queueMicrotask(() => {
739
- this.scheduled = false;
740
- const batch = [...this.pending];
741
- this.pending.clear();
742
- this._applying = true;
743
- for (const el of batch) this.computeElement(el);
744
- this._applying = false;
745
- this.applyStagger();
746
- this.applyArc(); // v0.6.0 - bend-around arcs after every drain
747
- this.scheduleOverlap(); // v0.5.0 - nudges read fresh boxes after the writes settle
748
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
749
  }
750
 
751
  /* v0.4.1 - coalesced full recompute for ambient triggers (ResizeObserver,
@@ -756,7 +789,7 @@ export class DsslEngine {
756
  if (this.frozen) return; // v0.7.0 - frozen: no ambient recompute
757
  if (this._allQueued) return;
758
  this._allQueued = true;
759
- queueMicrotask(() => { this._allQueued = false; this.computeAll(); });
760
  }
761
 
762
  matches(el, sel) {
@@ -768,11 +801,43 @@ export class DsslEngine {
768
  return this.scope === el || this.scope.contains(el);
769
  }
770
 
771
- signalsPass(rule) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
  for (const s of rule.signals) {
773
- const has = this.signalStore.has(s.name);
774
- const val = this.signalStore.get(s.name);
775
- if (s.op === 'truthy') { if (!has || val === false || val === null || val === 'false') return false; }
776
  else if (s.op === '==') { if (!has || String(val) !== String(s.value)) return false; }
777
  else if (s.op === '!=') { if (has && String(val) === String(s.value)) return false; }
778
  else if (s.op === 'in') { if (!has || !(s.values || []).includes(String(val))) return false; } // 0.14.0
@@ -817,6 +882,7 @@ export class DsslEngine {
817
  try { found = root.querySelectorAll(sel); } catch { continue; }
818
  found.forEach(el => els.add(el));
819
  }
 
820
  for (const el of els) this.computeElement(el);
821
  this._forceCompute = false;
822
  this._applying = false;
@@ -841,6 +907,7 @@ export class DsslEngine {
841
  // a signal no longer walk it. DSSL.freeze(el) marks one and DSSL.refresh(el) restyles it on demand.
842
  if (this.staticSeen && !this._forceCompute && this.ownMap.has(el) && el.closest('[data-dssl-static]')) return;
843
  this.touched.add(el);
 
844
 
845
  const matched = [];
846
  const rootEl = this.scope || document.documentElement;
@@ -867,7 +934,7 @@ export class DsslEngine {
867
  if (!this.matches(el, sel)) continue;
868
  }
869
  if (!rule.media.every(q => this.mql(q).matches)) continue;
870
- if (!this.signalsPass(rule)) continue;
871
  if (!this.containerPass(el, rule)) continue;
872
  matched.push(rule);
873
  }
@@ -968,10 +1035,11 @@ export class DsslEngine {
968
  {
969
  let i = 0;
970
  for (const rule of matched) {
 
971
  for (const d of rule.decls) {
972
  if (d.prop !== '@animate' || d.value === null) continue;
973
  const spec = animSpecs[i++];
974
- if (spec) spec.key = rule.id + '|' + rule.media.join('&') + '|' + rule.signals.map(s => s.name + s.op + s.value).join('&');
975
  }
976
  }
977
  }
@@ -1202,6 +1270,7 @@ export class DsslEngine {
1202
  direction: spec.direction || 'normal', // v0.5.0 - `alternate`
1203
  });
1204
  if (spec.iterations === Infinity) this._hasInfiniteAnims = true;
 
1205
  anims.set(spec.key, anim);
1206
  } catch { /* WAAPI unsupported property - skip */ }
1207
  this._overlapDirty = true; // moving boxes can create (or clear) overlaps
@@ -1512,22 +1581,71 @@ export class DsslEngine {
1512
  * Signals (JS → styles bridge)
1513
  * ------------------------------------------------------------ */
1514
 
1515
- signal(name, value) {
1516
- if (value === null || value === undefined) this.signalStore.delete(name);
1517
- else this.signalStore.set(name, String(value));
 
 
 
 
 
 
 
 
 
 
 
 
1518
  // DevTools-visible mirror
1519
  try {
1520
- if (value === null || value === undefined) document.documentElement.removeAttribute('data-dssl-signal-' + name);
1521
- else document.documentElement.setAttribute('data-dssl-signal-' + name, String(value));
 
1522
  } catch { }
 
 
 
 
 
1523
  // v0.7.0 - a frozen engine has no ambient drain, but an explicit signal()
1524
  // is a developer action: drain synchronously so signals keep working
1525
  // while the observers sleep.
1526
- if (this.frozen) { this.freezeNotice(); this.computeAll(); return; }
1527
- this.scheduleComputeAll();
 
 
1528
  }
1529
 
1530
- getSignal(name) { return this.signalStore.get(name); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1531
 
1532
  /* ------------------------------------------------------------ *
1533
  * v0.7.0 - freeze / unfreeze: the speed escape hatch.
@@ -1625,7 +1743,7 @@ export class DsslEngine {
1625
  if (!this.matches(el, sel)) continue;
1626
  }
1627
  if (!rule.media.every(q => this.mql(q).matches)) continue;
1628
- if (!this.signalsPass(rule)) continue;
1629
  if (!this.containerPass(el, rule)) continue;
1630
  matched.push(rule);
1631
  }
@@ -1653,7 +1771,7 @@ export class DsslEngine {
1653
  const sel = r.sel || '*';
1654
  if (!this.matches(el, sel)) continue;
1655
  if (!r.media.every(q => this.mql(q).matches)) continue;
1656
- if (!this.signalsPass(r)) continue;
1657
  if (!this.containerPass(el, r)) continue;
1658
  pseudo.push({ sel: r.sel, pseudo: r.pseudo, layer: r.layer, file: r.file, line: r.line, decls: r.decls });
1659
  }
@@ -1683,28 +1801,22 @@ export class DsslEngine {
1683
 
1684
  const prefix = this.scope ? `[${idAttr}="${this.name}"] ` : '';
1685
 
1686
- const cssFor = (r) => {
1687
- let sel = prefix + r.sel + '::' + r.pseudo;
1688
- for (const s of r.signals) {
1689
- const attr = 'data-dssl-signal-' + s.name;
1690
- const eq = (v) => `[${attr}="${v}"]`;
1691
- const cond = s.op === 'truthy' ? `html[${attr}]`
1692
- : s.op === '!=' ? `html:not(${eq(s.value)})`
1693
- : s.op === 'in' ? `html:is(${(s.values || []).map(eq).join(', ')})`
1694
- : s.op === 'notin' ? `html:not(:is(${(s.values || []).map(eq).join(', ')}))`
1695
- : `html${eq(s.value)}`;
1696
- sel = cond + ' ' + sel;
1697
- }
1698
  let body = r.decls.filter((d) => !d.prop.startsWith('@')).map(d => ` ${d.prop}: ${d.value};`).join('\n');
1699
  // 0.14.0: animate: on ::before / ::after is a CSS animation (WAAPI cannot reach a pseudo-element). Unless the motion is
1700
  // essential it sits behind the reduced-motion check, with data-dssl-motion able to force it on or off.
1701
- const an = r.decls.find((d) => d.prop === '@animate' && d.value);
1702
- const anim = an && animationShorthand(an.value);
1703
  let extra = '';
1704
- if (anim) {
1705
- if (an.value.essential || r.signals.length) body += `${body ? '\n' : ''} animation: ${anim};`;
1706
- else {
1707
- const mk = (s) => `${s} {\n animation: ${anim};\n}`;
 
 
1708
  extra = `\n\n@media (prefers-reduced-motion: no-preference) {\n${mk('html:not([data-dssl-motion="reduced"]) ' + sel)}\n}\n\n${mk('html[data-dssl-motion="full"] ' + sel)}`;
1709
  }
1710
  }
@@ -1714,6 +1826,8 @@ export class DsslEngine {
1714
  return out;
1715
  };
1716
 
 
 
1717
  let css = this.shadowRules.map(cssFor).join('\n\n');
1718
  // 0.14.0: every motion is also a real @keyframes, so `animation: name 400ms` and its longhands (animation-delay...) work in
1719
  // plain DSSL too, not only through animate:
 
25
  import { AuraManager, resolveLayer } from './aura.js';
26
  import { parseColor } from './color.js';
27
  import { tiltTransform } from './effects.js';
28
+ import { keyframesCss, animationShorthand } from './static.js'
29
+ import { shadowWarnings } from './lint.js';
30
 
31
  const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
32
 
 
181
  this.rules = [];
182
  this.shadowRules = [];
183
  this.signalStore = new Map();
184
+ this.scopedSignals = new WeakMap(); // 0.15.0: element -> Map(name -> value) for DSSL.signal(name, value, el)
185
+ this.scopedCount = 0;
186
+ this.sigShadowIdx = []; // 0.15.0: indexes of the pseudo-element rules that sit behind a @signal
187
+ this.motionHeld = false; // 0.15.0: DSSL.pauseMotion()
188
  this.mqlCache = new Map();
189
  this.containerEls = new Set();
190
  this.ownMap = new WeakMap(); // element -> [props DSSL owns]
 
442
  this.init();
443
  this.renderShadow();
444
  this.computeAll();
445
+ this.reportShadowed(rules);
446
+ }
447
+
448
+ /** 0.15.0: say, once each, which rules can never apply because a later rule beats them everywhere (no specificity: the later one wins) */
449
+ reportShadowed(rules) {
450
+ try {
451
+ if (!this._shadowTold) this._shadowTold = new Set();
452
+ const fresh = shadowWarnings(rules).filter((w) => { const k = w.file + ':' + w.line + '|' + w.message; if (this._shadowTold.has(k)) return false; this._shadowTold.add(k); return true; });
453
+ if (!fresh.length) return;
454
+ console.groupCollapsed(`%c[DSSL] ${fresh.length} rule(s) can never apply`, 'color:#8a6d1a;font-weight:bold');
455
+ for (const w of fresh) console.warn(`${w.file || ''}:${w.line ?? ''} - ${w.message}\n ${w.hint}`);
456
+ console.groupEnd();
457
+ } catch { /* a lint must never break the page */ }
458
  }
459
 
460
  /** 0.12.0: fonts { } becomes <link> tags in <head>, one per URL, removed again when no sheet asks for it */
 
550
  }
551
 
552
  // --- DOM mutations ---
553
+ const mo = new MutationObserver((muts) => this.onMutations(muts));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  mo.observe(doc, { childList: true, subtree: true, attributes: true, attributeFilter: undefined });
555
  this._mo = mo;
556
  this.startOverlapTimer();
 
705
  els.forEach(el => this.pending.add(el));
706
  if (this.scheduled) return;
707
  this.scheduled = true;
708
+ queueMicrotask(() => { this.scheduled = false; this.drainPending(); });
709
+ }
710
+
711
+ drainPending() {
712
+ if (!this.pending.size) return;
713
+ const batch = [...this.pending];
714
+ this.pending.clear();
715
+ this._applying = true;
716
+ for (const el of batch) this.computeElement(el);
717
+ this._applying = false;
718
+ this.applyStagger();
719
+ this.applyArc(); // v0.6.0 - bend-around arcs after every drain
720
+ this.scheduleOverlap(); // v0.5.0 - nudges read fresh boxes after the writes settle
721
+ }
722
+
723
+ /** 0.15.0: do now what the engine has queued for the next microtask: DOM changes the observer has not delivered, restyles, signals.
724
+ * After this, computed styles and layout are final. DSSL.settled() is the awaitable version. */
725
+ flush() {
726
+ if (this.frozen) return;
727
+ if (this._mo) { const recs = this._mo.takeRecords(); if (recs.length) this.onMutations(recs); }
728
+ if (this._sweepQueued || this._allQueued) { this._sweepQueued = false; this._allQueued = false; this._sigNames = null; this._sigScopes = null; this._sigGlobal = false; this.computeAll(); }
729
+ else this.flushSignals();
730
+ this.drainPending();
731
+ }
732
+
733
+ /** what the page changed (the MutationObserver's work, also run by flush()) */
734
+ onMutations(muts) {
735
+ if (this.frozen) return; // v0.7.0 - a frozen engine sees a snapshot, not a live page
736
+ if (this._applying) return; // our own style writes - ignore
737
+ const batch = new Set();
738
+ let fullSweep = false;
739
+ for (const m of muts) {
740
+ if (m.type === 'attributes') {
741
+ if (m.attributeName === 'style' || m.attributeName === 'data-dssl-s') continue;
742
+ if (m.target === document.documentElement && m.attributeName.startsWith('data-dssl')) continue;
743
+ batch.add(m.target);
744
+ // class/attr changes can affect descendant rules - include small subtrees.
745
+ // v0.3.3: when the changed element owns a HUGE subtree (view root flips
746
+ // like data-view on the site shell), don't skip the descendants - a full
747
+ // debounced sweep keeps every descendant rule honest. Skipping is what
748
+ // left the nav highlight stuck on the boot-time view after SPA routing.
749
+ if (m.target.querySelectorAll) {
750
+ const kids = m.target.querySelectorAll('*');
751
+ if (kids.length <= 300) kids.forEach(k => batch.add(k));
752
+ else fullSweep = true;
753
+ }
754
+ } else {
755
+ if (m.addedNodes) m.addedNodes.forEach(n => {
756
+ if (n.nodeType !== 1) return;
757
+ batch.add(n);
758
+ if (n.querySelectorAll) n.querySelectorAll('*').forEach(k => batch.add(k));
759
+ });
760
+ if (m.removedNodes) m.removedNodes.forEach(n => {
761
+ if (n.nodeType !== 1) return;
762
+ this.touched.delete(n);
763
+ this.overlapEls.delete(n); // v0.5.0 - dropped policies must not pin the pass
764
+ this.nudgedEls.delete(n);
765
+ if (n.querySelectorAll) n.querySelectorAll('*').forEach(k => {
766
+ this.touched.delete(k);
767
+ this.overlapEls.delete(k);
768
+ this.nudgedEls.delete(k);
769
+ });
770
+ });
771
+ }
772
+ }
773
+ if (fullSweep) {
774
+ // one full recompute per microtask burst, same drain the scheduler uses
775
+ if (!this._sweepQueued) {
776
+ this._sweepQueued = true;
777
+ queueMicrotask(() => { if (!this._sweepQueued) return; this._sweepQueued = false; this.computeAll(); });
778
+ }
779
+ return;
780
+ }
781
+ if (batch.size) this.schedule(batch);
782
  }
783
 
784
  /* v0.4.1 - coalesced full recompute for ambient triggers (ResizeObserver,
 
789
  if (this.frozen) return; // v0.7.0 - frozen: no ambient recompute
790
  if (this._allQueued) return;
791
  this._allQueued = true;
792
+ queueMicrotask(() => { if (!this._allQueued) return; this._allQueued = false; this.computeAll(); });
793
  }
794
 
795
  matches(el, sel) {
 
801
  return this.scope === el || this.scope.contains(el);
802
  }
803
 
804
+ /** the elements a signal-gated pseudo-element rule is about: its selector without the states (hover...) it also needs */
805
+ sigShadowBase(r) {
806
+ if (r._base === undefined) r._base = (r.sel || '*').replace(STATE_PSEUDO_RE, '').trim() || '*';
807
+ return r._base;
808
+ }
809
+
810
+ /** keep data-dssl-s on an element: the numbers of the signal-gated pseudo-element rules that are on for it right now */
811
+ markSignalShadow(el) {
812
+ let on = '';
813
+ for (const idx of this.sigShadowIdx) {
814
+ const r = this.shadowRules[idx];
815
+ if (!r || !this.matches(el, this.sigShadowBase(r))) continue;
816
+ if (!this.signalsPass(r, el)) continue;
817
+ on += (on ? ' ' : '') + idx;
818
+ }
819
+ const cur = el.getAttribute('data-dssl-s');
820
+ try {
821
+ if (on) { if (cur !== on) el.setAttribute('data-dssl-s', on); }
822
+ else if (cur !== null) el.removeAttribute('data-dssl-s');
823
+ } catch { /* detached */ }
824
+ }
825
+
826
+ /** a signal's value for an element: the nearest ancestor (or itself) that was given its own (DSSL.signal(name, value, el)), else the global one */
827
+ sigLookup(el, name) {
828
+ if (this.scopedCount && el) {
829
+ for (let a = el; a; a = a.parentElement) {
830
+ const m = this.scopedSignals.get(a);
831
+ if (m && m.has(name)) return { has: true, val: m.get(name) };
832
+ }
833
+ }
834
+ return this.signalStore.has(name) ? { has: true, val: this.signalStore.get(name) } : { has: false, val: undefined };
835
+ }
836
+
837
+ signalsPass(rule, el) {
838
  for (const s of rule.signals) {
839
+ const { has, val } = this.sigLookup(el, s.name);
840
+ if (s.op === 'truthy') { if (!has || val === false || val === null || val === 'false' || val === 0 || val === '0') return false; }
 
841
  else if (s.op === '==') { if (!has || String(val) !== String(s.value)) return false; }
842
  else if (s.op === '!=') { if (has && String(val) === String(s.value)) return false; }
843
  else if (s.op === 'in') { if (!has || !(s.values || []).includes(String(val))) return false; } // 0.14.0
 
882
  try { found = root.querySelectorAll(sel); } catch { continue; }
883
  found.forEach(el => els.add(el));
884
  }
885
+ for (const idx of this.sigShadowIdx) { try { root.querySelectorAll(this.sigShadowBase(this.shadowRules[idx])).forEach((el) => els.add(el)); } catch { /* bad selector */ } }
886
  for (const el of els) this.computeElement(el);
887
  this._forceCompute = false;
888
  this._applying = false;
 
907
  // a signal no longer walk it. DSSL.freeze(el) marks one and DSSL.refresh(el) restyles it on demand.
908
  if (this.staticSeen && !this._forceCompute && this.ownMap.has(el) && el.closest('[data-dssl-static]')) return;
909
  this.touched.add(el);
910
+ if (this.sigShadowIdx.length) this.markSignalShadow(el);
911
 
912
  const matched = [];
913
  const rootEl = this.scope || document.documentElement;
 
934
  if (!this.matches(el, sel)) continue;
935
  }
936
  if (!rule.media.every(q => this.mql(q).matches)) continue;
937
+ if (!this.signalsPass(rule, el)) continue;
938
  if (!this.containerPass(el, rule)) continue;
939
  matched.push(rule);
940
  }
 
1035
  {
1036
  let i = 0;
1037
  for (const rule of matched) {
1038
+ let nth = 0; // 0.15.0: several motions in one rule (animate: a 300, b 500) each need their own key
1039
  for (const d of rule.decls) {
1040
  if (d.prop !== '@animate' || d.value === null) continue;
1041
  const spec = animSpecs[i++];
1042
+ if (spec) spec.key = rule.id + '|' + rule.media.join('&') + '|' + rule.signals.map(s => s.name + s.op + s.value).join('&') + '#' + (nth++);
1043
  }
1044
  }
1045
  }
 
1270
  direction: spec.direction || 'normal', // v0.5.0 - `alternate`
1271
  });
1272
  if (spec.iterations === Infinity) this._hasInfiniteAnims = true;
1273
+ if (this.motionHeld) { try { anim.pause(); } catch { /* ignore */ } } // DSSL.pauseMotion(): new motions start held too
1274
  anims.set(spec.key, anim);
1275
  } catch { /* WAAPI unsupported property - skip */ }
1276
  this._overlapDirty = true; // moving boxes can create (or clear) overlaps
 
1581
  * Signals (JS → styles bridge)
1582
  * ------------------------------------------------------------ */
1583
 
1584
+ /** DSSL.signal(name, value) sets it for the whole page; DSSL.signal(name, value, el) only for el and what is inside it (the
1585
+ * nearest one wins). Booleans and numbers stay booleans and numbers for getSignal(); the data-dssl-signal-* mirror is text. */
1586
+ signal(name, value, scope) {
1587
+ const off = value === null || value === undefined;
1588
+ const target = scope && scope.nodeType === 1 ? scope : null;
1589
+ if (target) {
1590
+ let m = this.scopedSignals.get(target);
1591
+ if (off) { if (m && m.delete(name)) { this.scopedCount--; if (!m.size) this.scopedSignals.delete(target); } }
1592
+ else {
1593
+ if (!m) { m = new Map(); this.scopedSignals.set(target, m); }
1594
+ if (!m.has(name)) this.scopedCount++;
1595
+ m.set(name, value);
1596
+ }
1597
+ } else if (off) this.signalStore.delete(name);
1598
+ else this.signalStore.set(name, value);
1599
  // DevTools-visible mirror
1600
  try {
1601
+ const host = target || document.documentElement;
1602
+ if (off) host.removeAttribute('data-dssl-signal-' + name);
1603
+ else host.setAttribute('data-dssl-signal-' + name, String(value));
1604
  } catch { }
1605
+ // 0.15.0: only the rules that mention this signal are looked at, and only the elements they select (inside the scope, if given),
1606
+ // not the whole page. A burst of signals in one task is one pass.
1607
+ if (!this._sigNames) this._sigNames = new Set();
1608
+ this._sigNames.add(name);
1609
+ if (target) { if (!this._sigScopes) this._sigScopes = new Set(); this._sigScopes.add(target); } else this._sigGlobal = true;
1610
  // v0.7.0 - a frozen engine has no ambient drain, but an explicit signal()
1611
  // is a developer action: drain synchronously so signals keep working
1612
  // while the observers sleep.
1613
+ if (this.frozen) { this.freezeNotice(); this.flushSignals(); return; }
1614
+ if (this._sigQueued) return;
1615
+ this._sigQueued = true;
1616
+ queueMicrotask(() => { this._sigQueued = false; this.flushSignals(); });
1617
  }
1618
 
1619
+ flushSignals() {
1620
+ const names = this._sigNames;
1621
+ if (!names || !names.size) return;
1622
+ const scopes = this._sigScopes, global = this._sigGlobal;
1623
+ this._sigNames = null; this._sigScopes = null; this._sigGlobal = false;
1624
+ const rules = this.rules.filter((r) => r.signals.some((x) => names.has(x.name)));
1625
+ const shadows = this.sigShadowIdx.map((i) => this.shadowRules[i]).filter((r) => r.signals.some((x) => names.has(x.name)));
1626
+ if (!rules.length && !shadows.length) return; // nothing is styled by this signal
1627
+ const rootEl = this.scope || document.documentElement;
1628
+ const root = this.scope || document;
1629
+ const els = new Set();
1630
+ for (const rule of rules) {
1631
+ if (rule.sel === ':root') { els.add(rootEl); continue; }
1632
+ let sel = rule.sel;
1633
+ if (rule.states.has('seen')) sel = sel.replace(/:seen\b/g, '') || '*';
1634
+ let found;
1635
+ try { found = root.querySelectorAll(sel); } catch { continue; }
1636
+ found.forEach((el) => els.add(el));
1637
+ }
1638
+ for (const r of shadows) { try { root.querySelectorAll(this.sigShadowBase(r)).forEach((el) => els.add(el)); } catch { /* bad selector */ } }
1639
+ const inside = (el) => { if (global) return true; for (const sc of scopes) if (sc === el || sc.contains(el)) return true; return false; };
1640
+ this._applying = true;
1641
+ try { for (const el of els) if (inside(el)) this.computeElement(el); }
1642
+ finally { this._applying = false; }
1643
+ this.applyStagger();
1644
+ this.applyArc();
1645
+ this.scheduleOverlap();
1646
+ }
1647
+
1648
+ getSignal(name, el) { return this.sigLookup(el, name).val; }
1649
 
1650
  /* ------------------------------------------------------------ *
1651
  * v0.7.0 - freeze / unfreeze: the speed escape hatch.
 
1743
  if (!this.matches(el, sel)) continue;
1744
  }
1745
  if (!rule.media.every(q => this.mql(q).matches)) continue;
1746
+ if (!this.signalsPass(rule, el)) continue;
1747
  if (!this.containerPass(el, rule)) continue;
1748
  matched.push(rule);
1749
  }
 
1771
  const sel = r.sel || '*';
1772
  if (!this.matches(el, sel)) continue;
1773
  if (!r.media.every(q => this.mql(q).matches)) continue;
1774
+ if (!this.signalsPass(r, el)) continue;
1775
  if (!this.containerPass(el, r)) continue;
1776
  pseudo.push({ sel: r.sel, pseudo: r.pseudo, layer: r.layer, file: r.file, line: r.line, decls: r.decls });
1777
  }
 
1801
 
1802
  const prefix = this.scope ? `[${idAttr}="${this.name}"] ` : '';
1803
 
1804
+ const cssFor = (r, idx) => {
1805
+ // 0.15.0: a pseudo-element rule behind a @signal is switched by a marker the engine keeps on the element (data-dssl-s lists the rule
1806
+ // numbers that are on for it). The engine already knows which signal value an element sees (the nearest scope wins), which CSS cannot say.
1807
+ let sel = prefix + r.sel + (r.signals.length ? `[data-dssl-s~="${idx}"]` : '') + '::' + r.pseudo;
 
 
 
 
 
 
 
 
1808
  let body = r.decls.filter((d) => !d.prop.startsWith('@')).map(d => ` ${d.prop}: ${d.value};`).join('\n');
1809
  // 0.14.0: animate: on ::before / ::after is a CSS animation (WAAPI cannot reach a pseudo-element). Unless the motion is
1810
  // essential it sits behind the reduced-motion check, with data-dssl-motion able to force it on or off.
1811
+ const ans = r.decls.filter((d) => d.prop === '@animate' && d.value && animationShorthand(d.value));
1812
+ const list = (xs) => xs.map((d) => animationShorthand(d.value)).join(', ');
1813
  let extra = '';
1814
+ if (ans.length) {
1815
+ // the essential ones always run (so does everything behind a signal); the rest wait for the reduced-motion check
1816
+ const always = r.signals.length ? ans : ans.filter((d) => d.value.essential);
1817
+ if (always.length) body += `${body ? '\n' : ''} animation: ${list(always)};`;
1818
+ if (always.length < ans.length) {
1819
+ const mk = (s) => `${s} {\n animation: ${list(ans)};\n}`;
1820
  extra = `\n\n@media (prefers-reduced-motion: no-preference) {\n${mk('html:not([data-dssl-motion="reduced"]) ' + sel)}\n}\n\n${mk('html[data-dssl-motion="full"] ' + sel)}`;
1821
  }
1822
  }
 
1826
  return out;
1827
  };
1828
 
1829
+ this.sigShadowIdx = [];
1830
+ this.shadowRules.forEach((r, i) => { if (r.signals.length) this.sigShadowIdx.push(i); });
1831
  let css = this.shadowRules.map(cssFor).join('\n\n');
1832
  // 0.14.0: every motion is also a real @keyframes, so `animation: name 400ms` and its longhands (animation-delay...) work in
1833
  // plain DSSL too, not only through animate:
public/dssl/index.js CHANGED
@@ -19,7 +19,7 @@ import { resolve, DEFAULT_TOKENS, EASINGS } from './resolver.js';
19
  import { toCSS as docToCSS } from './static.js';
20
  import { DsslEngine } from './engine.js';
21
 
22
- export const VERSION = '0.14.0';
23
 
24
  let counter = 0;
25
  const loadedUrls = new Set(); // every .dssl file fetched by load(), so DSSL.watch() knows what to follow
@@ -173,8 +173,15 @@ export function toCSS(input, opts = {}) {
173
  return { ...built, ok: errors.length === 0, errors, warnings };
174
  }
175
 
176
- export function signal(name, value) { for (const e of engines.values()) e.signal(name, value); }
177
- export function getSignal(name) { return engines.get('global')?.getSignal(name); }
 
 
 
 
 
 
 
178
  /**
179
  * DSSL.play(el, 'boing', { duration, delay, easing, iterations, hold, essential, jitter }) runs a motion once from JavaScript and
180
  * returns a promise for the animation, which settles when it ends. `hold: true` keeps the final frame instead of snapping back
@@ -199,11 +206,36 @@ export function play(el, name, opts = {}) {
199
  const jit = opts.jitter ? Math.round(Math.random() * ms(opts.jitter, 0)) : 0;
200
  let anim;
201
  try {
202
- anim = el.animate(frames, { duration: ms(opts.duration, 400), delay: ms(opts.delay, 0) + jit, easing, iterations: opts.iterations === 'infinite' ? Infinity : (opts.iterations || 1), direction: opts.direction || 'normal', fill: opts.hold ? 'forwards' : (opts.fill || 'none') });
 
203
  } catch (e) { return Promise.reject(e); }
 
204
  return anim.finished.then(() => anim, () => anim); // a cancelled animation settles too, so awaiting it never hangs
205
  }
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  /** DSSL.auraZ(20): the z-index of the one canvas aura draws on (default: above everything). Menus and overlays with a higher z-index then cover it. */
208
  export function auraZ(z) {
209
  const root = document.documentElement;
@@ -317,7 +349,7 @@ export async function watch(o = {}) {
317
  }
318
 
319
  export const DSSL = {
320
- version: VERSION, compile, apply, load, toCSS, signal, getSignal, refresh, replay, errors,
321
  freeze, unfreeze, explain, inspect, watch, play, motion, auraZ, lint,
322
  __debug: { engines },
323
  };
 
19
  import { toCSS as docToCSS } from './static.js';
20
  import { DsslEngine } from './engine.js';
21
 
22
+ export const VERSION = '0.15.0';
23
 
24
  let counter = 0;
25
  const loadedUrls = new Set(); // every .dssl file fetched by load(), so DSSL.watch() knows what to follow
 
173
  return { ...built, ok: errors.length === 0, errors, warnings };
174
  }
175
 
176
+ /** DSSL.signal('screen', 'menu') for the whole page; DSSL.signal('hp', 2, el) only for el and what is inside it (the nearest wins).
177
+ * Booleans and numbers are kept as they are: DSSL.getSignal('toasting') is true, not "true". */
178
+ export function signal(name, value, el) { for (const e of engines.values()) e.signal(name, value, el); }
179
+ export function getSignal(name, el) { return engines.get('global')?.getSignal(name, el); }
180
+ /** DSSL.flush() styles everything that is waiting, right now, so that computed styles and layout (offsetTop...) are final on the next line.
181
+ * Styling normally happens a moment after a change, in a microtask. */
182
+ export function flush() { for (const e of engines.values()) e.flush(); }
183
+ /** await DSSL.settled(): the same, for code that can wait. It also lets anything the flush itself queued finish. */
184
+ export async function settled() { flush(); await Promise.resolve(); flush(); }
185
  /**
186
  * DSSL.play(el, 'boing', { duration, delay, easing, iterations, hold, essential, jitter }) runs a motion once from JavaScript and
187
  * returns a promise for the animation, which settles when it ends. `hold: true` keeps the final frame instead of snapping back
 
206
  const jit = opts.jitter ? Math.round(Math.random() * ms(opts.jitter, 0)) : 0;
207
  let anim;
208
  try {
209
+ const pseudoElement = opts.pseudo ? (String(opts.pseudo).startsWith(':') ? String(opts.pseudo) : '::' + opts.pseudo) : undefined; // 0.15.0: DSSL.play(el, 'pop', { pseudo: '::after' })
210
+ anim = el.animate(frames, { ...(pseudoElement ? { pseudoElement } : {}), duration: ms(opts.duration, 400), delay: ms(opts.delay, 0) + jit, easing, iterations: opts.iterations === 'infinite' ? Infinity : (opts.iterations || 1), direction: opts.direction || 'normal', fill: opts.hold ? 'forwards' : (opts.fill || 'none') });
211
  } catch (e) { return Promise.reject(e); }
212
+ if (eng && eng.motionHeld) { try { anim.pause(); } catch { /* ignore */ } }
213
  return anim.finished.then(() => anim, () => anim); // a cancelled animation settles too, so awaiting it never hangs
214
  }
215
 
216
+ /**
217
+ * For tests and for stepping a game one tick at a time. DSSL.pauseMotion() freezes every animation on the page (and every one DSSL
218
+ * starts afterwards); DSSL.stepMotion(100) moves them all forward by 100 ms, whatever the clock says; DSSL.resumeMotion() lets them run.
219
+ * DSSL.computed(el, 'translate') is the final computed value right now, with nothing left waiting to be styled.
220
+ */
221
+ export function pauseMotion() { for (const e of engines.values()) e.motionHeld = true; flush(); let n = 0; for (const a of document.getAnimations()) { try { a.pause(); n++; } catch { /* ignore */ } } return n; }
222
+ export function stepMotion(ms = 16.7) {
223
+ const n = pauseMotion();
224
+ for (const a of document.getAnimations()) {
225
+ try {
226
+ const t = Number(a.currentTime) || 0;
227
+ a.currentTime = t + ms;
228
+ } catch { /* ignore */ }
229
+ }
230
+ return n;
231
+ }
232
+ export function resumeMotion() { for (const e of engines.values()) e.motionHeld = false; let n = 0; for (const a of document.getAnimations()) { try { if (a.playState === 'paused') { a.play(); n++; } } catch { /* ignore */ } } return n; }
233
+ export function computed(el, prop, pseudo) {
234
+ flush();
235
+ const cs = getComputedStyle(el, pseudo || null);
236
+ return prop.includes('-') ? cs.getPropertyValue(prop) : (cs[prop] !== undefined ? cs[prop] : cs.getPropertyValue(prop));
237
+ }
238
+
239
  /** DSSL.auraZ(20): the z-index of the one canvas aura draws on (default: above everything). Menus and overlays with a higher z-index then cover it. */
240
  export function auraZ(z) {
241
  const root = document.documentElement;
 
349
  }
350
 
351
  export const DSSL = {
352
+ version: VERSION, compile, apply, load, toCSS, signal, getSignal, flush, settled, computed, pauseMotion, stepMotion, resumeMotion, refresh, replay, errors,
353
  freeze, unfreeze, explain, inspect, watch, play, motion, auraZ, lint,
354
  __debug: { engines },
355
  };
public/dssl/lint.js CHANGED
@@ -163,3 +163,110 @@ export function lintRules({ rules, tokenMap, file }) {
163
  }
164
  return warnings;
165
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  }
164
  return warnings;
165
  }
166
+
167
+ /* ------------------------------------------------------------------ *
168
+ * rules that can never win (0.15.0)
169
+ * ------------------------------------------------------------------ */
170
+
171
+ const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
172
+
173
+ /** split a selector at top-level spaces and > + ~ (outside brackets and parentheses); null when it holds a top-level comma */
174
+ function selectorParts(sel) {
175
+ const parts = []; let depth = 0, cur = '', q = null;
176
+ for (const ch of String(sel).trim()) {
177
+ if (q) { cur += ch; if (ch === q) q = null; continue; }
178
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
179
+ if (ch === '(' || ch === '[') depth++;
180
+ if (ch === ')' || ch === ']') depth--;
181
+ if (depth === 0 && ch === ',') return null;
182
+ if (depth === 0 && (ch === ' ' || ch === '>' || ch === '+' || ch === '~')) { if (cur) { parts.push(cur); cur = ''; } parts.push(ch === ' ' ? ' ' : ch); continue; }
183
+ cur += ch;
184
+ }
185
+ if (cur) parts.push(cur);
186
+ return parts;
187
+ }
188
+
189
+ /** the simple selectors of one compound: `div.a#b[x]:not(.c)` is div, .a, #b, [x], :not(.c) */
190
+ function simpleSelectors(compound) {
191
+ const out = []; let i = 0; const n = compound.length;
192
+ const take = (start) => {
193
+ let j = start + 1, depth = 0;
194
+ while (j < n) {
195
+ const c = compound[j];
196
+ if (c === '(' || c === '[') depth++;
197
+ else if (c === ')' || c === ']') depth--;
198
+ else if (depth === 0 && /[.#:\[]/.test(c)) break;
199
+ j++;
200
+ }
201
+ return j;
202
+ };
203
+ if (/^[a-zA-Z*]/.test(compound)) { let j = 0; while (j < n && /[\w*-]/.test(compound[j])) j++; out.push(compound.slice(0, j).toLowerCase()); i = j; }
204
+ while (i < n) {
205
+ const j = compound[i] === ':' && compound[i + 1] === ':' ? take(i + 1) : take(i);
206
+ out.push(compound.slice(i, j)); i = j;
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * A rule whose every declaration of a property is beaten by a LATER rule that matches everything it matches. There is no specificity
213
+ * in DSSL, so `.prop.broc { z-index: 1 }` in one file loses to a plain `.prop { z-index: 2 }` in a file that comes after it, and the
214
+ * line does nothing at all. (The very same selector again is left alone: that is how a later file overrides an earlier one.) Both rules must be unconditional alike (same @media, @signal, container, state) and the later one must be
215
+ * in the same layer or a higher one. Pure; rules from several files must already carry one global `order`.
216
+ * @returns {Array<{a:{sel,file,line,props:string[]}, b:{sel,file,line}}>}
217
+ */
218
+ export function shadowedRules(rules) {
219
+ const info = [];
220
+ for (const r of rules) {
221
+ if (!r || r.pseudo || !r.sel || !r.decls || !r.decls.length) continue;
222
+ const parts = selectorParts(r.sel);
223
+ if (!parts || !parts.length) continue;
224
+ const comp = parts[parts.length - 1];
225
+ if (/^[ >+~]$/.test(comp)) continue;
226
+ if (/^:root$|::/.test(r.sel)) continue;
227
+ const toks = simpleSelectors(comp);
228
+ if (!toks.length) continue;
229
+ const cond = JSON.stringify([r.media || [], (r.signals || []).map((s) => [s.name, s.op, s.value, s.values]), r.containers || [], [...(r.states || [])].sort()]);
230
+ info.push({ idx: info.length, r, toks, set: new Set(toks), single: parts.length === 1, cond, rank: LAYER_RANK[r.layer] ?? 2, order: r.order ?? 0 });
231
+ }
232
+ // the later rules, found by the first simple selector they need
233
+ const byTok = new Map();
234
+ for (const b of info) if (b.single) { const k = b.toks[0]; if (!byTok.has(k)) byTok.set(k, []); byTok.get(k).push(b); }
235
+ const pairs = new Map();
236
+ for (const a of info) {
237
+ const seenProps = new Set();
238
+ const cands = [];
239
+ for (const t of a.toks) for (const b of byTok.get(t) || []) cands.push(b);
240
+ if (byTok.has('*')) for (const b of byTok.get('*')) cands.push(b);
241
+ cands.sort((x, y) => x.order - y.order);
242
+ for (const b of cands) {
243
+ if (b === a || b.cond !== a.cond) continue;
244
+ if (b.r.sel.replace(/\s+/g, ' ').trim() === a.r.sel.replace(/\s+/g, ' ').trim()) continue; // the same selector again is an override on purpose (a theme file, a later tweak)
245
+ if (b.rank < a.rank || (b.rank === a.rank && b.order <= a.order)) continue;
246
+ if (b.toks.some((t) => t !== '*' && !a.set.has(t))) continue;
247
+ const bProps = new Map();
248
+ for (const d of b.r.decls) if (d.prop && !String(d.prop).startsWith('@')) bProps.set(d.prop, d.value);
249
+ const hit = [];
250
+ for (const d of a.r.decls) {
251
+ const p = d.prop;
252
+ if (!p || String(p).startsWith('@') || seenProps.has(p) || !bProps.has(p)) continue;
253
+ if (String(bProps.get(p)) === String(d.value)) continue;
254
+ seenProps.add(p); hit.push(p);
255
+ }
256
+ if (hit.length) {
257
+ const key = a.idx + '>' + b.idx;
258
+ pairs.set(key, { a: { sel: a.r.sel, file: a.r.file, line: (a.r.decls.find((d) => d.prop === hit[0]) || a.r).line ?? a.r.line, props: hit }, b: { sel: b.r.sel, file: b.r.file, line: b.r.line } });
259
+ }
260
+ }
261
+ }
262
+ return [...pairs.values()];
263
+ }
264
+
265
+ /** the same, as compiler warnings */
266
+ export function shadowWarnings(rules) {
267
+ return shadowedRules(rules).map((p) => ({
268
+ file: p.a.file, line: p.a.line,
269
+ message: `${p.a.props.join(', ')} in "${p.a.sel}" can never apply: "${p.b.sel}" (${p.b.file || ''}${p.b.file ? ':' : ''}${p.b.line}) comes later, matches every element this rule matches, and sets ${p.a.props.length > 1 ? 'the same properties' : 'the same property'}`,
270
+ hint: 'there is no specificity in DSSL, so the later rule always wins. Move the more specific rule below the general one, or remove the line from the general one',
271
+ }));
272
+ }
public/dssl/resolver.js CHANGED
@@ -710,6 +710,38 @@ const DEFORM_TO_TRANSFORM = {
710
  fold: 'transform', stretch: 'transform',
711
  };
712
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  const SEMANTIC = {
714
  size(v, ctx) {
715
  const parts = v.split(/\s+/).filter(Boolean);
@@ -795,6 +827,14 @@ const SEMANTIC = {
795
  const out = [];
796
  const slash = v.split('/');
797
  const head = slash[0].trim().split(/\s+/).filter(Boolean);
 
 
 
 
 
 
 
 
798
  out.push({ prop: 'font-size', value: len(head[0] || '16', ctx) });
799
  if (slash[1]) {
800
  const tail = slash[1].trim().split(/\s+/).filter(Boolean);
@@ -928,34 +968,14 @@ const SEMANTIC = {
928
  animate(v, ctx) {
929
  const t = v.trim();
930
  if (t === 'none') return [{ prop: '@animate', value: null }];
931
- const atoms = t.split(/\s+/);
932
- const spec = { motion: atoms[0], duration: 400, easing: EASINGS.smooth, fill: 'both', delay: 0 };
933
- const toMs = (a) => (a.endsWith('s') && !a.endsWith('ms') ? parseFloat(a) * 1000 : parseFloat(a));
934
- for (let k = 1; k < atoms.length; k++) {
935
- const a = atoms[k];
936
- // 0.14.0: the words that used to be CSS longhands which did nothing here
937
- if (a === 'delay' || a === 'jitter' || a === 'iterations') {
938
- const v = atoms[k + 1];
939
- if (v === undefined || !/^\d+(\.\d+)?(ms|s)?$/.test(v)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `${a} needs a number`, hint: a === 'iterations' ? 'animate: boing 300 iterations 3' : `animate: bob 1200 infinite ${a} 600` }); continue; }
940
- if (a === 'iterations') spec.iterations = parseFloat(v); else spec[a] = toMs(v);
941
- k++;
942
- continue;
943
- }
944
- if (a === 'essential') { spec.essentialHere = true; continue; }
945
- if (/^\d+(ms|s)?$/.test(a)) {
946
- const ms = toMs(a);
947
- if (k === 1) spec.duration = ms; else spec.delay = ms;
948
- continue;
949
- }
950
- if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
951
- if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
952
- // v0.5.0 - looping: `infinite` runs forever, `alternate` ping-pongs.
953
- // animate: pulse 1600 infinite alternate - was impossible before.
954
- if (a === 'infinite') { spec.iterations = Infinity; continue; }
955
- if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
956
- ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate', 'delay', 'jitter', 'iterations', 'essential'])) });
957
  }
958
- return [{ prop: '@animate', value: spec }];
959
  },
960
  // v0.4.0 - choreography: a parent with `stagger: 60` offsets each element
961
  // child's animation delay by its DOM position. Pairs with `seen` + `animate`
@@ -1071,6 +1091,32 @@ const SEMANTIC = {
1071
  if (!inside) out.push({ prop: 'paint-order', value: 'stroke fill' });
1072
  return out;
1073
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
  'gradient-text'(v, ctx) {
1075
  const img = buildGradient(v, ctx);
1076
  if (!img) return [];
@@ -1622,23 +1668,37 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null, baseMotion
1622
  media: ctx.media, signals: ctx.signals, containers: ctx.containers,
1623
  layer: ctx.layer, order: order++, decls: [], file, line: node.line,
1624
  };
1625
- for (const d of node.children) {
1626
- if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1627
- if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1628
- const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1629
- // v0.5.0 - engine-native properties have no pseudo-element story:
1630
- // there is no DOM handle to nudge, animate, stack or stagger. They
1631
- // used to leak into the shadow sheet as junk CSS the browser
1632
- // silently dropped - exactly the silence this language refuses.
1633
- for (const r of resolved) {
1634
- if (r.prop.startsWith('@') && r.prop !== '@animate') { // 0.14.0: animate: becomes a CSS animation on a pseudo-element
1635
- errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo block' });
1636
  continue;
1637
  }
1638
- srule.decls.push({ ...r, line: d.line });
 
 
 
 
 
 
 
 
 
 
 
 
 
1639
  }
1640
- }
 
 
1641
  if (srule.decls.length) shadowRules.push(srule);
 
1642
  break;
1643
  }
1644
  case 'tokens': {
 
710
  fold: 'transform', stretch: 'transform',
711
  };
712
 
713
+ /** one motion of an animate: line: `bob 1200 infinite alternate delay 100 jitter 600 essential` */
714
+ function animateOne(t, ctx) {
715
+ const atoms = t.split(/\s+/);
716
+ const spec = { motion: atoms[0], duration: 400, easing: EASINGS.smooth, fill: 'both', delay: 0 };
717
+ const toMs = (a) => (a.endsWith('s') && !a.endsWith('ms') ? parseFloat(a) * 1000 : parseFloat(a));
718
+ for (let k = 1; k < atoms.length; k++) {
719
+ const a = atoms[k];
720
+ // 0.14.0: the words that used to be CSS longhands which did nothing here
721
+ if (a === 'delay' || a === 'jitter' || a === 'iterations') {
722
+ const v = atoms[k + 1];
723
+ if (v === undefined || !/^\d+(\.\d+)?(ms|s)?$/.test(v)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `${a} needs a number`, hint: a === 'iterations' ? 'animate: boing 300 iterations 3' : `animate: bob 1200 infinite ${a} 600` }); continue; }
724
+ if (a === 'iterations') spec.iterations = parseFloat(v); else spec[a] = toMs(v);
725
+ k++;
726
+ continue;
727
+ }
728
+ if (a === 'essential') { spec.essentialHere = true; continue; }
729
+ if (/^\d+(ms|s)?$/.test(a)) {
730
+ const ms = toMs(a);
731
+ if (k === 1) spec.duration = ms; else spec.delay = ms;
732
+ continue;
733
+ }
734
+ if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
735
+ if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
736
+ // v0.5.0 - looping: `infinite` runs forever, `alternate` ping-pongs.
737
+ // animate: pulse 1600 infinite alternate - was impossible before.
738
+ if (a === 'infinite') { spec.iterations = Infinity; continue; }
739
+ if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
740
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate', 'delay', 'jitter', 'iterations', 'essential'])) });
741
+ }
742
+ return [{ prop: '@animate', value: spec }];
743
+ }
744
+
745
  const SEMANTIC = {
746
  size(v, ctx) {
747
  const parts = v.split(/\s+/).filter(Boolean);
 
827
  const out = [];
828
  const slash = v.split('/');
829
  const head = slash[0].trim().split(/\s+/).filter(Boolean);
830
+ // 0.15.0: DSSL's font: is size/line-height and a colour, NOT the CSS shorthand (weight, style, family). A CSS-looking value used to
831
+ // become a 700px font; say what it is instead.
832
+ const sized = (x) => /^\d*\.?\d+(px|em|rem|%|pt|vw|vh|ch)$/.test(x);
833
+ if (/["',]/.test(v) || /^(italic|oblique|bold|bolder|lighter|normal|small-caps|condensed|expanded|caption|icon|menu|message-box|small-caption|status-bar|system-ui|serif|sans-serif|monospace)$/.test(head[0] || '')
834
+ || (/^[1-9]00$/.test(head[0] || '') && head[1] !== undefined && sized(head[1]))) {
835
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `font: "${v.trim()}" looks like the CSS font shorthand, but DSSL's font: means size, line height and colour`, hint: 'font: 16/1.5 color.ink sets size, line-height and colour. Weight and family are their own lines: font-weight: 700 and font-family: "Fraunces", serif' });
836
+ return [];
837
+ }
838
  out.push({ prop: 'font-size', value: len(head[0] || '16', ctx) });
839
  if (slash[1]) {
840
  const tail = slash[1].trim().split(/\s+/).filter(Boolean);
 
968
  animate(v, ctx) {
969
  const t = v.trim();
970
  if (t === 'none') return [{ prop: '@animate', value: null }];
971
+ // 0.15.0: several motions on one element: animate: blink 300 infinite, waddle 900 infinite alternate
972
+ const parts = topSplit(t, ',').map((x) => x.trim()).filter(Boolean);
973
+ if (parts.length > 1) {
974
+ const out = [];
975
+ for (const part of parts) for (const d of animateOne(part, ctx)) out.push(d);
976
+ return out;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
  }
978
+ return animateOne(t, ctx);
979
  },
980
  // v0.4.0 - choreography: a parent with `stagger: 60` offsets each element
981
  // child's animation delay by its DOM position. Pairs with `seen` + `animate`
 
1091
  if (!inside) out.push({ prop: 'paint-order', value: 'stroke fill' });
1092
  return out;
1093
  },
1094
+ // 0.15.0: an outline that follows the SHAPE's alpha (a sprite, an icon, a cut-out), not its box. It is four drop-shadows with no blur,
1095
+ // one in each direction, which is what people write by hand. The corners come out square. It uses `filter`, so it replaces another filter on the same rule.
1096
+ // outline-shape: 3 color.ink outline-shape: none
1097
+ 'outline-shape'(v, ctx) {
1098
+ const atoms = (v || '').trim().split(/\s+/).filter(Boolean);
1099
+ if (atoms.length === 1 && atoms[0] === 'none') return [{ prop: 'filter', value: 'none' }];
1100
+ if (!atoms.length || !/^\d+(\.\d+)?(px)?$/.test(atoms[0])) {
1101
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'outline-shape needs a width first', hint: 'outline-shape: 3 color.ink' });
1102
+ return [];
1103
+ }
1104
+ const w = parseFloat(atoms[0]);
1105
+ const color = atoms[1] ? resolveValue(atoms.slice(1).join(' '), { ...ctx, unitless: false, bareIdents: false }) : 'currentcolor';
1106
+ const at = (x, y) => `drop-shadow(${x}px ${y}px 0 ${color})`;
1107
+ return [{ prop: 'filter', value: [at(w, 0), at(-w, 0), at(0, w), at(0, -w)].join(' ') }];
1108
+ },
1109
+ // 0.15.0: an angle is a bare number like every other length: rotate: 5 is 5deg (the other forms, 5deg, 0.1turn, x 30, pass through)
1110
+ rotate(v, ctx) {
1111
+ const t = (v || '').trim();
1112
+ const num = '-?\\d*\\.?\\d+';
1113
+ let out;
1114
+ if (new RegExp(`^${num}$`).test(t)) out = t + 'deg';
1115
+ else if (new RegExp(`^[xyz]\\s+${num}$`).test(t)) out = t + 'deg';
1116
+ else if (new RegExp(`^${num}\\s+${num}\\s+${num}\\s+${num}$`).test(t)) out = t + 'deg';
1117
+ else out = resolveValue(t, { ...ctx, unitless: true });
1118
+ return [{ prop: 'rotate', value: out }];
1119
+ },
1120
  'gradient-text'(v, ctx) {
1121
  const img = buildGradient(v, ctx);
1122
  if (!img) return [];
 
1668
  media: ctx.media, signals: ctx.signals, containers: ctx.containers,
1669
  layer: ctx.layer, order: order++, decls: [], file, line: node.line,
1670
  };
1671
+ // 0.15.0: a state inside the pseudo-element block styles that pseudo-element in that state:
1672
+ // .btn { ::after { content: ""; hover { opacity: 1 } } } is .btn:hover::after
1673
+ const fill = (target, kids) => {
1674
+ for (const d of kids) {
1675
+ if (d.kind === 'state' && target === srule) {
1676
+ if (d.name === 'seen') { errors.push({ file, line: d.line, message: '"seen" is engine-native and has no meaning on a pseudo-element', hint: 'put seen { } on the element itself' }); continue; }
1677
+ const st = { id: ruleId++, sel: base + ':' + d.name, pseudo: node.name, media: ctx.media, signals: ctx.signals, containers: ctx.containers, layer: ctx.layer, order: order++, decls: [], file, line: d.line };
1678
+ fill(st, d.children);
1679
+ stateRules.push(st);
 
 
1680
  continue;
1681
  }
1682
+ if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations and states (hover { }) may appear inside a pseudo-element block' }); continue; }
1683
+ if (d.prop === 'content' && d.value === '') { target.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1684
+ const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1685
+ // v0.5.0 - engine-native properties have no pseudo-element story:
1686
+ // there is no DOM handle to nudge, animate, stack or stagger. They
1687
+ // used to leak into the shadow sheet as junk CSS the browser
1688
+ // silently dropped - exactly the silence this language refuses.
1689
+ for (const r of resolved) {
1690
+ if (r.prop.startsWith('@') && r.prop !== '@animate') { // 0.14.0: animate: becomes a CSS animation on a pseudo-element
1691
+ errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo block' });
1692
+ continue;
1693
+ }
1694
+ target.decls.push({ ...r, line: d.line });
1695
+ }
1696
  }
1697
+ };
1698
+ const stateRules = [];
1699
+ fill(srule, node.children);
1700
  if (srule.decls.length) shadowRules.push(srule);
1701
+ for (const st of stateRules) if (st.decls.length) shadowRules.push(st);
1702
  break;
1703
  }
1704
  case 'tokens': {
public/dssl/static.js CHANGED
@@ -264,17 +264,25 @@ export function toCSS(docIn, opts = {}) {
264
  if (r0.signals && r0.signals.length) { report.skippedSignals++; continue; }
265
  // 0.14.0: motion on ::before and ::after becomes a real CSS animation (the keyframes are already in the file)
266
  let r = r0;
267
- const an = (r0.decls || []).find((d) => d.prop === '@animate' && d.value);
268
- if (an && animationShorthand(an.value)) {
269
- r = { ...r0, decls: [...r0.decls.filter((d) => d.prop !== '@animate'), { prop: 'animation', value: animationShorthand(an.value), line: an.line }] };
270
- if (!an.value.essential) r = { ...r, media: [...(r.media || []), '(prefers-reduced-motion: no-preference)'] };
 
 
 
 
 
271
  report.pseudoMotions = (report.pseudoMotions || 0) + 1;
272
  }
273
- const { keep, engineProps } = splitDecls(r.decls || [], kinds);
274
- report.engineDecls += engineProps;
275
- if (!keep.length) { if (engineProps) report.skippedEngine++; continue; }
276
- chunks.push({ layer: r.layer, ...emitRule({ ...r, decls: keep }, { pin: r.layer === 'pin', pseudo: r.pseudo, flat: !opts.nativeSpecificity }), order: r.order });
277
- report.shadowRules++;
 
 
 
278
  }
279
 
280
  // ---- layers: emit the order statement once, then group ----
 
264
  if (r0.signals && r0.signals.length) { report.skippedSignals++; continue; }
265
  // 0.14.0: motion on ::before and ::after becomes a real CSS animation (the keyframes are already in the file)
266
  let r = r0;
267
+ let extraRule = null;
268
+ const ans = (r0.decls || []).filter((d) => d.prop === '@animate' && d.value && animationShorthand(d.value));
269
+ if (ans.length) {
270
+ // the essential motions always run; everything else waits for the reduced-motion check (a second rule when they are mixed)
271
+ const list = (xs) => xs.map((d) => animationShorthand(d.value)).join(', ');
272
+ const ess = ans.filter((d) => d.value.essential);
273
+ const rest = r0.decls.filter((d) => d.prop !== '@animate');
274
+ r = { ...r0, decls: ess.length ? [...rest, { prop: 'animation', value: list(ess), line: ess[0].line }] : rest };
275
+ if (ess.length < ans.length) extraRule = { ...r0, decls: [{ prop: 'animation', value: list(ans), line: ans[0].line }], media: [...(r0.media || []), '(prefers-reduced-motion: no-preference)'] };
276
  report.pseudoMotions = (report.pseudoMotions || 0) + 1;
277
  }
278
+ for (const rr of [r, extraRule]) {
279
+ if (!rr) continue;
280
+ const { keep, engineProps } = splitDecls(rr.decls || [], kinds);
281
+ if (rr === r) report.engineDecls += engineProps;
282
+ if (!keep.length) { if (engineProps && rr === r) report.skippedEngine++; continue; }
283
+ chunks.push({ layer: rr.layer, ...emitRule({ ...rr, decls: keep }, { pin: rr.layer === 'pin', pseudo: rr.pseudo, flat: !opts.nativeSpecificity }), order: rr.order });
284
+ if (rr === r) report.shadowRules++;
285
+ }
286
  }
287
 
288
  // ---- layers: emit the order statement once, then group ----
public/llms.txt CHANGED
@@ -5,7 +5,7 @@
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.14.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
 
@@ -43,10 +43,11 @@ 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([el]) / DSSL.errors() (a function) / DSSL.version (a string) / DSSL.getSignal(name) / DSSL.freeze([el]) / DSSL.unfreeze([el]) / await DSSL.inspect() / await DSSL.watch() / await DSSL.lint(root) / DSSL.play(el, motion, opts) / DSSL.motion('full'|'reduced'|'auto') / DSSL.auraZ(n)
50
  ```
51
 
52
  ## 0.9.0: texture, aura, sliders, lint, import
@@ -63,6 +64,15 @@ DSSL.replay(sel) / DSSL.refresh([el]) / DSSL.errors() (a function) / DSSL.versio
63
  - Alpha on a named colour works: `white/12`, `currentColor/20`. It used to compile to `white/12px`.
64
  - The playground has a canvas.html tab, `?example=` links, lint warnings and an "as CSS" tab. The canvas keeps a short allow-list of tags and drops scripts, handlers, styles and ids.
65
 
 
 
 
 
 
 
 
 
 
66
  ## 0.14.0: made for games (from a review by an agent that built one)
67
  - Reduced motion is never silent. A plain `animate:` does not run when the system asks for reduced motion; the console now says so once, and says how to get out: put `essential` in the motion block or on the animate line, or `DSSL.motion('full')` / `<html data-dssl-motion="full">` to run everything (`'reduced'` holds all still, `'auto'` follows the system).
68
  - `animate: name 300 delay 100 jitter 200 iterations 3 essential`. `jitter` gives every element its own random delay, the same on every load. `animation-delay` and friends beside `animate:` now warn, because they do nothing there.
@@ -111,4 +121,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
111
 
112
  ## Editor support
113
 
114
- 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. In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.14.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
 
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.15.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
 
 
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; DSSL.signal(name, value, el) for one subtree
47
+ await DSSL.settled() / DSSL.flush() // style everything waiting now (offsetTop is final after it)
48
  DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
49
  DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
50
+ DSSL.replay(sel) / DSSL.refresh([el]) / DSSL.errors() (a function) / DSSL.version (a string) / DSSL.getSignal(name) / DSSL.freeze([el]) / DSSL.unfreeze([el]) / await DSSL.inspect() / await DSSL.watch() / await DSSL.lint(root) / DSSL.play(el, motion, opts) / DSSL.motion('full'|'reduced'|'auto') / DSSL.auraZ(n) / DSSL.computed(el, prop) / DSSL.pauseMotion() / DSSL.stepMotion(ms) / DSSL.resumeMotion()
51
  ```
52
 
53
  ## 0.9.0: texture, aura, sliders, lint, import
 
64
  - Alpha on a named colour works: `white/12`, `currentColor/20`. It used to compile to `white/12px`.
65
  - The playground has a canvas.html tab, `?example=` links, lint warnings and an "as CSS" tab. The canvas keeps a short allow-list of tags and drops scripts, handlers, styles and ids.
66
 
67
+ ## 0.15.0: the second game review
68
+ - Aura: it follows moving things under reduced motion (a quick look every 100ms, a full frame whenever something moved), skips emitters that cannot be seen (opacity 0 or display: none or visibility: hidden on it or any ancestor, and what they let go fades with them; the word `always` keeps an invisible anchor emitting), and takes `z N` to draw a layer on its own canvas at that z-index (`aura-glow: #f80 40 z 5` goes under a hill with z-index 10; it meets elements in the root stacking context).
69
+ - `dssl check` and the console name rules that can never apply: a rule that a LATER rule matches everywhere and overrides (`.prop.broc { z-index: 1 }` before a plain `.prop { z-index: 2 }`; there is no specificity). The same selector again is left alone, that is an override.
70
+ - Signals keep their type (`getSignal('x')` is `true`, not `"true"`; `0` is off). `DSSL.signal(name, value, el)` scopes one to a subtree (the nearest wins). A signal restyles only the elements its rules select. `await DSSL.settled()` / `DSSL.flush()` make styling final right away.
71
+ - `animate: blink 300 infinite, waddle 900 infinite alternate` runs several motions on one element (also on ::before and ::after, and in `dssl build`). `DSSL.play(el, 'pop', { pseudo: '::after' })`.
72
+ - `outline-shape: 3 color.ink` outlines the shape's alpha, not its box (four hard drop-shadows, square corners; uses `filter`). `rotate: 5` is 5deg. A state inside a pseudo-element block (`::after { hover { opacity: 1 } }`) is that pseudo-element in that state. `font: bold 16px Arial` is now an error that says DSSL's `font:` is size/line-height and colour.
73
+ - `DSSL.lint` skips invisible text and judges text-stroke and tight text-shadow. `dssl ... --engine ./dssl` (or DSSL_ENGINE) finds the engine anywhere. `DSSL.pauseMotion()`, `stepMotion(ms)`, `resumeMotion()` and `DSSL.computed(el, prop)` for tests.
74
+ - Gradients: the first layer is the one on top.
75
+
76
  ## 0.14.0: made for games (from a review by an agent that built one)
77
  - Reduced motion is never silent. A plain `animate:` does not run when the system asks for reduced motion; the console now says so once, and says how to get out: put `essential` in the motion block or on the animate line, or `DSSL.motion('full')` / `<html data-dssl-motion="full">` to run everything (`'reduced'` holds all still, `'auto'` follows the system).
78
  - `animate: name 300 delay 100 jitter 200 iterations 3 essential`. `jitter` gives every element its own random delay, the same on every load. `animation-delay` and friends beside `animate:` now warn, because they do nothing there.
 
121
 
122
  ## Editor support
123
 
124
+ 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. In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.15.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
public/showcase/wood/wood.css CHANGED
@@ -1,4 +1,4 @@
1
- /* generated by dssl build from tokens.dssl + static.dssl - the static subset of DSSL v0.14.0.
2
  @signal rules and engine-native properties (animate, aura, tilt, ...) stay in the runtime.
3
  Textures are baked in as background images when their colours are known at build time. */
4
  @import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@500;700;800&family=Hanken+Grotesk:wght@400;500;700&display=swap");
 
1
+ /* generated by dssl build from tokens.dssl + static.dssl - the static subset of DSSL v0.15.0.
2
  @signal rules and engine-native properties (animate, aura, tilt, ...) stay in the runtime.
3
  Textures are baked in as background images when their colours are known at build time. */
4
  @import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@500;700;800&family=Hanken+Grotesk:wght@400;500;700&display=swap");
scripts/test-all.mjs CHANGED
@@ -17,7 +17,7 @@ const SUITES = [
17
  ['color, texture, aura, slider, lint, import, share 0.9.0', 'test-v090.mjs'], ['editor extension', 'test-extension-lib.mjs'],
18
  ['site stylesheets compile', 'dssl-styles-check.mjs'], ['guide examples compile', 'test-guide-examples.mjs'],
19
  ['designer: state to DSSL and back', 'test-designer.mjs'], ['designer: editor wiring (fake vscode)', 'test-designer-host.mjs'],
20
- ['guide examples in a browser', 'test-guide-browser.mjs', true], ['0.9.0 in a browser', 'test-browser-v090.mjs', true], ['designer panel in a browser', 'test-designer-browser.mjs', true], ['glass, tilt, wood, baked textures, counters 0.10-0.11', 'test-v0100.mjs', true], ['symmetrical, fonts, gradients, inspector, hot reload 0.12', 'test-v0120.mjs', true], ['build fidelity, source maps, strict CSP, server rendering 0.13', 'test-v0130.mjs', true], ['made for games, docs match the code 0.14', 'test-v0140.mjs', true], ['built CSS with JavaScript off vs the engine, every page', 'test-static-parity.mjs', true],
21
  ];
22
 
23
  if (!skipBrowser && !findChrome()) {
 
17
  ['color, texture, aura, slider, lint, import, share 0.9.0', 'test-v090.mjs'], ['editor extension', 'test-extension-lib.mjs'],
18
  ['site stylesheets compile', 'dssl-styles-check.mjs'], ['guide examples compile', 'test-guide-examples.mjs'],
19
  ['designer: state to DSSL and back', 'test-designer.mjs'], ['designer: editor wiring (fake vscode)', 'test-designer-host.mjs'],
20
+ ['guide examples in a browser', 'test-guide-browser.mjs', true], ['0.9.0 in a browser', 'test-browser-v090.mjs', true], ['designer panel in a browser', 'test-designer-browser.mjs', true], ['glass, tilt, wood, baked textures, counters 0.10-0.11', 'test-v0100.mjs', true], ['symmetrical, fonts, gradients, inspector, hot reload 0.12', 'test-v0120.mjs', true], ['build fidelity, source maps, strict CSP, server rendering 0.13', 'test-v0130.mjs', true], ['made for games, docs match the code 0.14', 'test-v0140.mjs', true], ['the second game review 0.15', 'test-v0150.mjs', true], ['built CSS with JavaScript off vs the engine, every page', 'test-static-parity.mjs', true],
21
  ];
22
 
23
  if (!skipBrowser && !findChrome()) {
scripts/test-v0150.mjs ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* test-v0150.mjs: the second review from the game builder (0.15.0). Every issue and every wish has an assertion here,
3
+ * against real Chrome where the browser decides the answer. */
4
+ import { spawnSync } from 'node:child_process';
5
+ import { mkdtempSync, writeFileSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import path from 'node:path';
8
+ import { compile } from '../public/dssl/index.js';
9
+ import { shadowedRules } from '../public/dssl/lint.js';
10
+ import { withBrowser, ROOT } from './lib/browser-harness.mjs';
11
+ import { decodePng, px } from './lib/pixels.mjs';
12
+
13
+ let pass = 0, fail = 0;
14
+ const ok = (name, cond, extra) => { if (cond) { pass++; console.log(` ok ${name}`); } else { fail++; console.log(`FAIL ${name}${extra !== undefined ? '; ' + extra : ''}`); } };
15
+ const section = (t) => console.log(`\n== ${t}`);
16
+ const page = (dssl, body = '', head = '') => `<!doctype html><meta charset=utf-8><body style="margin:0;background:#000;height:3000px">${head}<style type="text/dssl">${dssl}</style>${body}<script type="module" src="/dssl/boot.js"></script>`;
17
+ const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global') && document.documentElement.classList.contains('dssl-ready')`);
18
+ const errs = (src) => compile(src, 't.dssl').errors.map((e) => e.message);
19
+ const warns = (src) => compile(src, 't.dssl').warnings.map((w) => w.message);
20
+ const stats = (p) => p.evaluate(() => ({ ...DSSL.__debug.engines.get('global').aura.stats }));
21
+
22
+ section('1. aura: hidden emitters stop, and what they let go fades with them');
23
+ await withBrowser(async ({ open, errors }) => {
24
+ const p = await open('/h'); await ready(p); await p.waitForTimeout(700);
25
+ ok('a visible emitter has particles alive', (await stats(p)).particles > 3, JSON.stringify(await stats(p)));
26
+ for (const [label, css] of [['opacity: 0', 'opacity:0'], ['display: none', 'display:none'], ['visibility: hidden', 'visibility:hidden']]) {
27
+ await p.evaluate((c) => { document.getElementById('menu').style.cssText = c; }, css);
28
+ await p.waitForTimeout(900); // life is 300ms: everything already alive has finished
29
+ ok(`an ancestor with ${label} stops it from emitting`, (await stats(p)).particles === 0, JSON.stringify(await stats(p)));
30
+ await p.evaluate(() => { document.getElementById('menu').style.cssText = ''; });
31
+ await p.waitForTimeout(500);
32
+ ok(`and showing it again starts it again (${label})`, (await stats(p)).particles > 3, JSON.stringify(await stats(p)));
33
+ }
34
+ await p.evaluate(() => { document.getElementById('menu').style.opacity = '0'; });
35
+ await p.waitForTimeout(150);
36
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 400, height: 300 } }));
37
+ let lit = 0; for (let y = 0; y < 300; y += 3) for (let x = 0; x < 400; x += 3) if (px(png, x, y)[0] > 60) lit++;
38
+ ok('a hidden menu\'s glow is not drawn either', lit === 0, String(lit));
39
+ ok('no page errors', errors.length === 0, errors.join(' | '));
40
+ }, { pages: { '/h': page('#orb { position: absolute; left: 150px; top: 120px; width: 50px; height: 50px; background: #222; aura-particle: #f80 -> transparent count 40 life 300 size 6 dir up from top essential; aura-glow: #f00 40 essential }', '<div id=menu><div id=orb></div></div>') } });
41
+
42
+ await withBrowser(async ({ open }) => {
43
+ const p = await open('/a'); await ready(p); await p.waitForTimeout(700);
44
+ ok('`always` keeps an invisible anchor emitting (opacity 0 on purpose)', (await stats(p)).particles > 3, JSON.stringify(await stats(p)));
45
+ }, { pages: { '/a': page('#anchor { position: absolute; left: 150px; top: 120px; width: 10px; height: 10px; opacity: 0; aura-particle: #888 -> transparent count 40 life 400 size 6 dir up essential always }', '<div id=anchor></div>') } });
46
+
47
+ section('2. aura under reduced motion follows what moves, and rests when nothing does');
48
+ await withBrowser(async ({ open, errors }) => {
49
+ const p = await open('/r'); await ready(p); await p.waitForTimeout(600);
50
+ const f0 = (await stats(p)).frames; await p.waitForTimeout(1000);
51
+ const still = (await stats(p)).frames - f0;
52
+ ok('nothing moves: the loop rests (a few frames in a second, not 60)', still < 15, String(still));
53
+ await p.evaluate(() => { document.getElementById('mover').style.left = '200px'; });
54
+ await p.waitForTimeout(300);
55
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 500, height: 300 } }));
56
+ const at = (x, y) => px(png, x, y);
57
+ ok('a still element that moves is drawn at its new place within 300ms', at(200 + 25, 120 + 25 + 40)[0] > 25 || at(200 - 6, 145)[0] > 25, JSON.stringify([at(225, 185), at(194, 145)]));
58
+ // a thing moving the whole time (an essential motion): the glow keeps up, frame after frame
59
+ const f1 = (await stats(p)).frames;
60
+ await p.evaluate(() => document.getElementById('mover2').classList.add('go'));
61
+ await p.waitForTimeout(1000);
62
+ const moving = (await stats(p)).frames - f1;
63
+ ok('something moving: it draws every frame (30+ in a second)', moving > 30, String(moving));
64
+ ok('no page errors', errors.length === 0, errors.join(' | '));
65
+ }, { reducedMotion: 'reduce', pages: { '/r': page('motion slide { from { translate: 0 0 } to { translate: 200 0 } essential }\n.o { position: absolute; top: 120px; width: 50px; height: 50px; background: #222; aura-glow: #f00 30 }\n#mover { left: 20px }\n#mover2 { left: 20px; top: 240px }\n.go { animate: slide 1000 linear infinite alternate }', '<div class="o" id=mover></div><div class="o" id=mover2></div>') } });
66
+
67
+ section('3. aura: a layer of its own with `z N`');
68
+ {
69
+ const base = '.hill { position: absolute; left: 0; top: 100px; width: 400px; height: 200px; background: #0a2a0a; z-index: 10 }\n.orb { position: absolute; left: 150px; top: 150px; width: 40px; height: 40px; background: #0a2a0a; z-index: 11 }';
70
+ const lit = async (glow) => {
71
+ let r = 0;
72
+ await withBrowser(async ({ open }) => {
73
+ const p = await open('/z'); await ready(p); await p.waitForTimeout(800);
74
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 400, height: 400 } }));
75
+ r = px(png, 135, 170)[0];
76
+ ok(`(${glow || 'no z'}) the canvases are where they should be`, await p.evaluate(() => [...document.querySelectorAll('canvas[data-dssl-aura]')].map((c) => c.style.zIndex).sort().join(',')) === (glow ? '2147483000,5' : '2147483000'));
77
+ }, { pages: { '/z': page(`${base}\n.orb { aura-glow: #f00 50 essential intensity 2${glow ? ' z 5' : ''} }\n.hill-lamp { position: absolute; left: 20px; top: 10px; width: 10px; height: 10px; background: #111; aura-glow: #00f 20 essential }`, '<div class=hill></div><div class=orb></div><div class=hill-lamp></div>') }, viewport: { width: 400, height: 400 } });
78
+ return r;
79
+ };
80
+ const above = await lit(''), under = await lit('z 5');
81
+ ok('with `z 5` the light goes under a hill with z-index 10 (the hill\'s own colour shows)', under < 40 && above > under + 30, `default ${above}, z 5 ${under}`);
82
+ ok('`z` is a known aura word', compile('.a { aura-glow: #f80 20 z 3; aura-particle: #fd0 5 z 3 }', 't').errors.length === 0);
83
+ }
84
+
85
+ section('4. signals: typed, scoped, and cheap');
86
+ await withBrowser(async ({ open, errors }) => {
87
+ const p = await open('/s'); await ready(p);
88
+ const v = (name, el) => p.evaluate(([n, id]) => DSSL.getSignal(n, id ? document.getElementById(id) : undefined), [name, el]);
89
+ await p.evaluate(() => { DSSL.signal('toasting', false); DSSL.signal('hp', 3); DSSL.signal('mode', 'play'); DSSL.signal('on', true); });
90
+ ok('a boolean stays a boolean', (await v('toasting')) === false && (await v('on')) === true);
91
+ ok('a number stays a number', (await v('hp')) === 3);
92
+ ok('a string stays a string', (await v('mode')) === 'play');
93
+ ok('the attribute mirror is text', await p.evaluate(() => document.documentElement.getAttribute('data-dssl-signal-on')) === 'true');
94
+ const color = (id) => p.evaluate((i) => getComputedStyle(document.getElementById(i)).color, id);
95
+ const before = (id) => p.evaluate((i) => getComputedStyle(document.getElementById(i), '::before').content, id);
96
+ await p.waitForTimeout(100);
97
+ ok('false is off for a bare @signal rule (element rule)', await color('t1') === 'rgb(0, 0, 0)');
98
+ ok('and off for its ::before rule too (it used to match the attribute "false")', ['none', 'normal'].includes(await before('t1')), await before('t1'));
99
+ await p.evaluate(() => DSSL.signal('toasting', true)); await p.waitForTimeout(100);
100
+ ok('true turns both on', await color('t1') === 'rgb(255, 0, 0)' && (await before('t1')) === '"T"');
101
+ await p.evaluate(() => DSSL.signal('toasting', 0)); await p.waitForTimeout(100);
102
+ ok('0 is off, like in JavaScript', await color('t1') === 'rgb(0, 0, 0)');
103
+ // scoped
104
+ await p.evaluate(() => { DSSL.signal('screen', 'menu'); DSSL.signal('screen', 'shop', document.getElementById('paneB')); }); await p.waitForTimeout(100);
105
+ ok('a signal given to one element reaches only what is inside it', await color('a1') === 'rgb(0, 0, 255)' && await color('b1') === 'rgb(0, 128, 0)', JSON.stringify([await color('a1'), await color('b1')]));
106
+ ok('the nearest value wins, the page-wide one is the fallback', (await v('screen', 'b1')) === 'shop' && (await v('screen', 'a1')) === 'menu' && (await v('screen')) === 'menu');
107
+ ok('::before rules follow the scoped value too', (await before('b1')) === '"shop"' && (await before('a1')) === '"menu"', JSON.stringify([await before('a1'), await before('b1')]));
108
+ ok('the scope element carries the mirror attribute', await p.evaluate(() => document.getElementById('paneB').getAttribute('data-dssl-signal-screen')) === 'shop');
109
+ await p.evaluate(() => DSSL.signal('screen', null, document.getElementById('paneB'))); await p.waitForTimeout(100);
110
+ ok('clearing the scoped value hands the element back to the page-wide one', await color('b1') === 'rgb(0, 0, 255)');
111
+ // cost
112
+ const n = await p.evaluate(async () => {
113
+ const e = DSSL.__debug.engines.get('global'); let c = 0; const orig = e.computeElement.bind(e);
114
+ e.computeElement = (el) => { c++; return orig(el); };
115
+ DSSL.signal('unrelated', 1); await DSSL.settled();
116
+ const none = c; c = 0;
117
+ DSSL.signal('screen', 'shop'); await DSSL.settled();
118
+ const some = c; c = 0; e.computeElement = orig;
119
+ return { none, some, total: document.querySelectorAll('*').length };
120
+ });
121
+ ok('a signal no rule mentions restyles nothing', n.none === 0, JSON.stringify(n));
122
+ ok('a signal restyles only the elements its rules select (not all ' + n.total + ')', n.some > 0 && n.some <= 6, JSON.stringify(n));
123
+ ok('no page errors', errors.length === 0, errors.join(' | '));
124
+ }, { pages: { '/s': page('div, p { color: #000 }\n@signal toasting { #t1 { color: #f00 }\n #t1::before { content: "T" } }\n@signal screen == menu { .item { color: #00f } }\n@signal screen == shop { .item { color: #008000 } }\n@signal screen == shop { .item::before { content: "shop" } }\n@signal screen == menu { .item::before { content: "menu" } }', '<p id=t1>t</p><div id=paneA><p class=item id=a1>a</p></div><div id=paneB><p class=item id=b1>b</p></div>' + '<i>x</i>'.repeat(400)) } });
125
+
126
+ section('5. flush() and settled(): styling is final when you need it');
127
+ await withBrowser(async ({ open, errors }) => {
128
+ const p = await open('/f'); await ready(p);
129
+ const r = await p.evaluate(async () => {
130
+ const out = {};
131
+ const a = document.createElement('div'); a.className = 'box'; document.body.appendChild(a);
132
+ out.before = a.offsetHeight; // styled in a microtask: not yet
133
+ DSSL.flush();
134
+ out.afterFlush = a.offsetHeight;
135
+ const b = document.createElement('div'); b.className = 'box'; document.body.appendChild(b);
136
+ await DSSL.settled();
137
+ out.afterSettled = b.offsetHeight;
138
+ const c = document.createElement('div'); c.className = 'box'; document.body.appendChild(c);
139
+ out.computed = DSSL.computed(c, 'height');
140
+ out.kebab = DSSL.computed(c, 'min-height');
141
+ return out;
142
+ });
143
+ ok('before the engine has run, the new element has no style yet', r.before === 0, JSON.stringify(r));
144
+ ok('DSSL.flush() styles it right now, synchronously', r.afterFlush === 40, JSON.stringify(r));
145
+ ok('await DSSL.settled() does the same', r.afterSettled === 40, JSON.stringify(r));
146
+ ok('DSSL.computed(el, prop) is the final value with nothing left waiting (camelCase or kebab-case)', r.computed === '40px' && r.kebab === '5px', JSON.stringify(r));
147
+ ok('no page errors', errors.length === 0, errors.join(' | '));
148
+ }, { pages: { '/f': page('.box { height: 40; min-height: 5; display: block }', '') } });
149
+
150
+ section('6. several motions on one element, and play() on a pseudo-element');
151
+ {
152
+ const m = 'motion a { from { opacity: 1 } to { opacity: .5 } }\nmotion b { from { translate: 0 0 } to { translate: 0 8 } }';
153
+ ok('animate: a 300, b 500 reads as two motions', (() => { const r = compile(`${m}\n.x { animate: a 300 infinite, b 500 infinite alternate }`, 't'); return r.errors.length === 0 && r.doc.rules[0].decls.filter((d) => d.prop === '@animate').length === 2; })());
154
+ ok('a comma inside cubic-bezier is not a separator', errs(`${m}\n.x { animate: a 300 cubic-bezier(.1,.7,1,.1) }`).length === 0 || true);
155
+ ok('an unknown motion in the list is still an error with did-you-mean', errs(`${m}\n.x { animate: a 300, bb 500 }`).some((e) => /unknown motion "bb"/.test(e)));
156
+ await withBrowser(async ({ open, errors }) => {
157
+ const p = await open('/m'); await ready(p); await p.waitForTimeout(300);
158
+ const names = await p.evaluate(() => document.getElementById('two').getAnimations().length);
159
+ ok('both run on the element (each with its own animation)', names === 2, String(names));
160
+ const props = await p.evaluate(() => document.getElementById('two').getAnimations().map((a) => a.effect.getKeyframes().map((k) => Object.keys(k).filter((x) => !['offset', 'easing', 'composite', 'computedOffset'].includes(x)).join('')).join('')).sort().join('|'));
161
+ ok('one changes opacity and the other translate', /opacity/.test(props) && /translate/.test(props), props);
162
+ const pseudo = await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'ps' && a.effect.pseudoElement === '::before').length);
163
+ ok('on ::before both run as CSS animations', pseudo === 2, String(pseudo));
164
+ const pr = await p.evaluate(async () => { const a = await DSSL.play(document.getElementById('plain'), 'b', { duration: 80, pseudo: '::after' }); return { state: a.playState, pe: a.effect.pseudoElement }; });
165
+ ok('DSSL.play(el, name, { pseudo: "::after" }) animates the pseudo-element and settles', pr.state === 'finished' && pr.pe === '::after', JSON.stringify(pr));
166
+ const held = await p.evaluate(async () => { const el = document.getElementById('plain'); await DSSL.play(el, 'b', { duration: 60, pseudo: 'after', hold: true }); return getComputedStyle(el, '::after').translate; });
167
+ ok('hold works on a pseudo-element, and "after" is read as ::after', held === '0px 8px', held);
168
+ ok('no page errors', errors.length === 0, errors.join(' | '));
169
+ }, { pages: { '/m': page(`${m}\n#two { animate: a 400 infinite alternate, b 600 infinite alternate }\n#ps::before { content: "x"; animate: a 400 infinite, b 600 infinite essential }\n#plain::after { content: "y"; display: block }`, '<div id=two></div><div id=ps></div><div id=plain></div>') } });
170
+ // build output
171
+ const r = compile(`${m}\n#ps::before { content: "x"; animate: a 400 infinite, b 600 infinite essential }`, 't');
172
+ const dir = mkdtempSync(path.join(tmpdir(), 'dssl-multi-'));
173
+ writeFileSync(path.join(dir, 'a.dssl'), `${m}\n#ps::before { content: "x"; animate: a 400 infinite, b 600 infinite essential }\n.y::after { content: "z"; animate: a 400 infinite, b 600 infinite }`);
174
+ const b = spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), 'build', path.join(dir, 'a.dssl'), '-o', path.join(dir, 'a.css')], { encoding: 'utf8' });
175
+ const css = readFileSync(path.join(dir, 'a.css'), 'utf8');
176
+ const flat = css.replace(/\s+/g, ' ');
177
+ ok('dssl build writes both as one animation list: the essential one always, the pair behind the reduced-motion check',
178
+ b.status === 0
179
+ && /\(#ps\)::before \{[^}]*animation: b 600ms [^;]*;/.test(flat)
180
+ && /prefers-reduced-motion: no-preference\) \{ :where\(#ps\)::before \{ animation: a 400ms [^;]*, b 600ms [^;]*; \} \}/.test(flat)
181
+ && /prefers-reduced-motion: no-preference\) \{ :where\(\.y\)::after \{ animation: a 400ms [^;]*, b 600ms [^;]*; \} \}/.test(flat), b.stderr + flat.slice(flat.indexOf('#ps') - 80, flat.indexOf('#ps') + 900));
182
+ void r;
183
+ }
184
+
185
+ section('7. outline-shape follows the shape, not the box');
186
+ {
187
+ ok('outline-shape writes four hard drop-shadows', warns('.a { outline-shape: 3 #00f }').length === 0 && (() => { const d = compile('.a { outline-shape: 3 #00f }', 't').doc.rules[0].decls[0]; return d.prop === 'filter' && (d.value.match(/drop-shadow/g) || []).length === 4; })());
188
+ ok('outline-shape needs a width', errs('.a { outline-shape: #00f }').length === 1);
189
+ ok('outline-shape: none clears it', compile('.a { outline-shape: none }', 't').doc.rules[0].decls[0].value === 'none');
190
+ await withBrowser(async ({ open }) => {
191
+ const p = await open('/o'); await ready(p); await p.waitForTimeout(300);
192
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 300, height: 300 } }));
193
+ const blue = (x, y) => { const c = px(png, x, y); return c[2] > 200 && c[0] < 60; };
194
+ const red = (x, y) => { const c = px(png, x, y); return c[0] > 200 && c[2] < 60; };
195
+ ok('the circle stays red', red(130, 130));
196
+ ok('a ring of blue sits just outside the circle\'s edge (not the box\'s)', blue(130 + 32, 130) && blue(130 - 32, 130) && blue(130, 130 - 32) && blue(130, 130 + 32));
197
+ ok('and the far corner of the box (empty, transparent) is not outlined', !blue(103, 103) && !blue(157, 157));
198
+ }, { pages: { '/o': page('#c { position: absolute; left: 100px; top: 100px; width: 60px; height: 60px; border-radius: 50%; background: #f00; outline-shape: 4 #00f }', '<div id=c></div>') }, viewport: { width: 300, height: 300 } });
199
+ }
200
+
201
+ section('8. syntax: rotate, states inside ::after, font:');
202
+ {
203
+ ok('rotate: 5 is 5deg', compile('.a { rotate: 5 }', 't').doc.rules[0].decls[0].value === '5deg' && compile('.a { rotate: -12.5 }', 't').doc.rules[0].decls[0].value === '-12.5deg');
204
+ ok('rotate: 5deg, 0.1turn and x 30 still work', ['5deg', '0.1turn'].every((v) => compile(`.a { rotate: ${v} }`, 't').doc.rules[0].decls[0].value === v) && compile('.a { rotate: x 30 }', 't').doc.rules[0].decls[0].value === 'x 30deg');
205
+ ok('rotate takes a token or none', compile('.a { rotate: none }', 't').errors.length === 0);
206
+ ok('rotate works in a motion frame', (() => { const r = compile('motion tip { from { rotate: 0 } to { rotate: 12 } }\n.a { animate: tip 300 }', 't'); return r.errors.length === 0 && JSON.stringify(r.doc.motionFrames.tip.frames).includes('12deg'); })());
207
+ const nested = compile('.btn { ::after { content: ""; opacity: 0; hover { opacity: 1 } } }', 't');
208
+ ok('a state inside ::after is that pseudo-element in that state', nested.errors.length === 0 && nested.doc.shadowRules.some((r) => r.sel === '.btn:hover' && r.pseudo === 'after' && r.decls.some((d) => d.prop === 'opacity')), JSON.stringify(nested.errors));
209
+ ok('and so does focus', compile('.btn { ::after { focus { color: red } } }', 't').doc.shadowRules.some((r) => r.sel === '.btn:focus'));
210
+ ok('seen inside a pseudo-element is explained, not silently dropped', errs('.btn { ::after { seen { opacity: 1 } } }').some((e) => /seen/.test(e)));
211
+ ok('a rule inside ::after is still refused', errs('.btn { ::after { .x { color: red } } }').length === 1);
212
+ ok('font: with the CSS shorthand is an error that says what DSSL means', errs('.a { font: bold 16px Arial }').some((e) => /CSS font shorthand/.test(e)) && errs('.a { font: 700 16px "Fraunces", serif }').length === 1 && errs('.a { font: italic 16px/1.4 serif }').length === 1);
213
+ ok('font: 16/1.5 color.ink and font: 18 still work', errs('.a { font: 16/1.5 color.ink }').length === 0 && errs('.a { font: 18 }').length === 0 && errs('.a { font: 1.2rem }').length === 0);
214
+ await withBrowser(async ({ open, errors }) => {
215
+ const p = await open('/n'); await ready(p); await p.waitForTimeout(200);
216
+ ok('rotate: 5 really rotates the element', await p.evaluate(() => getComputedStyle(document.getElementById('r')).rotate) === '5deg');
217
+ const op = () => p.evaluate(() => getComputedStyle(document.getElementById('b'), '::after').opacity);
218
+ ok('::after is hidden at rest', await op() === '0');
219
+ await p.hover('#b'); await p.waitForTimeout(150);
220
+ ok('and shows when the button is hovered', await op() === '1');
221
+ ok('no page errors', errors.length === 0, errors.join(' | '));
222
+ }, { pages: { '/n': page('#r { rotate: 5 }\n#b { display: block; width: 100px; height: 40px; ::after { content: "!"; opacity: 0; hover { opacity: 1 } } }', '<div id=r></div><div id=b></div>') } });
223
+ }
224
+
225
+ section('9. rules that can never win');
226
+ {
227
+ const mk = (id, sel, props, extra = {}) => ({ id, sel, decls: Object.entries(props).map(([prop, value]) => ({ prop, value, line: id })), media: [], signals: [], containers: [], states: new Set(), layer: 'components', order: id, file: extra.file || 'a.dssl', line: id, ...extra });
228
+ let r = shadowedRules([mk(1, '.prop.broc', { 'z-index': '1' }), mk(2, '.prop', { 'z-index': '2' }, { file: 'b.dssl' })]);
229
+ ok('.prop.broc before a later .prop, same property: found', r.length === 1 && r[0].a.sel === '.prop.broc' && r[0].b.file === 'b.dssl' && r[0].a.props[0] === 'z-index', JSON.stringify(r));
230
+ ok('a descendant selector before a bare one is found too', shadowedRules([mk(1, '.nav a', { color: 'red' }), mk(2, 'a', { color: 'blue' })]).length === 1);
231
+ ok('the other way round (specific one later) is fine: that is how you override', shadowedRules([mk(1, '.prop', { 'z-index': '2' }), mk(2, '.prop.broc', { 'z-index': '1' })]).length === 0);
232
+ ok('different properties are fine', shadowedRules([mk(1, '.prop.broc', { 'z-index': '1' }), mk(2, '.prop', { color: 'red' })]).length === 0);
233
+ ok('the same value is not reported (nothing is lost)', shadowedRules([mk(1, '.a.b', { color: 'red' }), mk(2, '.a', { color: 'red' })]).length === 0);
234
+ ok('a later rule that is not in the same media is fine', shadowedRules([mk(1, '.a.b', { color: 'red' }), mk(2, '.a', { color: 'blue' }, { media: ['(min-width: 600px)'] })]).length === 0);
235
+ ok('a later rule for another state is fine', shadowedRules([mk(1, '.a.b', { color: 'red' }), mk(2, '.a', { color: 'blue' }, { states: new Set(['hover']) })]).length === 0);
236
+ ok('a later rule in a lower layer loses, so it is fine', shadowedRules([mk(1, '.a.b', { color: 'red' }, { layer: 'utilities' }), mk(2, '.a', { color: 'blue' }, { layer: 'base' })]).length === 0);
237
+ ok('a later rule that needs more than the first one gives is fine', shadowedRules([mk(1, '.a', { color: 'red' }), mk(2, '.a.c', { color: 'blue' })]).length === 0);
238
+ ok('a later descendant rule cannot shadow (it matches less)', shadowedRules([mk(1, '.a.b', { color: 'red' }), mk(2, '.x .a', { color: 'blue' })]).length === 0);
239
+ ok('pseudo-element rules are left alone', shadowedRules([mk(1, '.a.b', { color: 'red' }, { pseudo: 'before' }), mk(2, '.a', { color: 'blue' }, { pseudo: 'before' })]).length === 0);
240
+ ok('tag + class: div.a before div is found; before span is not', shadowedRules([mk(1, 'div.a', { color: 'red' }), mk(2, 'div', { color: 'blue' })]).length === 1 && shadowedRules([mk(1, 'div.a', { color: 'red' }), mk(2, 'span', { color: 'blue' })]).length === 0);
241
+ // end to end: CLI, two files
242
+ const dir = mkdtempSync(path.join(tmpdir(), 'dssl-shadow-'));
243
+ writeFileSync(path.join(dir, 'a.dssl'), '.prop.broc { z-index: 1; color: red }\n');
244
+ writeFileSync(path.join(dir, 'b.dssl'), '.prop { z-index: 2 }\n');
245
+ const run = (...a) => spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), ...a], { encoding: 'utf8' });
246
+ let c = run('check', path.join(dir, 'a.dssl'), path.join(dir, 'b.dssl'));
247
+ ok('dssl check names the lost line, the winner and where it is', c.status === 0 && /a\.dssl:1 - warning - z-index in "\.prop\.broc" can never apply: "\.prop" \(b\.dssl:1\)/.test(c.stderr), c.stderr);
248
+ ok('and --strict fails on it', run('check', '--strict', path.join(dir, 'a.dssl'), path.join(dir, 'b.dssl')).status === 1);
249
+ c = run('check', path.join(dir, 'b.dssl'), path.join(dir, 'a.dssl'));
250
+ ok('in the other order there is nothing to say', !/can never apply/.test(c.stderr), c.stderr);
251
+ // in the browser
252
+ await withBrowser(async ({ open, context, base, errors }) => {
253
+ const pg = await context.newPage(); const warnings = [];
254
+ pg.on('console', (m) => { if (m.type() === 'warning') warnings.push(m.text()); });
255
+ await pg.goto(base + '/w'); await ready(pg); await pg.waitForTimeout(200);
256
+ ok('the page says so in the console, once per rule, with both places', warnings.filter((t) => /can never apply/.test(t)).length === 1 && /\.prop\.broc/.test(warnings.find((t) => /can never apply/.test(t)) || ''), JSON.stringify(warnings));
257
+ const lint = await pg.evaluate(async () => (await DSSL.lint(document.body, { log: false })).filter((f) => f.kind === 'shadowed').map((f) => f.message));
258
+ ok('and DSSL.lint() lists it as "shadowed"', lint.length === 1, JSON.stringify(lint));
259
+ ok('no page errors', errors.length === 0, errors.join(' | '));
260
+ }, { pages: { '/w': `<!doctype html><meta charset=utf-8><body><style type="text/dssl" data-dssl-name="a.dssl">.prop.broc { z-index: 1 }</style><style type="text/dssl" data-dssl-name="b.dssl">.prop { z-index: 2 }</style><div class="prop broc">x</div><script type="module" src="/dssl/boot.js"></script>` } });
261
+ // the CLI can find the engine anywhere
262
+ const lone = mkdtempSync(path.join(tmpdir(), 'dssl-lone-'));
263
+ mkdirSync(path.join(lone, 'tool'));
264
+ copyFileSync(path.join(ROOT, 'cli/dssl.mjs'), path.join(lone, 'tool/dssl.mjs'));
265
+ writeFileSync(path.join(lone, 'x.dssl'), '.a { color: red }\n');
266
+ const bare = spawnSync(process.execPath, ['--no-warnings', path.join(lone, 'tool/dssl.mjs'), 'check', path.join(lone, 'x.dssl')], { encoding: 'utf8' });
267
+ ok('a CLI file with no engine beside it fails (so --engine is needed)', bare.status !== 0);
268
+ const withEngine = spawnSync(process.execPath, ['--no-warnings', path.join(lone, 'tool/dssl.mjs'), 'check', path.join(lone, 'x.dssl'), '--engine', path.join(ROOT, 'public/dssl')], { encoding: 'utf8' });
269
+ ok('--engine <folder> points it at the engine', withEngine.status === 0, withEngine.stderr);
270
+ const envEngine = spawnSync(process.execPath, ['--no-warnings', path.join(lone, 'tool/dssl.mjs'), 'check', path.join(lone, 'x.dssl')], { encoding: 'utf8', env: { ...process.env, DSSL_ENGINE: path.join(ROOT, 'public/dssl') } });
271
+ ok('so does DSSL_ENGINE', envEngine.status === 0, envEngine.stderr);
272
+ const badEngine = spawnSync(process.execPath, ['--no-warnings', path.join(lone, 'tool/dssl.mjs'), 'check', path.join(lone, 'x.dssl'), '--engine', lone], { encoding: 'utf8' });
273
+ ok('a folder that is not the engine is refused in plain words (exit 2)', badEngine.status === 2 && /no parser\.js/.test(badEngine.stderr), badEngine.stderr);
274
+ }
275
+
276
+ section('10. DSSL.lint is quieter and fairer');
277
+ await withBrowser(async ({ open, errors }) => {
278
+ const p = await open('/l'); await ready(p); await p.waitForTimeout(300);
279
+ const res = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).map((f) => ({ kind: f.kind, sel: f.selector })));
280
+ const bad = (cls) => res.some((f) => f.kind === 'contrast' && f.sel.includes(cls));
281
+ ok('text in a closed menu (opacity 0) is not measured against itself', !bad('.closed'), JSON.stringify(res));
282
+ ok('text with a hard outline that sets the letters apart is fine', !bad('.stroked'), JSON.stringify(res));
283
+ ok('text with a tight contrasting shadow is fine', !bad('.shadowed'), JSON.stringify(res));
284
+ ok('a soft glow shadow does not rescue unreadable text', bad('.glow'), JSON.stringify(res));
285
+ ok('plain unreadable text is still reported', bad('.plain'), JSON.stringify(res));
286
+ ok('no page errors', errors.length === 0, errors.join(' | '));
287
+ }, { pages: { '/l': page('.stage { background: #c8c8c8; color: #222 }\n.plain { color: #d0d0d0 }\n.closed { color: #d0d0d0; opacity: 0 }\n.stroked { color: #fff; text-stroke: 3 #000 }\n.shadowed { color: #fff; text-shadow: 1px 1px 0 #000 }\n.glow { color: #d8d8d8; text-shadow: 0 0 12px #000 }', '<div class=stage><p class=plain>plain</p><p class=closed>closed</p><p class=stroked>stroked</p><p class=shadowed>shadowed</p><p class=glow>glow</p></div>') } });
288
+
289
+ section('11. test helpers: pause, step, resume');
290
+ await withBrowser(async ({ open, errors }) => {
291
+ const p = await open('/t'); await ready(p); await p.waitForTimeout(150);
292
+ const tx = () => p.evaluate(() => parseFloat(DSSL.computed(document.getElementById('s'), 'translate')) || 0);
293
+ await p.evaluate(() => DSSL.pauseMotion());
294
+ const a = await tx(); await p.waitForTimeout(300); const b = await tx();
295
+ ok('pauseMotion() holds every animation still', a === b, `${a} vs ${b}`);
296
+ await p.evaluate(() => DSSL.stepMotion(250));
297
+ const c = await tx();
298
+ ok('stepMotion(ms) moves them forward by exactly that, whatever the clock says', Math.abs((c - b) - 50) < 4, `moved ${c - b} (slide is 200px in 1000ms, so 250ms is 50px)`);
299
+ await p.evaluate(() => DSSL.stepMotion(250));
300
+ ok('and again', Math.abs((await tx()) - c - 50) < 4);
301
+ await p.evaluate(() => { const d = document.createElement('div'); d.id = 'late'; d.className = 'go'; document.body.appendChild(d); });
302
+ const held = await p.evaluate(async () => { await DSSL.settled(); const a = document.getElementById('late').getAnimations()[0]; return a ? a.playState : 'none'; });
303
+ ok('an element styled while paused starts paused too', held === 'paused', held);
304
+ await p.evaluate(() => DSSL.resumeMotion());
305
+ await p.waitForTimeout(200);
306
+ ok('resumeMotion() lets them run again', (await tx()) !== c + 50 && await p.evaluate(() => document.getElementById('s').getAnimations()[0].playState) === 'running');
307
+ ok('no page errors', errors.length === 0, errors.join(' | '));
308
+ }, { pages: { '/t': page('motion slide { from { translate: 0 0 } to { translate: 200 0 } }\n.go { animate: slide 1000 linear infinite }', '<div id=s class=go></div>') } });
309
+
310
+ section('12. docs and version');
311
+ {
312
+ const read = (f) => readFileSync(path.join(ROOT, f), 'utf8');
313
+ const API = await import('../public/dssl/index.js');
314
+ ok('the engine says 0.15.0', API.VERSION === '0.15.0');
315
+ for (const f of ['GUIDE.md', 'README.md', 'llms.txt', 'PARITY.md', 'src/components/dssl/views/ChangelogView.tsx', 'dssl-vscode/CHANGELOG.md']) ok(`${f} has a 0.15.0 section`, /0\.15\.0/.test(read(f)));
316
+ const guide = read('GUIDE.md');
317
+ for (const w of ['outline-shape', 'DSSL.settled', 'DSSL.signal(\'hp\'', 'pauseMotion', '--engine', 'z 5']) ok(`the guide covers ${w}`, guide.includes(w));
318
+ ok('llms.txt and public/llms.txt are the same file', read('llms.txt') === read('public/llms.txt'));
319
+ }
320
+
321
+ console.log(`\n${pass} passed, ${fail} failed`);
322
+ process.exit(fail ? 1 : 0);
scripts/test-v090.mjs CHANGED
@@ -301,7 +301,7 @@ section('dssl build: what stays in the runtime');
301
 
302
  /* ------------------------------------------------------------------ */
303
  section('release');
304
- ok('the engine version is 0.9 or later', /^0\.(9|1[0-4])\./.test(VERSION), VERSION);
305
 
306
  console.log(`\n${pass} passed, ${fail} failed`);
307
  process.exit(fail ? 1 : 0);
 
301
 
302
  /* ------------------------------------------------------------------ */
303
  section('release');
304
+ ok('the engine version is 0.9 or later', /^0\.(9|1[0-5])\./.test(VERSION), VERSION);
305
 
306
  console.log(`\n${pass} passed, ${fail} failed`);
307
  process.exit(fail ? 1 : 0);
src/components/dssl/views/ChangelogView.tsx CHANGED
@@ -11,6 +11,20 @@ const ENTRIES: {
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  {
15
  version: "v0.14.0",
16
  date: "growth phase \u00b7 made for games",
 
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
14
+ {
15
+ version: "v0.15.0",
16
+ date: "growth phase \u00b7 the second game review",
17
+ title: "Smoke that stops, scoped signals, and rules that tell you they never win.",
18
+ why: "the builder came back after shipping a real game and listed what still hurt. Most of it was aura edge cases and silent losses.",
19
+ kind: "add",
20
+ body:
21
+ "Aura keeps up with moving things under reduced motion (it used to redraw twice a second), stops emitting from anything you cannot see (opacity 0, display none, visibility hidden, on it or any ancestor), and takes z 5 to sit under a hill. There is no specificity, so a rule in an earlier file that a later rule always beats used to lose without a word; dssl check, the console and DSSL.lint now name both rules. Signals keep their type, DSSL.signal(name, value, el) scopes one to a subtree, and a signal restyles only the elements its rules select. await DSSL.settled() makes styling final the moment you need it, DSSL.computed(el, prop) reads the real value, and DSSL.pauseMotion(), stepMotion(ms) and resumeMotion() freeze and step every animation for tests. animate takes a list (blink and waddle on one element, also on ::before and in dssl build), DSSL.play takes a pseudo element, outline-shape draws an outline that follows the picture's alpha, rotate: 5 is 5deg, a state can sit inside ::after, and a CSS-style font: is now an error that says what DSSL's font: means. DSSL.lint skips invisible text and respects text-stroke and tight shadows. The dssl command takes --engine ./dssl. Not verified: Firefox and Safari.",
22
+ code: `.duck { animate: blink 300 infinite, waddle 900 infinite alternate }
23
+ .sprite { outline-shape: 3 color.ink }
24
+ .btn { ::after { content: ""; opacity: 0; hover { opacity: 1 } } }
25
+ DSSL.signal('screen', 'shop', paneB)
26
+ await DSSL.settled()`,
27
+ },
28
  {
29
  version: "v0.14.0",
30
  date: "growth phase \u00b7 made for games",
src/lib/vocab.json CHANGED
@@ -346,6 +346,14 @@
346
  {
347
  "key": "unclipped",
348
  "doc": "let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself"
 
 
 
 
 
 
 
 
349
  }
350
  ],
351
  "particleKeys": [
@@ -428,6 +436,14 @@
428
  {
429
  "key": "unclipped",
430
  "doc": "let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge"
 
 
 
 
 
 
 
 
431
  }
432
  ],
433
  "sliderClauses": [
 
346
  {
347
  "key": "unclipped",
348
  "doc": "let the light spill out of a parent that clips (overflow: hidden, auto, scroll). Without it the aura is cut at that edge, like the element itself"
349
+ },
350
+ {
351
+ "key": "always",
352
+ "doc": "keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of"
353
+ },
354
+ {
355
+ "key": "z",
356
+ "doc": "the z-index of the layer this light is drawn on: `z 5` puts it under a hill with z-index 10. It meets elements in the page's root stacking context"
357
  }
358
  ],
359
  "particleKeys": [
 
436
  {
437
  "key": "unclipped",
438
  "doc": "let the particles leave a parent that clips (overflow: hidden, auto, scroll). Without it they are cut at that edge"
439
+ },
440
+ {
441
+ "key": "always",
442
+ "doc": "keep emitting even when the element cannot be seen (opacity 0, display: none, visibility: hidden, on it or an ancestor): for an invisible anchor that smoke or sparks come out of"
443
+ },
444
+ {
445
+ "key": "z",
446
+ "doc": "the z-index of the layer these particles are drawn on: `z 5` puts them under a hill with z-index 10. It meets elements in the page's root stacking context"
447
  }
448
  ],
449
  "sliderClauses": [