EHS-Studio Claude Sonnet 5.5 commited on
Commit
e6c76da
·
1 Parent(s): 6c00f0c

v0.14.0: made for games (fixes and features from a game-building agent's review)

Browse files

Reduced motion is no longer silent (console notice, essential, DSSL.motion). animate: gains delay,
jitter, iterations, essential; motions are shared across files; keyframes accept 0%; every motion is
a real @keyframes ; animate: works on pseudo-elements; DSSL.play(). Layered gradient sentences,
text-stroke, bare custom shadow/radius tokens, paint-order and conic-gradient(from ...). Author
inline styles win; static subtrees; aura clip/scale/z; @signal in/not in/!=; DSSL.lint(root);
dssl check/build take an index.html. Docs checked against the real API by test-v0140.

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

GUIDE.md CHANGED
@@ -18,7 +18,7 @@ hand-rolled CSS for creative pages, docs, playgrounds and internal tools.
18
 
19
  | Path | What it is |
20
  |---|---|
21
- | `public/dssl/` | The engine. Six zero-dependency ESM files: `parser.js`, `resolver.js`, `engine.js`, `index.js` (public API), `utils.js`, `boot.js`. This is the whole language. |
22
  | `public/styles/*.dssl` | The stylesheets that drive the docs site itself. The site is styled entirely by its own engine, which makes it the de-facto test suite. |
23
  | `public/examples/hello.dssl` | The starter stylesheet. Small, commented, every interaction real. |
24
  | `package/` + `public/install.sh`, `install.ps1` | The downloadable engine package: `curl -fsSL https://dssl-sable.vercel.app/install.sh \| sh`. |
@@ -425,6 +425,89 @@ engine is ready: the engine adds the class `dssl-ready` to `<html>` when it has
425
  <script>setTimeout(() => document.documentElement.classList.add('dssl-ready'), 2500)</script> <!-- failsafe -->
426
  ```
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  ### Fonts
429
 
430
  ```dssl
@@ -706,17 +789,27 @@ and listed, so the file you get always compiles.
706
  ## 4. The engine API
707
 
708
  ```js
709
- import { DSSL } from '/dssl/index.js'
710
 
711
- DSSL.version() // "0.7.2"
712
- DSSL.compile(source) // { ok, errors, warnings, stats, doc }
713
  DSSL.apply(source, { name, scope, replace })
714
  await DSSL.load(['a.dssl', 'b.dssl'])
715
- DSSL.signal('theme', 'dark') // drives @signal rules
716
- DSSL.refresh() // full recompute
717
- DSSL.freeze() / DSSL.unfreeze() // static snapshot: ambient restyling off
718
- DSSL.explain(el) // which rules matched, what won, why
719
- DSSL.errors() // last compile errors
 
 
 
 
 
 
 
 
 
 
720
  ```
721
 
722
  `freeze()` is the big performance lever for static pages: the engine drops its
@@ -729,7 +822,7 @@ Three ways to get it:
729
 
730
  1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
731
  https://open-vsx.org/extension/ehs-studio/dssl-vscode
732
- 2. VS Code: download `dssl-vscode/dssl-vscode-0.13.0.vsix` from the repo files, then
733
  Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
734
  3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
735
 
@@ -885,6 +978,7 @@ node scripts/test-browser-v090.mjs # 0.9.0 in Chrome: pixels, lifecycle, re
885
  node scripts/test-static-parity.mjs # 0.13.0: every page of this site, built CSS with JavaScript off vs the engine, element by element (needs npm run build)
886
  node scripts/test-v0130.mjs # 0.13.0: build fidelity, source maps, strict CSP, server rendering
887
  node scripts/bench-compiled.mjs # 0.13.0: compiled DSSL vs hand-written CSS, startup and churn (takes minutes)
 
888
  node scripts/test-v0100.mjs # 0.10.0: glass, tilt, named-colour alpha, playground examples, HTML filter
889
  node scripts/test-designer.mjs # the designer's state to DSSL and back, plus a random sweep
890
  node scripts/test-designer-browser.mjs # the designer panel, driven like a person would
 
18
 
19
  | Path | What it is |
20
  |---|---|
21
+ | `public/dssl/` | The engine: plain ESM files with no dependencies, loaded by one script tag. `parser.js` (text to tree), `resolver.js` (tree to rules), `engine.js` (applies them in the browser), `index.js` (the public API), `boot.js` (auto-boot), `utils.js`, `static.js` (`dssl build`), `server.js` (the same compiler with no DOM), `texture.js`, `aura.js`, `slider.js`, `effects.js` (glass, tilt), `color.js`, `lint.js`, `import.js` (CSS to DSSL), `fonts.js`, `dev.js` (inspector, hot reload, runtime lint; loaded only when asked for). This is the whole language. |
22
  | `public/styles/*.dssl` | The stylesheets that drive the docs site itself. The site is styled entirely by its own engine, which makes it the de-facto test suite. |
23
  | `public/examples/hello.dssl` | The starter stylesheet. Small, commented, every interaction real. |
24
  | `package/` + `public/install.sh`, `install.ps1` | The downloadable engine package: `curl -fsSL https://dssl-sable.vercel.app/install.sh \| sh`. |
 
425
  <script>setTimeout(() => document.documentElement.classList.add('dssl-ready'), 2500)</script> <!-- failsafe -->
426
  ```
427
 
428
+ ### Using it for a game (0.14.0)
429
+
430
+ A review by someone building a game on DSSL turned up twelve problems and a list of wishes. This is the language now, in the
431
+ order they hit.
432
+
433
+ **Motion**
434
+
435
+ ```dssl
436
+ motion bob { from { translate: 0 0 } to { translate: 0 8 } }
437
+ motion pulse { from { opacity: 1 } to { opacity: .4 } }
438
+ motion boing { 0%, 100% { translate: 0 0 } 40% { translate: 0 -18 } essential } // 0% and 40% work, a list works
439
+ .coin { animate: bob 1200 infinite alternate delay 100 jitter 600 } // jitter: a different delay per element
440
+ .hero { animate: boing 300 iterations 3 essential } // essential for this use only
441
+ .spark::before { content: ""; animate: pulse 900 infinite } // motion on a pseudo-element
442
+ ```
443
+
444
+ - **Reduced motion is never silent.** A system that asks for reduced motion stops every `animate:` that is not `essential`, which
445
+ for a game is all of it. The engine now says so, once, in the console, and there are three ways out: `essential` inside the
446
+ motion, `essential` on one `animate:` line, or for the whole page `<html data-dssl-motion="full">` (`DSSL.motion('full')`;
447
+ `'reduced'` holds everything still, `'auto'` goes back to following the system).
448
+ - **Motions are shared like tokens.** `motion waddle { }` in one file is usable by `animate: waddle` in any file loaded after it
449
+ (`dssl check` and `dssl build` fold them forward the same way). No more pasting a motion into three files.
450
+ - **`animation-delay` and friends** (`-duration`, `-iteration-count`...) only tune a CSS animation. A rule using `animate:` has none,
451
+ so they used to compile and do nothing. Now there is a warning that names the replacement: `delay N`, `jitter N`, `iterations N`,
452
+ `infinite`, `alternate`, an easing, `essential`. And every motion is also written as a real `@keyframes` into the engine's own
453
+ stylesheet, so the plain CSS form `animation: boing 300ms 2` works too.
454
+ - **`DSSL.play(el, 'boing', { duration, delay, easing, iterations, hold, jitter, essential })`** runs a motion once and returns a
455
+ promise that settles when it ends (or is cancelled). `hold: true` keeps the last frame. It follows reduced motion like `animate:`.
456
+ This replaces add a class, set a timer, remove the class.
457
+ - **Keyframes in CSS habits:** `0% { }`, `40% { }`, and `0%, 100% { }` are accepted next to `from`, `to` and `at 40%`.
458
+ - **Motion on `::before` and `::after`** becomes a CSS animation, behind the reduced-motion check (and `dssl build` writes it).
459
+
460
+ **Looks**
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
468
+ .label { paint-order: stroke fill } // stroke and fill are accepted words
469
+ ```
470
+
471
+ - **`gradient:` and `background-image:` in one rule** replace each other, and the first used to vanish without a word. Now it is a
472
+ warning that tells you to layer them in the sentence: layers are joined with `+`, and a layer can be a plain CSS
473
+ `radial-gradient(...)` or `url(...)`. Each layer takes `tile 24` (or `tile 24 12`), `offset 4 4`, `no-repeat`, `repeat-x`, `repeat-y`.
474
+ - **`shadow: pop` and `radius: pill`** work for tokens you define (`tokens { shadow { pop: 0 4 0 #000 } radius { pill: 99 } }`), like
475
+ the built-in `sm`, `md`, `lg`.
476
+ - **`text-stroke: 6 color.ink`** is the stroke-plus-`paint-order` trick written once (`inside` keeps a centred stroke).
477
+
478
+ **The engine and your scripts**
479
+
480
+ - **Your inline styles win.** What the page wrote before the engine looked (a `style=""` attribute, a script placing an element) and what a
481
+ script changes afterwards belongs to you: a rule with `left: 0` no longer undoes the positions your game code sets, exactly like
482
+ in CSS. (The few properties the engine also moves itself, `transform`, `translate`, `transform-origin` and `grid-area`, are exempt
483
+ from the after-the-fact part.)
484
+ - **Static subtrees.** `<div data-dssl-static>` or `DSSL.freeze(el)` styles a subtree once and leaves it alone: a class toggled on a
485
+ parent, a signal, a mutation no longer walk it. A full recompute (new stylesheet, a media query flipping) still reaches it, and
486
+ `DSSL.refresh(el)` restyles it on demand. `DSSL.unfreeze(el)` takes the mark off. `DSSL.freeze()` with no argument still parks the whole page.
487
+ - **Aura follows its stage.** It is cut at the edge of any parent that clips (`overflow: hidden`, `auto`, `scroll`; say `unclipped` to
488
+ let it spill), its size and speed follow an ancestor `transform: scale()`, and `DSSL.auraZ(20)` (or `<html data-dssl-aura-z="20">`)
489
+ lowers the canvas under a menu that has a higher z-index.
490
+ - **No more `folded border-color into border` in the console.** It was the normal, correct merge, logged on every recompute.
491
+
492
+ **Signals**
493
+
494
+ ```dssl
495
+ @signal screen in (menu, shop, settings) { .overlay { opacity: 1 } }
496
+ @signal screen not in (playing) { .hud { display: none } }
497
+ @signal screen != menu { .back { display: block } }
498
+ ```
499
+
500
+ **Checking**
501
+
502
+ - `dssl check index.html` (and `dssl build index.html`) read the stylesheets from the page in the order it loads them: every
503
+ `<link rel="stylesheet/dssl">` and inline `<style type="text/dssl">`. No script of your own to keep the order.
504
+ - `await DSSL.lint(root?)` checks what a stylesheet linter cannot, because it needs the page: **text against the colour it really sits
505
+ on** (its own background or the nearest ancestor's, through transparency; the cream text that inherited onto a cream card), boxes the
506
+ engine had to turn into grids, and motion held back by reduced motion. `{ min: 3 }` changes the limit, `{ images: true }` also judges
507
+ text over images and gradients, `{ log: false }` stays quiet.
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
 
789
  ## 4. The engine API
790
 
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
804
+ DSSL.freeze() / DSSL.unfreeze() // the whole page: ambient restyling off
805
+ DSSL.freeze(el) / DSSL.unfreeze(el) // one subtree: styled once, then left alone
806
+ DSSL.explain(el) // which rules matched, what won, why (console)
807
+ await DSSL.inspect() // the same, as a panel you hover with
808
+ await DSSL.watch() // hot reload of loaded .dssl and linked .css
809
+ await DSSL.lint(root) // problems only a live page shows (contrast on the real background, ...)
810
+ DSSL.play(el, 'boing', opts) // run a motion once from JS; resolves when it ends
811
+ DSSL.motion('full' | 'reduced' | 'auto') // reduced-motion override
812
+ DSSL.auraZ(20) // z-index of the aura canvas
813
  ```
814
 
815
  `freeze()` is the big performance lever for static pages: the engine drops its
 
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
 
 
978
  node scripts/test-static-parity.mjs # 0.13.0: every page of this site, built CSS with JavaScript off vs the engine, element by element (needs npm run build)
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
PARITY.md CHANGED
@@ -62,6 +62,27 @@ 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.13.0: the compiled page is the page
66
 
67
  | Promise | Status | Evidence |
 
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`.
68
+
69
+ | Review item | Status |
70
+ |---|---|
71
+ | Reduced motion silently kills every `animate:` | done: console says so once; `essential`, `DSSL.motion('full')`, `data-dssl-motion` |
72
+ | `gradient:` and `background-image:` overwrite each other | done: warning; layers with `+` |
73
+ | `animation-delay` etc. compile and do nothing | done: warning that names the replacement; `delay`/`jitter`/`iterations` words |
74
+ | Motions scoped to one file | done: shared registry, same as tokens |
75
+ | `paint-order: stroke fill`, `conic-gradient(from 0deg ...)` rejected | done |
76
+ | `shadow: pop`, `radius: pill` for custom tokens | done |
77
+ | Console noise "folded border-color" | done: removed |
78
+ | Lint misses cross-rule contrast, false align warning | done: `DSSL.lint(root)` computes it on the live page; the align warning moved to `dssl build`'s report where it applies |
79
+ | Docs drift | done: docs checked by a test against the real API and file list |
80
+ | `0% { }` keyframes | done |
81
+ | Engine overwrites inline styles | done: author styles win |
82
+ | Static subtree, play(), scoped aura, `check index.html`, richer `@signal`, gradient sugar, motions on pseudo-elements, `text-stroke`, per-element variation, runtime lint | done |
83
+
84
+ Not verified: Firefox and Safari (all checks ran in Chrome). The engine is still not at CSS parity for startup (see v0.13.0 below).
85
+
86
  ## v0.13.0: the compiled page is the page
87
 
88
  | Promise | Status | Evidence |
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.13.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
 
@@ -453,13 +453,20 @@ v0.7 added warnings: things that compile, run, and are still probably not what y
453
  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.signal('theme', 'dark') // the JS → styles bridge
 
457
  DSSL.replay(sel) // re-run animations
458
- DSSL.refresh() // full recompute (rarely needed)
459
- DSSL.errors() // structured errors from the last apply
460
- DSSL.freeze() / DSSL.unfreeze() // the speed escape hatch, next section
461
- DSSL.explain(el) // the DevTools panel, next section
462
- DSSL.version // '0.7.0'
 
 
 
 
 
463
  ```
464
 
465
  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.
@@ -488,7 +495,7 @@ semicolons are required inside it, and DSSL warns if you forget one), and
488
  360px wide window to 24px on a 1200px wide one. `dssl build --strict` makes
489
  warnings fail the build, for CI.
490
 
491
- ## Importing CSS, and the designer (v0.9.0)
492
 
493
  ```sh
494
  node cli/dssl.mjs import styles.css -o styles.dssl
@@ -535,7 +542,7 @@ const rep = DSSL.explain(document.querySelector('.doc-card'))
535
 
536
  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.
537
 
538
- ## What works today (v0.9.0)
539
 
540
  - the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
541
  - 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
@@ -575,7 +582,7 @@ The matcher also pre-filters every rule through a cheap class/tag/id gate extrac
575
 
576
  ## Status
577
 
578
- v0.9.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.
579
 
580
  ## License
581
 
 
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
 
 
453
  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
462
+ DSSL.freeze() / DSSL.unfreeze() // park the whole page; with an element, one static subtree
463
+ DSSL.explain(el) // which rules won (console)
464
+ await DSSL.inspect() / DSSL.watch() // an in-page inspector; hot reload
465
+ await DSSL.lint(root) // contrast against the real background, boxes the engine made
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.
 
495
  360px wide window to 24px on a 1200px wide one. `dssl build --strict` makes
496
  warnings fail the build, for CI.
497
 
498
+ ## Importing CSS, and the designer
499
 
500
  ```sh
501
  node cli/dssl.mjs import styles.css -o styles.dssl
 
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
 
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
 
cli/dssl.mjs CHANGED
@@ -45,11 +45,12 @@ const { importCss } = await import('file://' + join(engineDir, 'import.js'));
45
 
46
  const USAGE = `dssl v${VERSION} - the DSSL style language
47
 
48
- dssl check <files…> compile with the real engine, print diagnostics
 
49
  --json machine-readable output
50
  --strict warnings are errors too (exit 1)
51
  --quiet only print diagnostics, no summary
52
- dssl build <files…> compile the static subset to a real .css file (several files: in load order, tokens first)
53
  -o <out.css> output path (default: <first file>.css)
54
  --sourcemap write <out.css>.map so devtools show the .dssl file and line of each rule
55
  --map-name <f> file name for the map when your host will not serve *.map (default <out.css>.map)
@@ -87,24 +88,64 @@ const readSource = (file) => {
87
  }
88
  };
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  /** compile files in order, folding tokens forward like the browser does */
91
  function compileAll(files) {
92
  const out = [];
93
- let tokenBase = null;
94
- for (const file of files) {
95
- const src = readSource(file);
96
- const name = basename(file);
 
 
97
  const { ast, errors: parseErrors } = parse(src, name);
98
  let doc;
99
  try {
100
- doc = resolve(ast, name, tokenBase);
101
  } catch (e) {
102
  // the compiler must never throw - this is a bug report if it happens
103
  doc = { rules: [], shadowRules: [], raws: [], errors: [{ file: name, line: 0, message: `internal compiler error: ${e.message}`, hint: 'please report this - a snippet that crashes the compiler is a bug in DSSL, not in your code' }], warnings: [], tokens: {} };
104
  }
105
  const all = [...parseErrors.map(e => ({ ...e, file: name })), ...doc.errors.map(e => ({ ...e, file: e.file || name }))];
106
- out.push({ file, name, doc, errors: all, warnings: doc.warnings || [] });
107
  tokenBase = { ...(tokenBase || {}), ...doc.tokens };
 
108
  }
109
  return out;
110
  }
@@ -148,7 +189,7 @@ if (cmd === 'check') {
148
  const strict = args.includes('--strict');
149
  const quiet = args.includes('--quiet');
150
  for (const f of ['--json', '--strict', '--quiet']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
151
- if (!args.length) { console.error('dssl check: give me at least one .dssl file'); process.exit(2); }
152
  const results = compileAll(args);
153
  if (json) {
154
  console.log(JSON.stringify({
@@ -209,11 +250,12 @@ if (cmd === 'dev') {
209
  if (!args.length) { console.error('dssl build: give me a .dssl file to build (or several, in load order: tokens first)'); process.exit(2); }
210
  const inputs = [...args];
211
  const results = compileAll(inputs);
 
212
  const errors = results.flatMap((x) => x.errors), warnings = results.flatMap((x) => x.warnings);
213
  const outputPath = outPath || (inputs[0].replace(/\.dssl$/i, '') + '.css');
214
  const outDir = dirname(pathResolve(outputPath));
215
  const sources = {};
216
- for (const x of results) sources[x.name] = { path: pathRelative(outDir, pathResolve(x.file)).split('\\').join('/'), content: readFileSync(x.file, 'utf8') };
217
  const built = toCSS(results.map((x) => x.doc), {
218
  file: inputs.map((f) => basename(f)).join(' + '), banner, version: VERSION, reset, nativeSpecificity, inlineTokens,
219
  sourceMap: sourcemap, sources, mapFile: basename(outputPath), sourceMapUrl: sourcemap ? (mapName || basename(outputPath) + '.map') : undefined,
@@ -240,6 +282,11 @@ if (cmd === 'dev') {
240
  if (rep.seenStripped) console.log(` ${rep.seenStripped} seen rule(s) applied unconditionally (the no-JS state)`);
241
  if (rep.bakedTextures) console.log(` ${rep.bakedTextures} texture(s) baked into the CSS as background images`);
242
  for (const n of rep.notBaked || []) console.log(` texture on "${n.sel}" stays in the runtime: ${n.why}`);
 
 
 
 
 
243
  if (rep.skippedSignals || rep.engineDecls) {
244
  const kinds = rep.engineKinds.length ? ` (${rep.engineKinds.join(', ')})` : '';
245
  console.log(` stays in the runtime: ${rep.skippedSignals} @signal rule(s), ${rep.engineDecls} engine-native declaration(s)${kinds}`);
 
45
 
46
  const USAGE = `dssl v${VERSION} - the DSSL style language
47
 
48
+ dssl check <files…> compile with the real engine, print diagnostics. A page (index.html) works too:
49
+ it takes the stylesheets in the order the page loads them
50
  --json machine-readable output
51
  --strict warnings are errors too (exit 1)
52
  --quiet only print diagnostics, no summary
53
+ dssl build <files…> (or a page, index.html) compile the static subset to a real .css file (several files: in load order, tokens first)
54
  -o <out.css> output path (default: <first file>.css)
55
  --sourcemap write <out.css>.map so devtools show the .dssl file and line of each rule
56
  --map-name <f> file name for the map when your host will not serve *.map (default <out.css>.map)
 
88
  }
89
  };
90
 
91
+ /**
92
+ * 0.14.0: `dssl check index.html` and `dssl build index.html` read the page and take its stylesheets in the order the browser
93
+ * loads them: every <link rel="stylesheet/dssl"> and every inline <style type="text/dssl"> (or <script type="text/dssl">),
94
+ * top to bottom. Relative links are resolved against the page's folder; a leading / is tried against that folder first.
95
+ * Nobody keeps a second list of files in a script any more, and the order cannot drift from what the runtime does.
96
+ */
97
+ function expandInputs(args) {
98
+ const out = [];
99
+ for (const a of args) {
100
+ if (!/\.html?$/i.test(a)) { out.push(a); continue; }
101
+ const html = readSource(a);
102
+ const dir = dirname(pathResolve(a));
103
+ const found = [];
104
+ const re = /<link\b[^>]*>|<(style|script)\b[^>]*type\s*=\s*["']text\/dssl["'][^>]*>([\s\S]*?)<\/\1>/gi;
105
+ let n = 0, m;
106
+ while ((m = re.exec(html))) {
107
+ if (m[0].toLowerCase().startsWith('<link')) {
108
+ if (!/rel\s*=\s*["']stylesheet\/dssl["']/i.test(m[0])) continue;
109
+ const href = (m[0].match(/href\s*=\s*["']([^"']+)["']/i) || [])[1];
110
+ if (!href || /^(https?:)?\/\//i.test(href)) continue;
111
+ const clean = href.split(/[?#]/)[0];
112
+ const cands = [join(dir, clean), join(dir, clean.replace(/^\//, '')), pathResolve(clean)];
113
+ const hit = cands.find((c) => existsSync(c));
114
+ if (!hit) { console.error(`dssl: ${a} links ${href}, which is not on disk (looked in ${dir})`); process.exit(2); }
115
+ found.push(hit);
116
+ } else {
117
+ n++;
118
+ const nm = (m[0].match(/data-dssl-name\s*=\s*["']([^"']+)["']/i) || [])[1] || `${basename(a)}#inline-${n}`;
119
+ found.push({ name: nm, text: m[2], file: pathResolve(a) });
120
+ }
121
+ }
122
+ if (!found.length) { console.error(`dssl: ${a} has no <link rel="stylesheet/dssl"> or <style type="text/dssl"> in it`); process.exit(2); }
123
+ out.push(...found);
124
+ }
125
+ return out;
126
+ }
127
+
128
  /** compile files in order, folding tokens forward like the browser does */
129
  function compileAll(files) {
130
  const out = [];
131
+ let tokenBase = null, motionBase = {};
132
+ for (const item of expandInputs(files)) {
133
+ const inline = typeof item === 'object';
134
+ const file = inline ? item.file : item;
135
+ const src = inline ? item.text : readSource(file);
136
+ const name = inline ? item.name : basename(file);
137
  const { ast, errors: parseErrors } = parse(src, name);
138
  let doc;
139
  try {
140
+ doc = resolve(ast, name, tokenBase, motionBase);
141
  } catch (e) {
142
  // the compiler must never throw - this is a bug report if it happens
143
  doc = { rules: [], shadowRules: [], raws: [], errors: [{ file: name, line: 0, message: `internal compiler error: ${e.message}`, hint: 'please report this - a snippet that crashes the compiler is a bug in DSSL, not in your code' }], warnings: [], tokens: {} };
144
  }
145
  const all = [...parseErrors.map(e => ({ ...e, file: name })), ...doc.errors.map(e => ({ ...e, file: e.file || name }))];
146
+ out.push({ file, name, doc, errors: all, warnings: doc.warnings || [], text: src });
147
  tokenBase = { ...(tokenBase || {}), ...doc.tokens };
148
+ motionBase = { ...motionBase, ...(doc.motionFrames || {}) };
149
  }
150
  return out;
151
  }
 
189
  const strict = args.includes('--strict');
190
  const quiet = args.includes('--quiet');
191
  for (const f of ['--json', '--strict', '--quiet']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
192
+ if (!args.length) { console.error('dssl check: give me at least one .dssl file, or a page (index.html) to read the stylesheet order from'); process.exit(2); }
193
  const results = compileAll(args);
194
  if (json) {
195
  console.log(JSON.stringify({
 
250
  if (!args.length) { console.error('dssl build: give me a .dssl file to build (or several, in load order: tokens first)'); process.exit(2); }
251
  const inputs = [...args];
252
  const results = compileAll(inputs);
253
+ inputs.length = 0; inputs.push(...results.map((x) => x.file));
254
  const errors = results.flatMap((x) => x.errors), warnings = results.flatMap((x) => x.warnings);
255
  const outputPath = outPath || (inputs[0].replace(/\.dssl$/i, '') + '.css');
256
  const outDir = dirname(pathResolve(outputPath));
257
  const sources = {};
258
+ for (const x of results) sources[x.name] = { path: pathRelative(outDir, pathResolve(x.file)).split('\\').join('/'), content: x.text };
259
  const built = toCSS(results.map((x) => x.doc), {
260
  file: inputs.map((f) => basename(f)).join(' + '), banner, version: VERSION, reset, nativeSpecificity, inlineTokens,
261
  sourceMap: sourcemap, sources, mapFile: basename(outputPath), sourceMapUrl: sourcemap ? (mapName || basename(outputPath) + '.map') : undefined,
 
282
  if (rep.seenStripped) console.log(` ${rep.seenStripped} seen rule(s) applied unconditionally (the no-JS state)`);
283
  if (rep.bakedTextures) console.log(` ${rep.bakedTextures} texture(s) baked into the CSS as background images`);
284
  for (const n of rep.notBaked || []) console.log(` texture on "${n.sel}" stays in the runtime: ${n.why}`);
285
+ if (rep.needsBox && rep.needsBox.length) {
286
+ console.log(` ${rep.needsBox.length} rule(s) align their children without saying display, so the engine would make them grids and this CSS will not:`);
287
+ for (const n of rep.needsBox.slice(0, 8)) console.log(` ${n.file || ''}:${n.line} ${n.sel} { ${n.prop} } -> add display: grid (or flex)`);
288
+ if (rep.needsBox.length > 8) console.log(` ...and ${rep.needsBox.length - 8} more`);
289
+ }
290
  if (rep.skippedSignals || rep.engineDecls) {
291
  const kinds = rep.engineKinds.length ? ` (${rep.engineKinds.join(', ')})` : '';
292
  console.log(` stays in the runtime: ${rep.skippedSignals} @signal rule(s), ${rep.engineDecls} engine-native declaration(s)${kinds}`);
dssl-vscode/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
3
  ## 0.13.0
4
 
5
  - Bundled engine updated to 0.13.0. In the editor: a new warning for a rule that aligns its children (`place-items`, `justify-content`...)
 
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
6
+ documented on hover; `animate:` now documents `delay`, `jitter`, `iterations` and `essential`; `text-stroke` and layered
7
+ `gradient:` (`+`, `tile`, `offset`, `no-repeat`) have hover docs; `animate:` inside `::before`/`::after` is no longer an error.
8
+ (The editor still checks one file at a time, so a motion defined in another file shows as unknown there; `dssl check index.html` checks them together.)
9
+
10
  ## 0.13.0
11
 
12
  - Bundled engine updated to 0.13.0. In the editor: a new warning for a rule that aligns its children (`place-items`, `justify-content`...)
dssl-vscode/lib/engine/aura.js CHANGED
@@ -48,6 +48,7 @@ export const GLOW_KEYS = {
48
  seed: num(0, 99999, { int: true, doc: 'a different random flicker with the same look' }),
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
  };
52
 
53
  export const PARTICLE_KEYS = {
@@ -70,6 +71,7 @@ export const PARTICLE_KEYS = {
70
  seed: num(0, 99999, { int: true, doc: 'a different random pattern with the same look' }),
71
  texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
72
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
 
73
  };
74
 
75
  /* ------------------------------------------------------------------ *
@@ -392,7 +394,10 @@ export class AuraManager {
392
  const c = document.createElement('canvas');
393
  c.setAttribute('aria-hidden', 'true');
394
  c.setAttribute('data-dssl-aura', '');
395
- c.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483000;';
 
 
 
396
  (document.body || document.documentElement).appendChild(c);
397
  this.canvas = c;
398
  this.ctx = c.getContext('2d');
@@ -502,11 +507,19 @@ export class AuraManager {
502
  if (rect.width === 0 && rect.height === 0) rect = null;
503
  }
504
  em.rect = rect;
505
- if (em.radiusAt < 0 || (em.age++ % 45) === 0) { em.radius = this.cornerRadius(el, rect); em.radiusAt = 0; }
 
 
 
 
506
 
507
  if (em.dying) em.fade = Math.max(0, em.fade - dt * 3);
508
  else em.fade = Math.min(1, em.fade + dt * 4);
509
 
 
 
 
 
510
  // glow
511
  if (rect && em.fade > 0 && (em.visible || em.dying)) {
512
  for (const g of em.glow) { this.drawGlow(ctx, rect, em, g, now, reduced); }
@@ -517,11 +530,13 @@ export class AuraManager {
517
  for (const L of em.layers) {
518
  const cfg = L.cfg;
519
  const moving = !reduced || cfg.essential;
 
520
  if (rect && em.visible && !em.dying && moving) this.emit(L, rect, em, dt, share, el);
521
  if (L.parts.length) this.stepAndDraw(ctx, L, dt, moving, cfg.behind && rect ? { rect, radius: em.radius } : null);
522
  alive += L.parts.length;
523
  if (L.parts.length || (rect && em.visible && !em.dying && moving)) anyActive = true;
524
  }
 
525
  if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
526
  this.emitters.delete(el);
527
  if (this.io) this.io.unobserve(el);
@@ -551,6 +566,31 @@ export class AuraManager {
551
  if (!this._poll) this._poll = setTimeout(() => { this._poll = null; this.start(); }, 600);
552
  }
553
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  cornerRadius(el, rect) {
555
  try {
556
  const v = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
@@ -577,7 +617,7 @@ export class AuraManager {
577
  }
578
  if (a <= 0.004) return;
579
  const c = cfg.rgba;
580
- const R = cfg.radius;
581
  const dpr = this.dpr;
582
  const x = rect.left, y = rect.top, w = rect.width, h = rect.height;
583
  const pad = Math.ceil(R * 1.6);
@@ -706,11 +746,11 @@ export class AuraManager {
706
  while (L.acc >= 1) {
707
  L.acc -= 1;
708
  if (L.parts.length >= cap) { L.acc = Math.min(L.acc, 1); break; }
709
- L.parts.push(this.spawn(L, rect, el));
710
  }
711
  }
712
 
713
- spawn(L, rect, el) {
714
  const c = L.cfg, r = L.rnd;
715
  const between = (a, b) => a + (b - a) * r();
716
  let x, y;
@@ -744,11 +784,11 @@ export class AuraManager {
744
  if (c.dir === 'in') ang += Math.PI;
745
  } else ang = (c.dir * Math.PI) / 180;
746
  ang += ((r() - 0.5) * c.spread * Math.PI) / 180;
747
- const sp = between(c.speed[0], c.speed[1]);
748
  return {
749
  x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
750
  age: 0, life: between(c.life[0], c.life[1]) / 1000,
751
- size: between(c.size[0], c.size[1]), rot: r() * TAU, spin: (r() - 0.5) * 4, variant: (r() * 4) | 0, ph: r() * TAU,
752
  };
753
  }
754
 
@@ -770,8 +810,9 @@ export class AuraManager {
770
  if (moving) {
771
  p.age += dt;
772
  if (p.age >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; }
773
- p.vy += c.gravity * dt;
774
- if (c.turbulence) { p.vx += Math.sin(p.ph + p.age * 7) * c.turbulence * dt; p.vy += Math.cos(p.ph * 1.3 + p.age * 5) * c.turbulence * 0.3 * dt; }
 
775
  if (c.drag) { p.vx *= drag; p.vy *= drag; }
776
  p.x += p.vx * dt; p.y += p.vy * dt;
777
  p.rot += p.spin * dt;
 
48
  seed: num(0, 99999, { int: true, doc: 'a different random flicker with the same look' }),
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 = {
 
71
  seed: num(0, 99999, { int: true, doc: 'a different random pattern with the same look' }),
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
  /* ------------------------------------------------------------------ *
 
394
  const c = document.createElement('canvas');
395
  c.setAttribute('aria-hidden', 'true');
396
  c.setAttribute('data-dssl-aura', '');
397
+ // 0.14.0: the layer sits above the page by default. <html data-dssl-aura-z="20"> (or DSSL.auraZ(20)) lowers it, so a menu or a
398
+ // glass overlay with a higher z-index covers the light the way it covers everything else.
399
+ const z = parseInt(document.documentElement.getAttribute('data-dssl-aura-z'), 10);
400
+ c.style.cssText = `position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:${Number.isFinite(z) ? z : 2147483000};`;
401
  (document.body || document.documentElement).appendChild(c);
402
  this.canvas = c;
403
  this.ctx = c.getContext('2d');
 
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;
513
+ if (rect && el.offsetWidth > 0) em.scale = Math.max(0.05, Math.min(20, rect.width / el.offsetWidth));
514
+ if (em.radiusAt < 0 || (em.age++ % 45) === 0) { em.radius = this.cornerRadius(el, rect); em.radiusAt = 0; em.clips = this.clipAncestors(el, em); }
515
 
516
  if (em.dying) em.fade = Math.max(0, em.fade - dt * 3);
517
  else em.fade = Math.min(1, em.fade + dt * 4);
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); }
 
530
  for (const L of em.layers) {
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);
 
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` */
570
+ clipAncestors(el, em) {
571
+ if (em.layers.some((l) => l.cfg.unclipped) || em.glow.some((g) => g.cfg.unclipped)) return [];
572
+ const out = [];
573
+ try {
574
+ for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
575
+ const cs = getComputedStyle(a);
576
+ if (/(hidden|clip|auto|scroll)/.test(cs.overflowX + ' ' + cs.overflowY)) out.push(a);
577
+ }
578
+ } catch { /* detached */ }
579
+ return out;
580
+ }
581
+
582
+ /** the visible box: the padding boxes of every clipping ancestor, intersected. Null when nothing clips. */
583
+ clipRect(list) {
584
+ let x0 = -1e9, y0 = -1e9, x1 = 1e9, y1 = 1e9;
585
+ for (const a of list) {
586
+ const r = a.getBoundingClientRect();
587
+ const sx = a.offsetWidth ? r.width / a.offsetWidth : 1, sy = a.offsetHeight ? r.height / a.offsetHeight : 1;
588
+ x0 = Math.max(x0, r.left + a.clientLeft * sx); y0 = Math.max(y0, r.top + a.clientTop * sy);
589
+ x1 = Math.min(x1, r.left + (a.clientLeft + a.clientWidth) * sx); y1 = Math.min(y1, r.top + (a.clientTop + a.clientHeight) * sy);
590
+ }
591
+ return { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
592
+ }
593
+
594
  cornerRadius(el, rect) {
595
  try {
596
  const v = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
 
617
  }
618
  if (a <= 0.004) return;
619
  const c = cfg.rgba;
620
+ const R = cfg.radius * (em.scale || 1);
621
  const dpr = this.dpr;
622
  const x = rect.left, y = rect.top, w = rect.width, h = rect.height;
623
  const pad = Math.ceil(R * 1.6);
 
746
  while (L.acc >= 1) {
747
  L.acc -= 1;
748
  if (L.parts.length >= cap) { L.acc = Math.min(L.acc, 1); break; }
749
+ L.parts.push(this.spawn(L, rect, el, em.scale || 1));
750
  }
751
  }
752
 
753
+ spawn(L, rect, el, scale = 1) {
754
  const c = L.cfg, r = L.rnd;
755
  const between = (a, b) => a + (b - a) * r();
756
  let x, y;
 
784
  if (c.dir === 'in') ang += Math.PI;
785
  } else ang = (c.dir * Math.PI) / 180;
786
  ang += ((r() - 0.5) * c.spread * Math.PI) / 180;
787
+ const sp = between(c.speed[0], c.speed[1]) * scale;
788
  return {
789
  x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
790
  age: 0, life: between(c.life[0], c.life[1]) / 1000,
791
+ size: between(c.size[0], c.size[1]) * scale, rot: r() * TAU, spin: (r() - 0.5) * 4, variant: (r() * 4) | 0, ph: r() * TAU,
792
  };
793
  }
794
 
 
810
  if (moving) {
811
  p.age += dt;
812
  if (p.age >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; }
813
+ const sc = L.scale || 1;
814
+ p.vy += c.gravity * sc * dt;
815
+ if (c.turbulence) { p.vx += Math.sin(p.ph + p.age * 7) * c.turbulence * sc * dt; p.vy += Math.cos(p.ph * 1.3 + p.age * 5) * c.turbulence * sc * 0.3 * dt; }
816
  if (c.drag) { p.vx *= drag; p.vy *= drag; }
817
  p.x += p.vx * dt; p.y += p.vy * dt;
818
  p.rot += p.spin * dt;
dssl-vscode/lib/engine/lint.js CHANGED
@@ -52,6 +52,25 @@ const INTERACTIVE = /^(a|button|input|select|textarea|summary)\b|\[tabindex|\[ro
52
  * @param {object} p { rules, tokenMap, file }
53
  * @returns {Array} warnings in the compiler's {file, line, message, hint} shape
54
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export function lintRules({ rules, tokenMap, file }) {
56
  const warnings = [];
57
  const cssToColor = makeColorResolver(tokenMap);
@@ -106,21 +125,6 @@ export function lintRules({ rules, tokenMap, file }) {
106
  }
107
  }
108
 
109
- // ---- alignment with no box to align in
110
- // The engine quietly turns a plain box into a grid when a rule aligns its children (place-items, justify-content...),
111
- // so the page looks right in the browser. `dssl build` cannot know what each element is, so a compiled page would not.
112
- if (!conditional && states.size === 0) {
113
- const aligns = r.decls.find((d) => ALIGN_PROPS.has(d.prop));
114
- const hasDisplay = r.decls.some((d) => d.prop === 'display' || d.prop === '@display');
115
- if (aligns && !hasDisplay) {
116
- warnings.push({
117
- file, line: aligns.line,
118
- message: `"${aligns.prop}" on "${r.sel}" only works on a flex or grid box, and this rule never says which`,
119
- hint: 'add display: grid (or flex), or use row / column / grid, so a build matches the engine: the engine adds it for you at run time, dssl build does not',
120
- });
121
- }
122
- }
123
-
124
  // ---- focus
125
  if (hasFocus) focusBases.add(r.sel.replace(/:(focus-visible|focus)\b/g, '').trim());
126
  if (!hasFocus && INTERACTIVE.test(finalCompound(r.sel).replace(/[.#:][\w-]+.*$/, (m) => m.startsWith('[') ? m : '') || finalCompound(r.sel))) {
 
52
  * @param {object} p { rules, tokenMap, file }
53
  * @returns {Array} warnings in the compiler's {file, line, message, hint} shape
54
  */
55
+ /**
56
+ * Rules that align their children and never say what kind of box they are. The engine quietly makes the box a grid when it
57
+ * runs; a `dssl build` cannot, because a stylesheet does not know what each element is. So this is reported by the build (and
58
+ * by DSSL.lint on a live page, which knows exactly which elements were promoted), not by `check`: per rule it cannot see that
59
+ * a different rule gave the same element display: flex, and it was wrong about that often enough to be noise.
60
+ */
61
+ export function alignWithoutBox(rules) {
62
+ const out = [];
63
+ for (const r of rules) {
64
+ if (r.pseudo) continue;
65
+ const conditional = (r.media && r.media.length) || (r.signals && r.signals.length) || (r.containers && r.containers.length);
66
+ const states = r.states || new Set();
67
+ if (conditional || states.size) continue;
68
+ const aligns = r.decls.find((d) => ALIGN_PROPS.has(d.prop));
69
+ if (aligns && !r.decls.some((d) => d.prop === 'display' || d.prop === '@display')) out.push({ sel: r.sel, prop: aligns.prop, line: aligns.line, file: r.file });
70
+ }
71
+ return out;
72
+ }
73
+
74
  export function lintRules({ rules, tokenMap, file }) {
75
  const warnings = [];
76
  const cssToColor = makeColorResolver(tokenMap);
 
125
  }
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  // ---- focus
129
  if (hasFocus) focusBases.add(r.sel.replace(/:(focus-visible|focus)\b/g, '').trim());
130
  if (!hasFocus && INTERACTIVE.test(finalCompound(r.sel).replace(/[.#:][\w-]+.*$/, (m) => m.startsWith('[') ? m : '') || finalCompound(r.sel))) {
dssl-vscode/lib/engine/resolver.js CHANGED
@@ -38,6 +38,7 @@ import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
38
  // exported for the editor extension: completions ride the same list the
39
  // compiler validates against, so the two can never drift apart
40
  export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,auto,currentcolor,transparent,' +
 
41
  'black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
42
  'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' +
43
  'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' +
@@ -454,13 +455,38 @@ const INTERP_RE = /\s*\bin\s+(srgb-linear|srgb|display-p3|lab|oklab|xyz-d50|xyz-
454
  * radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 layers, first on top
455
  */
456
  function buildGradient(v, ctx) {
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  const layers = splitLayers(String(v).trim());
458
- if (layers.length > 1) {
459
- const out = [];
460
- for (const l of layers) { const g = buildGradientLayer(l, ctx); if (g === null) return null; out.push(g); }
461
- return out.join(', ');
 
 
 
 
 
 
 
 
 
 
 
462
  }
463
- return buildGradientLayer(String(v).trim(), ctx);
 
464
  }
465
 
466
  function buildMesh(rest, ctx) {
@@ -787,12 +813,15 @@ const SEMANTIC = {
787
  if (['sm', 'md', 'lg', 'xl', 'none'].includes(t)) {
788
  return [{ prop: 'box-shadow', value: t === 'none' ? 'none' : `var(--dssl-shadow-${t})` }];
789
  }
 
 
790
  return [{ prop: 'box-shadow', value: resolveValue(v, ctx) }];
791
  },
792
  radius(v, ctx) {
793
  const atoms = v.trim().split(/\s+/).filter(Boolean);
794
  const out = atoms.map(a => {
795
  if (['sm', 'md', 'lg', 'full', 'xl'].includes(a)) return `var(--dssl-radius-${a})`;
 
796
  return resolveValue(a, ctx);
797
  });
798
  return [{ prop: 'border-radius', value: out.join(' ') }];
@@ -901,10 +930,20 @@ const SEMANTIC = {
901
  if (t === 'none') return [{ prop: '@animate', value: null }];
902
  const atoms = t.split(/\s+/);
903
  const spec = { motion: atoms[0], duration: 400, easing: EASINGS.smooth, fill: 'both', delay: 0 };
 
904
  for (let k = 1; k < atoms.length; k++) {
905
  const a = atoms[k];
 
 
 
 
 
 
 
 
 
906
  if (/^\d+(ms|s)?$/.test(a)) {
907
- const ms = a.endsWith('s') && !a.endsWith('ms') ? parseFloat(a) * 1000 : parseFloat(a);
908
  if (k === 1) spec.duration = ms; else spec.delay = ms;
909
  continue;
910
  }
@@ -914,7 +953,7 @@ const SEMANTIC = {
914
  // animate: pulse 1600 infinite alternate - was impossible before.
915
  if (a === 'infinite') { spec.iterations = Infinity; continue; }
916
  if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
917
- 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'])) });
918
  }
919
  return [{ prop: '@animate', value: spec }];
920
  },
@@ -1006,8 +1045,31 @@ const SEMANTIC = {
1006
  * gradient into the glyphs. gradient-flow loops background-position so a
1007
  * gradient drifts forever - one line instead of keyframe folklore. */
1008
  gradient(v, ctx) {
1009
- const img = buildGradient(v, ctx);
1010
- return img ? [{ prop: 'background-image', value: img }] : [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1011
  },
1012
  'gradient-text'(v, ctx) {
1013
  const img = buildGradient(v, ctx);
@@ -1260,6 +1322,13 @@ function normalizeMedia(query, ctx, line) {
1260
  }
1261
 
1262
  function parseSignal(expr, ctx, line) {
 
 
 
 
 
 
 
1263
  const m = expr.match(/^([A-Za-z_][\w-]*)\s*(==|!=)\s*(.+)$/);
1264
  if (m) {
1265
  let v = m[3].trim();
@@ -1275,7 +1344,7 @@ function parseSignal(expr, ctx, line) {
1275
  * The flattener
1276
  * ------------------------------------------------------------------ */
1277
 
1278
- export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1279
  const errors = [];
1280
  const rules = [];
1281
  const shadowRules = [];
@@ -1333,15 +1402,24 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1333
 
1334
  const parseMotion = (name) => {
1335
  const node = motions[name];
1336
- if (!node) return null;
 
 
1337
  if (node._parsed) return node._parsed;
1338
  node._parsed = { essential: false, frames: [] };
 
1339
  const frameOf = (label) => {
1340
- if (label === 'from') return 0;
1341
- if (label === 'to') return 100;
1342
- const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
 
1343
  return m ? parseFloat(m[1]) : null;
1344
  };
 
 
 
 
 
1345
  const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
1346
  for (const child of node.children) {
1347
  if (child.kind === 'decl' && child.prop === 'essential') {
@@ -1353,9 +1431,9 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1353
  continue;
1354
  }
1355
  if (child.kind === 'selector') {
1356
- const offset = frameOf(child.sel);
1357
- if (offset === null) {
1358
- errors.push({ file, line: child.line, message: `bad keyframe selector "${child.sel}"`, hint: 'use from, to, or at 40%' });
1359
  continue;
1360
  }
1361
  const props = {};
@@ -1386,7 +1464,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1386
  props.transform = c.transform;
1387
  if (c.origin) props.transformOrigin = c.origin;
1388
  }
1389
- node._parsed.frames.push({ offset, props });
1390
  }
1391
  }
1392
  node._parsed.frames.sort((a, b) => a.offset - b.offset);
@@ -1553,7 +1631,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1553
  // used to leak into the shadow sheet as junk CSS the browser
1554
  // silently dropped - exactly the silence this language refuses.
1555
  for (const r of resolved) {
1556
- if (r.prop.startsWith('@')) {
1557
  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' });
1558
  continue;
1559
  }
@@ -1637,7 +1715,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1637
  // nested pseudo path): no DOM handle means no nudge, animate or stack.
1638
  const decls = [];
1639
  for (const d of sub.decls) {
1640
- if (d.prop && d.prop.startsWith('@')) {
1641
  errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo rule' });
1642
  continue;
1643
  }
@@ -1743,6 +1821,36 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1743
 
1744
  // parse all motions that were referenced
1745
  for (const r of [...rules, ...shadowRules]) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1746
  for (const d of r.decls) {
1747
  if (d.prop === '@animate' && d.value && d.value.motion) {
1748
  const m = parseMotion(d.value.motion);
@@ -1755,7 +1863,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1755
  d.value.broken = true;
1756
  } else {
1757
  d.value.frames = m.frames;
1758
- d.value.essential = m.essential;
1759
  }
1760
  }
1761
  // v0.8.0 - the animation shorthand is CSS passthrough, so a motion
@@ -1764,7 +1872,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1764
  // from raw { } or another stylesheet, the warning says it can be ignored.
1765
  if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') {
1766
  const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w));
1767
- const named = words.find(w => motions[w]);
1768
  if (words.length && !named) {
1769
  warnings.push({
1770
  file, line: d.line,
 
38
  // exported for the editor extension: completions ride the same list the
39
  // compiler validates against, so the two can never drift apart
40
  export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,auto,currentcolor,transparent,' +
41
+ 'from,stroke,markers,normal,' + // 0.14.0: conic-gradient(from 0deg...), paint-order: stroke fill
42
  'black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
43
  'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' +
44
  'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' +
 
455
  * radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 layers, first on top
456
  */
457
  function buildGradient(v, ctx) {
458
+ const parts = buildGradientParts(v, ctx);
459
+ return parts ? parts.image : null;
460
+ }
461
+
462
+ /**
463
+ * The same, with each layer's own size, position and repeat. After a layer, in any order:
464
+ * tile 24 background-size 24px (tile 24 12 for width and height)
465
+ * offset 4 4 background-position
466
+ * no-repeat, repeat-x, repeat-y
467
+ * and a layer can be a plain CSS function (radial-gradient(...), url(...)) when the sentence has no word for it.
468
+ * Returns { image, size, position, repeat } where size, position and repeat are null unless some layer set one.
469
+ */
470
+ function buildGradientParts(v, ctx) {
471
  const layers = splitLayers(String(v).trim());
472
+ const out = { images: [], sizes: [], positions: [], repeats: [] };
473
+ const px = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + 'px' : x);
474
+ for (let layer of layers) {
475
+ let size = null, pos = null, rep = null;
476
+ for (;;) {
477
+ let m;
478
+ if ((m = layer.match(/\s+tile\s+(\S+)(?:\s+(\S+))?$/i)) && !/^(tile|offset)$/i.test(m[1])) { size = px(m[1]) + ' ' + px(m[2] || m[1]); layer = layer.slice(0, m.index); continue; }
479
+ if ((m = layer.match(/\s+offset\s+(\S+)\s+(\S+)$/i))) { pos = px(m[1]) + ' ' + px(m[2]); layer = layer.slice(0, m.index); continue; }
480
+ if ((m = layer.match(/\s+(no-repeat|repeat-x|repeat-y)$/i))) { rep = m[1].toLowerCase(); layer = layer.slice(0, m.index); continue; }
481
+ break;
482
+ }
483
+ const isCssFn = /^(?:repeating-)?(?:linear|radial|conic)-gradient\(|^url\(|^image-set\(|^-webkit-/i.test(layer.trim());
484
+ const img = isCssFn ? resolveValue(layer.trim(), { ...ctx, unitless: false, bareIdents: false }) : buildGradientLayer(layer, ctx);
485
+ if (img === null) return null;
486
+ out.images.push(img); out.sizes.push(size); out.positions.push(pos); out.repeats.push(rep);
487
  }
488
+ const listOf = (arr, dflt) => (arr.some((x) => x !== null) ? arr.map((x) => x || dflt).join(', ') : null);
489
+ return { image: out.images.join(', '), size: listOf(out.sizes, 'auto'), position: listOf(out.positions, '0 0'), repeat: listOf(out.repeats, 'repeat') };
490
  }
491
 
492
  function buildMesh(rest, ctx) {
 
813
  if (['sm', 'md', 'lg', 'xl', 'none'].includes(t)) {
814
  return [{ prop: 'box-shadow', value: t === 'none' ? 'none' : `var(--dssl-shadow-${t})` }];
815
  }
816
+ // 0.14.0: your own shadow token works by its bare name, like the built-in ones: tokens { shadow { pop: ... } } then shadow: pop
817
+ if (/^[A-Za-z][\w-]*$/.test(t) && ctx.tokens && ctx.tokens.has('shadow.' + t)) return [{ prop: 'box-shadow', value: `var(--dssl-shadow-${t})` }];
818
  return [{ prop: 'box-shadow', value: resolveValue(v, ctx) }];
819
  },
820
  radius(v, ctx) {
821
  const atoms = v.trim().split(/\s+/).filter(Boolean);
822
  const out = atoms.map(a => {
823
  if (['sm', 'md', 'lg', 'full', 'xl'].includes(a)) return `var(--dssl-radius-${a})`;
824
+ if (/^[A-Za-z][\w-]*$/.test(a) && ctx.tokens && ctx.tokens.has('radius.' + a)) return `var(--dssl-radius-${a})`; // 0.14.0: radius { pill: 99 } then radius: pill
825
  return resolveValue(a, ctx);
826
  });
827
  return [{ prop: 'border-radius', value: out.join(' ') }];
 
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
  }
 
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
  },
 
1045
  * gradient into the glyphs. gradient-flow loops background-position so a
1046
  * gradient drifts forever - one line instead of keyframe folklore. */
1047
  gradient(v, ctx) {
1048
+ const g = buildGradientParts(v, ctx);
1049
+ if (!g) return [];
1050
+ const out = [{ prop: 'background-image', value: g.image, fromGradient: true }];
1051
+ if (g.size) out.push({ prop: 'background-size', value: g.size });
1052
+ if (g.position) out.push({ prop: 'background-position', value: g.position });
1053
+ if (g.repeat) out.push({ prop: 'background-repeat', value: g.repeat });
1054
+ return out;
1055
+ },
1056
+ // 0.14.0: an outline round text, in one line. The stroke is drawn first and the fill over it, so the outline sits OUTSIDE
1057
+ // the letters and does not eat them (the stroke-plus-paint-order trick, written once). `inside` keeps the default centred stroke.
1058
+ // text-stroke: 6 color.ink text-stroke: 2 #fff inside
1059
+ 'text-stroke'(v, ctx) {
1060
+ const atoms = (v || '').trim().split(/\s+/).filter(Boolean);
1061
+ if (atoms.length === 1 && atoms[0] === 'none') return [{ prop: '-webkit-text-stroke', value: '0' }, { prop: 'paint-order', value: 'normal' }];
1062
+ const inside = atoms.includes('inside');
1063
+ const rest = atoms.filter((a) => a !== 'inside');
1064
+ if (!rest.length || !/^\d+(\.\d+)?(px|em|rem)?$/.test(rest[0])) {
1065
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'text-stroke needs a width first', hint: 'text-stroke: 6 color.ink' });
1066
+ return [];
1067
+ }
1068
+ const width = /^\d+(\.\d+)?$/.test(rest[0]) ? rest[0] + 'px' : rest[0];
1069
+ const color = rest[1] ? resolveValue(rest.slice(1).join(' '), { ...ctx, unitless: false, bareIdents: false }) : 'currentcolor';
1070
+ const out = [{ prop: '-webkit-text-stroke', value: `${width} ${color}` }];
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);
 
1322
  }
1323
 
1324
  function parseSignal(expr, ctx, line) {
1325
+ // 0.14.0: a set of values: @signal screen in (menu, shop, settings) and @signal screen not in (playing)
1326
+ const list = expr.match(/^([A-Za-z_][\w-]*)\s+(not\s+in|in)\s*\(([^)]*)\)\s*$/);
1327
+ if (list) {
1328
+ const values = list[3].split(',').map((x) => x.trim().replace(/^(["'])(.*)\1$/, '$2')).filter((x) => x !== '');
1329
+ if (!values.length) { ctx.errors.push({ file: ctx.file, line, message: `@signal ${list[1]} ${list[2]} ( ) has no values`, hint: '@signal screen in (menu, shop)' }); }
1330
+ return { name: list[1], op: /^not/.test(list[2]) ? 'notin' : 'in', value: values.join(','), values };
1331
+ }
1332
  const m = expr.match(/^([A-Za-z_][\w-]*)\s*(==|!=)\s*(.+)$/);
1333
  if (m) {
1334
  let v = m[3].trim();
 
1344
  * The flattener
1345
  * ------------------------------------------------------------------ */
1346
 
1347
+ export function resolve(ast, file = 'inline.dssl', baseTokens = null, baseMotions = null) {
1348
  const errors = [];
1349
  const rules = [];
1350
  const shadowRules = [];
 
1402
 
1403
  const parseMotion = (name) => {
1404
  const node = motions[name];
1405
+ // 0.14.0: a motion defined in another stylesheet counts, like a token does. `baseMotions` holds the ones the files loaded
1406
+ // before this one defined, already turned into keyframes.
1407
+ if (!node) return (baseMotions && baseMotions[name]) || null;
1408
  if (node._parsed) return node._parsed;
1409
  node._parsed = { essential: false, frames: [] };
1410
+ // from, to, at 40%, and since 0.14.0 plain CSS habits too: 0%, 40%, and a list like 0%, 100%
1411
  const frameOf = (label) => {
1412
+ const t = label.trim();
1413
+ if (t === 'from') return 0;
1414
+ if (t === 'to') return 100;
1415
+ const m = t.match(/^(?:at\s+)?([0-9.]+)\s*%?$/);
1416
  return m ? parseFloat(m[1]) : null;
1417
  };
1418
+ const framesOf = (label) => {
1419
+ const parts = label.split(',').map((x) => x.trim()).filter(Boolean);
1420
+ const offs = parts.map(frameOf);
1421
+ return offs.some((o) => o === null || o < 0 || o > 100) ? null : offs;
1422
+ };
1423
  const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
1424
  for (const child of node.children) {
1425
  if (child.kind === 'decl' && child.prop === 'essential') {
 
1431
  continue;
1432
  }
1433
  if (child.kind === 'selector') {
1434
+ const offsets = framesOf(child.sel);
1435
+ if (offsets === null) {
1436
+ errors.push({ file, line: child.line, message: `bad keyframe selector "${child.sel}"`, hint: 'use from, to, at 40%, or just 40% (a list works too: 0%, 100%). A keyframe sits between 0 and 100' });
1437
  continue;
1438
  }
1439
  const props = {};
 
1464
  props.transform = c.transform;
1465
  if (c.origin) props.transformOrigin = c.origin;
1466
  }
1467
+ for (const offset of offsets) node._parsed.frames.push({ offset, props: { ...props } });
1468
  }
1469
  }
1470
  node._parsed.frames.sort((a, b) => a.offset - b.offset);
 
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
  }
 
1715
  // nested pseudo path): no DOM handle means no nudge, animate or stack.
1716
  const decls = [];
1717
  for (const d of sub.decls) {
1718
+ if (d.prop && d.prop.startsWith('@') && d.prop !== '@animate') {
1719
  errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo rule' });
1720
  continue;
1721
  }
 
1821
 
1822
  // parse all motions that were referenced
1823
  for (const r of [...rules, ...shadowRules]) {
1824
+ // 0.14.0: gradient: writes background-image, so a second background-image (or a background: with an image in it) in the same
1825
+ // rule replaces it and the gradient is gone, with no sign of it. Say so, and say how to layer.
1826
+ {
1827
+ const g = r.decls.find((x) => x.fromGradient);
1828
+ if (g) {
1829
+ const other = r.decls.find((x) => x !== g && !x.fromGradient && (x.prop === 'background-image' || (x.prop === 'background' && /url\(|gradient\(/.test(String(x.value)))));
1830
+ if (other) warnings.push({
1831
+ file, line: Math.max(g.line || 0, other.line || 0) || g.line,
1832
+ message: `"${r.sel}" sets the background image twice (gradient: and ${other.prop}): the later one replaces the earlier, so one of them never shows`,
1833
+ hint: 'layer them in one gradient: sentence, joined with +, first on top: gradient: radial circle, #fff4 -> transparent tile 24 + linear 180, #e33 -> #a11 (a CSS radial-gradient(...) or url(...) works as a layer too)',
1834
+ });
1835
+ }
1836
+ }
1837
+ // 0.14.0: animation-delay, -duration, -iteration-count... only tune a CSS animation, and a rule that runs its motion through
1838
+ // animate: (the Web Animations API) has none, so they compiled, were written inline, and did nothing, with no word about it.
1839
+ {
1840
+ const hasCssAnim = r.decls.some((x) => x.prop === 'animation' || x.prop === 'animation-name');
1841
+ const usesAnimate = r.decls.some((x) => x.prop === '@animate' && x.value);
1842
+ for (const x of r.decls) {
1843
+ const m = /^animation-(delay|duration|iteration-count|direction|fill-mode|timing-function|play-state)$/.exec(x.prop);
1844
+ if (m && !hasCssAnim) {
1845
+ const word = { delay: 'delay', duration: 'a number after the name', 'iteration-count': 'infinite, or iterations 3', direction: 'alternate', 'fill-mode': 'both, forwards', 'timing-function': 'an easing name', 'play-state': '' }[m[1]];
1846
+ warnings.push({
1847
+ file, line: x.line,
1848
+ message: `${x.prop} does nothing here: this rule has no CSS animation to tune${usesAnimate ? ', it runs its motion through animate:' : ''}`,
1849
+ hint: usesAnimate || !hasCssAnim ? `put it in the animate: line, e.g. animate: ${(r.decls.find((y) => y.prop === '@animate' && y.value) || { value: { motion: 'name' } }).value.motion} 400 ${m[1] === 'delay' ? 'delay 200' : word || ''} (words: delay N, jitter N, iterations N, infinite, alternate, an easing, essential). Or write the whole animation: name 400ms ... shorthand` : '',
1850
+ });
1851
+ }
1852
+ }
1853
+ }
1854
  for (const d of r.decls) {
1855
  if (d.prop === '@animate' && d.value && d.value.motion) {
1856
  const m = parseMotion(d.value.motion);
 
1863
  d.value.broken = true;
1864
  } else {
1865
  d.value.frames = m.frames;
1866
+ d.value.essential = m.essential || !!d.value.essentialHere;
1867
  }
1868
  }
1869
  // v0.8.0 - the animation shorthand is CSS passthrough, so a motion
 
1872
  // from raw { } or another stylesheet, the warning says it can be ignored.
1873
  if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') {
1874
  const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w));
1875
+ const named = words.find(w => motions[w] || (baseMotions && baseMotions[w]));
1876
  if (words.length && !named) {
1877
  warnings.push({
1878
  file, line: d.line,
dssl-vscode/lib/props-docs.js CHANGED
@@ -12,7 +12,7 @@ export const SEMANTIC = {
12
  size: 'width and height in one line: `size: 40 20` = 40 wide, 20 tall. `size: 40` = both.',
13
  shadow: 'box shadow from the shadow token scale: `shadow: sm | md | lg | xl`. Custom: `shadow: 0 2 4 ink/10, 0 4 12 ink/8`.',
14
  radius: 'border-radius, bare number or token: `radius: md`, `radius: full`, `radius: 14`.',
15
- gradient: 'gradient as a sentence: `gradient: linear 135, color.accent -> color.ok`. Also `repeating linear 45, #fff 0 10, #000 10 20` (stripes), `linear 90 in oklch, a -> b` (blend colour space), `a -> 30% -> b` (midpoint), `a 0 30%, b 30% 100%` (hard bands), `mesh #fff4e6, #ff7a1a at 15% 20%, #3949ab at 80% 70%` (soft blobs on a base), and layers joined with ` + ` (first on top). A stop that is not a color is a compile error.',
16
  'gradient-text': 'clips the gradient grammar into the glyphs: `gradient-text: linear to right, color.accent, color.ink`.',
17
  'gradient-flow': 'the gradient drifts forever through WAAPI: `gradient-flow: 6s alternate`. Holds still under reduced-motion.',
18
  bend: 'bows the element in 3D. Positive tips the top away, negative leans toward you. Bare numbers are degrees. Composes into the one transform channel.',
@@ -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] [fill] [infinite] [alternate]`. Example: `animate: pulse 1600 infinite alternate`.',
 
25
  stagger: 'choreographed children: `stagger: 70` fires each child\u2019s animation 70ms after its older sibling.',
26
  transition: 'per-property transition: `transition: 200`, or `transition: 200 snap transform`. Honors reduced-motion.',
27
  overlap: 'the anti-overlap policy: `auto` (engine shoves translucent elements apart), `deny` (this one never sits on anything), `allow` (exempt). Nothing below 100% opacity overlaps anything unless you say so.',
@@ -115,7 +116,7 @@ export const STATES = {
115
  export const AT_RULES = {
116
  '@media': 'conditions: `@media (max-width: 860)`, sugar forms `@media dark`, `@media light`, `@media reduced-motion`, bare widths, `<=` ranges.',
117
  '@container': 'evaluates against the nearest ancestor (or self) with a container-type: `@container (min-width: 480)`.',
118
- '@signal': 'the JS bridge: `@signal menuOpen { ... }` re-evaluates when DSSL.signal(\u0027menuOpen\u0027, true) fires. Mirrors onto data-dssl-signal-* attributes.',
119
  '@layer': 'cascade layers: `@layer base { }`, `@layer components { }`, `@layer utilities { }`. Cascade is (layer, source order) \u2014 no specificity, no !important.',
120
  };
121
 
 
12
  size: 'width and height in one line: `size: 40 20` = 40 wide, 20 tall. `size: 40` = both.',
13
  shadow: 'box shadow from the shadow token scale: `shadow: sm | md | lg | xl`. Custom: `shadow: 0 2 4 ink/10, 0 4 12 ink/8`.',
14
  radius: 'border-radius, bare number or token: `radius: md`, `radius: full`, `radius: 14`.',
15
+ gradient: 'gradient as a sentence: `gradient: linear 135, color.accent -> color.ok`. Also `repeating linear 45, #fff 0 10, #000 10 20` (stripes), `linear 90 in oklch, a -> b` (blend colour space), `a -> 30% -> b` (midpoint), `a 0 30%, b 30% 100%` (hard bands), `mesh #fff4e6, #ff7a1a at 15% 20%, #3949ab at 80% 70%` (soft blobs on a base), and layers joined with ` + ` (first on top; each can end with `tile 24`, `offset 4 4`, `no-repeat`, and can be a CSS `radial-gradient(...)`). A stop that is not a color is a compile error.',
16
  'gradient-text': 'clips the gradient grammar into the glyphs: `gradient-text: linear to right, color.accent, color.ink`.',
17
  'gradient-flow': 'the gradient drifts forever through WAAPI: `gradient-flow: 6s alternate`. Holds still under reduced-motion.',
18
  bend: 'bows the element in 3D. Positive tips the top away, negative leans toward you. Bare numbers are degrees. Composes into the one transform channel.',
 
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.',
28
  overlap: 'the anti-overlap policy: `auto` (engine shoves translucent elements apart), `deny` (this one never sits on anything), `allow` (exempt). Nothing below 100% opacity overlaps anything unless you say so.',
 
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
 
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.13.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.14.0",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
engine-package/USAGE.md CHANGED
@@ -71,8 +71,13 @@ Bare numbers are pixels. States like `hover` are nested blocks. The engine keeps
71
  - `DSSL.signal(name, value)` drive styles from JS with `@signal` conditions in your `.dssl`
72
  - `DSSL.refresh()` force a full recompute
73
  - `DSSL.replay(selector)` replay entrance motions on matching elements
74
- - `DSSL.errors` the last reported errors (also grouped in the console, with did-you-mean hints)
75
- - `DSSL.version` engine version
 
 
 
 
 
76
 
77
  ## Scoped engines
78
 
 
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
81
 
82
  ## Scoped engines
83
 
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.9.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
 
@@ -46,7 +46,7 @@ DSSL.toCSS(src) // v0.8.0: the exit lane -> { css, report }
46
  DSSL.signal('menuOpen', true) // the JS -> styles bridge
47
  DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
48
  DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
49
- DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
50
  ```
51
 
52
  ## 0.9.0: texture, aura, sliders, lint, import
@@ -63,6 +63,19 @@ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
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.13.0: shipping without the engine
67
  - `dssl build a.dssl b.dssl -o out.css [--reset] [--sourcemap] [--inline-tokens] [--native-specificity]`: several files in load order. Selectors are wrapped in :where() so source order wins like in the engine (no CSS specificity). --reset adds the small reset as the FIRST layer. --sourcemap writes out.css.map (rule and declaration level, sources embedded) so devtools show the .dssl file and line. --inline-tokens writes token values in place of var() where a token is defined once and never redefined (fastest; tokens changed at run time will not reach built rules).
68
  - Proof: scripts/test-static-parity.mjs compares every page of the site, built CSS with JavaScript off vs the engine, ~100 properties per element plus box and pseudo-elements, 5,000+ elements: identical. Engine-only features (animate, aura, tilt, bend, stretch, overlap, gradient-flow, stagger, signals, slider fill, texture shimmer) are not in a build.
@@ -98,4 +111,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
98
 
99
  ## Editor support
100
 
101
- 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.13.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.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
 
 
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
  - 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.
69
+ - Motions are shared across files like tokens (`dssl check`/`build` too). Keyframes accept `0%`, `40%`, `0%, 100%`, `from`, `to`. Every motion also exists as a real `@keyframes`, so `animation: bob 300ms` works. `animate:` works on `::before` and `::after`.
70
+ - `await DSSL.play(el, 'boing', { duration, hold })` returns a promise that settles when the motion ends (also when cancelled); `hold: true` keeps the last frame; an unknown name rejects with did-you-mean.
71
+ - `gradient:` layers with `+`, takes CSS gradient functions as layers, and `tile 24`, `offset 4 4`, `no-repeat` after a layer. `gradient:` plus `background-image:` in one rule is a warning.
72
+ - `text-stroke: 6 color.ink` (outside the letters, paint-order stroke fill), `inside` for centred, `none`. `paint-order: stroke fill` and `conic-gradient(from 0deg, ...)` are accepted. `shadow: pop` and `radius: pill` work for your own tokens.
73
+ - Your own inline styles win: a `style=""` attribute or a script's `el.style.left` beats a rule, including through `DSSL.refresh()`. `data-dssl-static` / `DSSL.freeze(el)` styles a subtree once and leaves it alone; `DSSL.refresh(el)` restyles it on demand.
74
+ - Aura: clipped to an `overflow: hidden` stage (add `unclipped` to spill), scales with an ancestor `transform: scale`, `DSSL.auraZ(20)` puts the canvas under a menu.
75
+ - `@signal screen in (menu, shop)`, `not in (...)`, `!=`.
76
+ - `DSSL.lint(root)` finds what only a live page shows: text colour against the composited background (cream on cream from two rules), and elements the engine had to turn into grids. `dssl check index.html` and `dssl build index.html` read the stylesheets a page links, in page order.
77
+ - The folded-border console message is gone. Docs now list every function on DSSL (a test keeps it that way).
78
+
79
  ## 0.13.0: shipping without the engine
80
  - `dssl build a.dssl b.dssl -o out.css [--reset] [--sourcemap] [--inline-tokens] [--native-specificity]`: several files in load order. Selectors are wrapped in :where() so source order wins like in the engine (no CSS specificity). --reset adds the small reset as the FIRST layer. --sourcemap writes out.css.map (rule and declaration level, sources embedded) so devtools show the .dssl file and line. --inline-tokens writes token values in place of var() where a token is defined once and never redefined (fastest; tokens changed at run time will not reach built rules).
81
  - Proof: scripts/test-static-parity.mjs compares every page of the site, built CSS with JavaScript off vs the engine, ~100 properties per element plus box and pseudo-elements, 5,000+ elements: identical. Engine-only features (animate, aura, tilt, bend, stretch, overlap, gradient-flow, stagger, signals, slider fill, texture shimmer) are not in a build.
 
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.
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "dssl",
3
- "version": "0.13.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.14.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
@@ -48,6 +48,7 @@ export const GLOW_KEYS = {
48
  seed: num(0, 99999, { int: true, doc: 'a different random flicker with the same look' }),
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
  };
52
 
53
  export const PARTICLE_KEYS = {
@@ -70,6 +71,7 @@ export const PARTICLE_KEYS = {
70
  seed: num(0, 99999, { int: true, doc: 'a different random pattern with the same look' }),
71
  texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
72
  essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
 
73
  };
74
 
75
  /* ------------------------------------------------------------------ *
@@ -392,7 +394,10 @@ export class AuraManager {
392
  const c = document.createElement('canvas');
393
  c.setAttribute('aria-hidden', 'true');
394
  c.setAttribute('data-dssl-aura', '');
395
- c.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483000;';
 
 
 
396
  (document.body || document.documentElement).appendChild(c);
397
  this.canvas = c;
398
  this.ctx = c.getContext('2d');
@@ -502,11 +507,19 @@ export class AuraManager {
502
  if (rect.width === 0 && rect.height === 0) rect = null;
503
  }
504
  em.rect = rect;
505
- if (em.radiusAt < 0 || (em.age++ % 45) === 0) { em.radius = this.cornerRadius(el, rect); em.radiusAt = 0; }
 
 
 
 
506
 
507
  if (em.dying) em.fade = Math.max(0, em.fade - dt * 3);
508
  else em.fade = Math.min(1, em.fade + dt * 4);
509
 
 
 
 
 
510
  // glow
511
  if (rect && em.fade > 0 && (em.visible || em.dying)) {
512
  for (const g of em.glow) { this.drawGlow(ctx, rect, em, g, now, reduced); }
@@ -517,11 +530,13 @@ export class AuraManager {
517
  for (const L of em.layers) {
518
  const cfg = L.cfg;
519
  const moving = !reduced || cfg.essential;
 
520
  if (rect && em.visible && !em.dying && moving) this.emit(L, rect, em, dt, share, el);
521
  if (L.parts.length) this.stepAndDraw(ctx, L, dt, moving, cfg.behind && rect ? { rect, radius: em.radius } : null);
522
  alive += L.parts.length;
523
  if (L.parts.length || (rect && em.visible && !em.dying && moving)) anyActive = true;
524
  }
 
525
  if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
526
  this.emitters.delete(el);
527
  if (this.io) this.io.unobserve(el);
@@ -551,6 +566,31 @@ export class AuraManager {
551
  if (!this._poll) this._poll = setTimeout(() => { this._poll = null; this.start(); }, 600);
552
  }
553
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  cornerRadius(el, rect) {
555
  try {
556
  const v = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
@@ -577,7 +617,7 @@ export class AuraManager {
577
  }
578
  if (a <= 0.004) return;
579
  const c = cfg.rgba;
580
- const R = cfg.radius;
581
  const dpr = this.dpr;
582
  const x = rect.left, y = rect.top, w = rect.width, h = rect.height;
583
  const pad = Math.ceil(R * 1.6);
@@ -706,11 +746,11 @@ export class AuraManager {
706
  while (L.acc >= 1) {
707
  L.acc -= 1;
708
  if (L.parts.length >= cap) { L.acc = Math.min(L.acc, 1); break; }
709
- L.parts.push(this.spawn(L, rect, el));
710
  }
711
  }
712
 
713
- spawn(L, rect, el) {
714
  const c = L.cfg, r = L.rnd;
715
  const between = (a, b) => a + (b - a) * r();
716
  let x, y;
@@ -744,11 +784,11 @@ export class AuraManager {
744
  if (c.dir === 'in') ang += Math.PI;
745
  } else ang = (c.dir * Math.PI) / 180;
746
  ang += ((r() - 0.5) * c.spread * Math.PI) / 180;
747
- const sp = between(c.speed[0], c.speed[1]);
748
  return {
749
  x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
750
  age: 0, life: between(c.life[0], c.life[1]) / 1000,
751
- size: between(c.size[0], c.size[1]), rot: r() * TAU, spin: (r() - 0.5) * 4, variant: (r() * 4) | 0, ph: r() * TAU,
752
  };
753
  }
754
 
@@ -770,8 +810,9 @@ export class AuraManager {
770
  if (moving) {
771
  p.age += dt;
772
  if (p.age >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; }
773
- p.vy += c.gravity * dt;
774
- if (c.turbulence) { p.vx += Math.sin(p.ph + p.age * 7) * c.turbulence * dt; p.vy += Math.cos(p.ph * 1.3 + p.age * 5) * c.turbulence * 0.3 * dt; }
 
775
  if (c.drag) { p.vx *= drag; p.vy *= drag; }
776
  p.x += p.vx * dt; p.y += p.vy * dt;
777
  p.rot += p.spin * dt;
 
48
  seed: num(0, 99999, { int: true, doc: 'a different random flicker with the same look' }),
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 = {
 
71
  seed: num(0, 99999, { int: true, doc: 'a different random pattern with the same look' }),
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
  /* ------------------------------------------------------------------ *
 
394
  const c = document.createElement('canvas');
395
  c.setAttribute('aria-hidden', 'true');
396
  c.setAttribute('data-dssl-aura', '');
397
+ // 0.14.0: the layer sits above the page by default. <html data-dssl-aura-z="20"> (or DSSL.auraZ(20)) lowers it, so a menu or a
398
+ // glass overlay with a higher z-index covers the light the way it covers everything else.
399
+ const z = parseInt(document.documentElement.getAttribute('data-dssl-aura-z'), 10);
400
+ c.style.cssText = `position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:${Number.isFinite(z) ? z : 2147483000};`;
401
  (document.body || document.documentElement).appendChild(c);
402
  this.canvas = c;
403
  this.ctx = c.getContext('2d');
 
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;
513
+ if (rect && el.offsetWidth > 0) em.scale = Math.max(0.05, Math.min(20, rect.width / el.offsetWidth));
514
+ if (em.radiusAt < 0 || (em.age++ % 45) === 0) { em.radius = this.cornerRadius(el, rect); em.radiusAt = 0; em.clips = this.clipAncestors(el, em); }
515
 
516
  if (em.dying) em.fade = Math.max(0, em.fade - dt * 3);
517
  else em.fade = Math.min(1, em.fade + dt * 4);
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); }
 
530
  for (const L of em.layers) {
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);
 
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` */
570
+ clipAncestors(el, em) {
571
+ if (em.layers.some((l) => l.cfg.unclipped) || em.glow.some((g) => g.cfg.unclipped)) return [];
572
+ const out = [];
573
+ try {
574
+ for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
575
+ const cs = getComputedStyle(a);
576
+ if (/(hidden|clip|auto|scroll)/.test(cs.overflowX + ' ' + cs.overflowY)) out.push(a);
577
+ }
578
+ } catch { /* detached */ }
579
+ return out;
580
+ }
581
+
582
+ /** the visible box: the padding boxes of every clipping ancestor, intersected. Null when nothing clips. */
583
+ clipRect(list) {
584
+ let x0 = -1e9, y0 = -1e9, x1 = 1e9, y1 = 1e9;
585
+ for (const a of list) {
586
+ const r = a.getBoundingClientRect();
587
+ const sx = a.offsetWidth ? r.width / a.offsetWidth : 1, sy = a.offsetHeight ? r.height / a.offsetHeight : 1;
588
+ x0 = Math.max(x0, r.left + a.clientLeft * sx); y0 = Math.max(y0, r.top + a.clientTop * sy);
589
+ x1 = Math.min(x1, r.left + (a.clientLeft + a.clientWidth) * sx); y1 = Math.min(y1, r.top + (a.clientTop + a.clientHeight) * sy);
590
+ }
591
+ return { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
592
+ }
593
+
594
  cornerRadius(el, rect) {
595
  try {
596
  const v = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
 
617
  }
618
  if (a <= 0.004) return;
619
  const c = cfg.rgba;
620
+ const R = cfg.radius * (em.scale || 1);
621
  const dpr = this.dpr;
622
  const x = rect.left, y = rect.top, w = rect.width, h = rect.height;
623
  const pad = Math.ceil(R * 1.6);
 
746
  while (L.acc >= 1) {
747
  L.acc -= 1;
748
  if (L.parts.length >= cap) { L.acc = Math.min(L.acc, 1); break; }
749
+ L.parts.push(this.spawn(L, rect, el, em.scale || 1));
750
  }
751
  }
752
 
753
+ spawn(L, rect, el, scale = 1) {
754
  const c = L.cfg, r = L.rnd;
755
  const between = (a, b) => a + (b - a) * r();
756
  let x, y;
 
784
  if (c.dir === 'in') ang += Math.PI;
785
  } else ang = (c.dir * Math.PI) / 180;
786
  ang += ((r() - 0.5) * c.spread * Math.PI) / 180;
787
+ const sp = between(c.speed[0], c.speed[1]) * scale;
788
  return {
789
  x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
790
  age: 0, life: between(c.life[0], c.life[1]) / 1000,
791
+ size: between(c.size[0], c.size[1]) * scale, rot: r() * TAU, spin: (r() - 0.5) * 4, variant: (r() * 4) | 0, ph: r() * TAU,
792
  };
793
  }
794
 
 
810
  if (moving) {
811
  p.age += dt;
812
  if (p.age >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; }
813
+ const sc = L.scale || 1;
814
+ p.vy += c.gravity * sc * dt;
815
+ if (c.turbulence) { p.vx += Math.sin(p.ph + p.age * 7) * c.turbulence * sc * dt; p.vy += Math.cos(p.ph * 1.3 + p.age * 5) * c.turbulence * sc * 0.3 * dt; }
816
  if (c.drag) { p.vx *= drag; p.vy *= drag; }
817
  p.x += p.vx * dt; p.y += p.vy * dt;
818
  p.rot += p.spin * dt;
public/dssl/dev.js CHANGED
@@ -8,6 +8,8 @@
8
  * (or when the script tag carries data-inspect or data-watch).
9
  * ========================================================================= */
10
 
 
 
11
  const HOST_ID = 'dssl-dev-host';
12
 
13
  function describe(el) {
@@ -180,3 +182,88 @@ export function createWatcher({ apply, sources }) {
180
  const stop = () => { if (timer) clearInterval(timer); timer = null; opts = null; if (toast) { toast.remove(); toast = null; } };
181
  return { start, stop, get active() { return !!timer || !!opts; }, check };
182
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  * (or when the script tag carries data-inspect or data-watch).
9
  * ========================================================================= */
10
 
11
+ import { parseColor, over, contrast, toHex } from './color.js';
12
+
13
  const HOST_ID = 'dssl-dev-host';
14
 
15
  function describe(el) {
 
182
  const stop = () => { if (timer) clearInterval(timer); timer = null; opts = null; if (toast) { toast.remove(); toast = null; } };
183
  return { start, stop, get active() { return !!timer || !!opts; }, check };
184
  }
185
+
186
+ /* ------------------------------------------------------------------ runtime lint */
187
+
188
+ /**
189
+ * DSSL.lint(root): the checks a stylesheet linter cannot make, because they need the page. It reads what the browser actually
190
+ * computed, so it sees the cream text that inherited onto a cream card (two rules, two files, no warning from `dssl check`).
191
+ *
192
+ * contrast a piece of text against the colour it really sits on: its own background, or the nearest ancestor's, composited
193
+ * through any transparency. Text over an image or a gradient is not guessed at (pass { images: true } to include it).
194
+ * promoted an element the engine turned into a grid because its rule aligns children without saying display. It looks right
195
+ * here, and a `dssl build` would not do it.
196
+ * motion animate: motions that did not run because of reduced motion
197
+ *
198
+ * Returns [{ kind, el, selector, message, ... }]. { log: false } stays quiet. { min: 3 } changes the 4.5 limit.
199
+ */
200
+ export function lintDocument(root, { explain, engines }, opts = {}) {
201
+ const out = [];
202
+ const scope = root || document.body;
203
+ const min = opts.min || 4.5;
204
+ const page = parseColor(getComputedStyle(document.documentElement).backgroundColor) || { r: 255, g: 255, b: 255, a: 1 };
205
+ const solidPage = page.a < 0.99 ? over(page, { r: 255, g: 255, b: 255, a: 1 }) : page;
206
+
207
+ // the colour behind an element, or null when an image or gradient is in the way and we would only be guessing
208
+ const backdrop = (el) => {
209
+ const layers = [];
210
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
211
+ const cs = getComputedStyle(n);
212
+ const c = parseColor(cs.backgroundColor);
213
+ const hasImage = cs.backgroundImage && cs.backgroundImage !== 'none';
214
+ if (hasImage && !opts.images) return null;
215
+ if (c && c.a > 0) layers.push(c);
216
+ if (c && c.a >= 0.99 && !hasImage) break;
217
+ }
218
+ let base = solidPage;
219
+ for (let i = layers.length - 1; i >= 0; i--) base = over(layers[i], { ...base, a: 1 });
220
+ return base;
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()) {
227
+ if (!t.nodeValue.trim()) continue;
228
+ const el = t.parentElement;
229
+ if (!el || seen.has(el) || el.closest('#' + HOST_ID + ', script, style, noscript')) continue;
230
+ seen.add(el);
231
+ const cs = getComputedStyle(el);
232
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
233
+ const r = el.getBoundingClientRect();
234
+ if (!r.width || !r.height) continue;
235
+ if (cs.webkitTextFillColor === 'rgba(0, 0, 0, 0)' || /transparent/.test(cs.webkitTextFillColor) ) continue; // gradient text: the colour is a picture
236
+ const fg = parseColor(cs.color);
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
+ }
248
+ }
249
+
250
+ for (const eng of engines) {
251
+ for (const el of scope.querySelectorAll('*')) {
252
+ const owned = eng.ownMap.get(el);
253
+ const disp = owned && owned.get('display');
254
+ const natural = eng.naturalDisplay.get(el);
255
+ if (disp && /^(inline-)?grid$/.test(disp.value) && natural && !/^(flex|inline-flex|grid|inline-grid)$/.test(natural) && !(eng.rules || []).some((r) => r.decls.some((d) => d.prop === 'display' && /grid|flex/.test(String(d.value))) && eng.matches(el, r.sel.replace(/:seen\b/g, '') || '*'))) {
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) {
262
+ try {
263
+ console.groupCollapsed(`%c[DSSL] lint: ${out.length} thing${out.length === 1 ? '' : 's'} worth a look`, 'color:#8a6d1a;font-weight:bold');
264
+ for (const f of out) console.warn(`${f.kind} ${f.selector} ${f.message}`, f.el || '');
265
+ console.groupEnd();
266
+ } catch { /* old console */ }
267
+ }
268
+ return out;
269
+ }
public/dssl/engine.js CHANGED
@@ -25,6 +25,7 @@ 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
 
29
  const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
30
 
@@ -78,6 +79,9 @@ function pageNonce() {
78
  } catch { return ''; }
79
  }
80
 
 
 
 
81
  const ALIGN_PROPS = new Set([
82
  'place-items', 'place-content', 'align-items', 'justify-items',
83
  'align-content', 'justify-content',
@@ -217,6 +221,9 @@ export class DsslEngine {
217
  this.texAnims = new WeakMap(); // element -> running texture-shimmer Animation
218
  this.texEls = new Set(); // elements that have one, so freeze() can stop them all
219
  this.tiltEls = new Map(); // 0.10.0: element -> { spec, stop } for tilt:
 
 
 
220
  }
221
 
222
  /* ------------------------------------------------------------ *
@@ -405,6 +412,7 @@ export class DsslEngine {
405
  const rules = [];
406
  const shadowRules = [];
407
  const raws = [];
 
408
  const bundles = [...this.bundles.values()].sort((a, b) => a.seq - b.seq);
409
  assignGlobalOrder(bundles);
410
  for (const b of bundles) {
@@ -414,7 +422,9 @@ export class DsslEngine {
414
  shadowRules.push(...b.shadowRules);
415
  // v0.8.0 - raw { } verbatim CSS rides the same document stylesheet
416
  if (b.raws) raws.push(...b.raws);
 
417
  }
 
418
  this.rules = rules;
419
  this.shadowRules = shadowRules;
420
  this.raws = raws;
@@ -453,6 +463,18 @@ export class DsslEngine {
453
  }
454
  }
455
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  mql(query) {
457
  let m = this.mqlCache.get(query);
458
  if (!m) {
@@ -581,7 +603,9 @@ export class DsslEngine {
581
  try {
582
  const rm = window.matchMedia('(prefers-reduced-motion: reduce)');
583
  rm.addEventListener('change', () => { if (!this.frozen) this.scheduleComputeAll(); });
584
- this.reducedMotion = rm;
 
 
585
  } catch { this.reducedMotion = { matches: false }; }
586
 
587
  // --- `seen` state (v0.4.0): fires once when the element scrolls into view ---
@@ -751,6 +775,8 @@ export class DsslEngine {
751
  if (s.op === 'truthy') { if (!has || val === false || val === null || val === 'false') return false; }
752
  else if (s.op === '==') { if (!has || String(val) !== String(s.value)) return false; }
753
  else if (s.op === '!=') { if (has && String(val) === String(s.value)) return false; }
 
 
754
  }
755
  return true;
756
  }
@@ -775,6 +801,8 @@ export class DsslEngine {
775
 
776
  computeAll() {
777
  this._colorCache.clear(); // 0.9.0: tokens or the theme may have changed, so colours are looked up again
 
 
778
  this._applying = true;
779
  const rootEl = this.scope || document.documentElement;
780
  const els = new Set([rootEl, ...this.touched]); // include previously-styled elements - their rules may have STOPPED matching
@@ -790,15 +818,28 @@ export class DsslEngine {
790
  found.forEach(el => els.add(el));
791
  }
792
  for (const el of els) this.computeElement(el);
 
793
  this._applying = false;
794
  this.applyStagger();
795
  this.applyArc(); // v0.6.0 - bend-around arcs after every drain
796
  this.scheduleOverlap(); // v0.5.0
797
  }
798
 
 
 
 
 
 
 
 
 
 
799
  computeElement(el) {
800
  if (!el || !el.style || el.nodeType !== 1) return;
801
  if (!this.inScope(el)) return;
 
 
 
802
  this.touched.add(el);
803
 
804
  const matched = [];
@@ -847,6 +888,26 @@ export class DsslEngine {
847
  * against what we own, and write ONLY the difference. */
848
 
849
  const owned = this.ownMap.get(el) || new Map(); // prop -> {value, imp}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
850
  const newOwned = new Map(); // prop -> {value, imp}
851
  const animSpecs = [];
852
  let cancelAnims = false;
@@ -928,9 +989,7 @@ export class DsslEngine {
928
  const single = wordsTop(lh.value).length === 1;
929
  if ((lh.seq || 0) > (sh.seq || 0) && single) newOwned.set(fam, { value: foldIntoBorder(sh.value, lh.value, lhName.slice(7)), imp: sh.imp, seq: lh.seq });
930
  newOwned.delete(lhName);
931
- if (!this._warned) this._warned = new Set();
932
- const wmsg = `[DSSL] folded ${lhName} into ${fam} on <${el.tagName.toLowerCase()}${el.className ? ' .' + String(el.className).split(' ')[0] : ''}> - inline style cannot hold a var() shorthand and its longhand together`;
933
- if (!this._warned.has(wmsg)) { this._warned.add(wmsg); console.info(wmsg); }
934
  }
935
  }
936
  for (const [fam, longhands] of Object.entries(POSITIONAL_FAMILIES)) {
@@ -1033,9 +1092,19 @@ export class DsslEngine {
1033
  }
1034
 
1035
  // SET pass - only what is new or actually changed.
 
1036
  for (const [prop, next] of newOwned) {
1037
  const prev = owned.get(prop);
1038
- if (prev && prev.value === next.value && prev.imp === next.imp) continue;
 
 
 
 
 
 
 
 
 
1039
  try {
1040
  el.style.setProperty(prop, next.value, next.imp);
1041
  } catch (why) {
@@ -1048,6 +1117,15 @@ export class DsslEngine {
1048
  }
1049
  }
1050
 
 
 
 
 
 
 
 
 
 
1051
  this.ownMap.set(el, newOwned);
1052
  this.lastApply = Date.now();
1053
 
@@ -1102,7 +1180,14 @@ export class DsslEngine {
1102
  const reduced = this.reducedMotion && this.reducedMotion.matches;
1103
  for (const spec of animSpecs) {
1104
  if (spec.broken || !spec.frames || !spec.frames.length) continue;
1105
- if (reduced && !spec.essential) continue;
 
 
 
 
 
 
 
1106
  if (anims.has(spec.key)) continue;
1107
  let frames = spec.frames.map(f => ({ ...f.props, offset: f.offset / 100 }));
1108
  if (frames.length === 1) {
@@ -1112,7 +1197,7 @@ export class DsslEngine {
1112
  try {
1113
  const anim = el.animate(frames, {
1114
  duration: spec.duration, easing: spec.easing,
1115
- delay: spec.delay || 0, fill: spec.fill,
1116
  iterations: spec.iterations || 1, // v0.5.0 - `infinite`
1117
  direction: spec.direction || 'normal', // v0.5.0 - `alternate`
1118
  });
@@ -1602,17 +1687,38 @@ export class DsslEngine {
1602
  let sel = prefix + r.sel + '::' + r.pseudo;
1603
  for (const s of r.signals) {
1604
  const attr = 'data-dssl-signal-' + s.name;
1605
- const cond = s.op === 'truthy' ? `html[${attr}]` : `html[${attr}="${s.value}"]`;
 
 
 
 
 
1606
  sel = cond + ' ' + sel;
1607
  }
1608
- let body = r.decls.map(d => ` ${d.prop}: ${d.value};`).join('\n');
1609
- let out = `${sel} {\n${body}\n}`;
 
 
 
 
 
 
 
 
 
 
 
 
1610
  for (const q of [...r.media].reverse()) out = `@media ${q} {\n${out}\n}`;
1611
  for (const c of [...r.containers].reverse()) out = `@container ${c} {\n${out}\n}`;
1612
  return out;
1613
  };
1614
 
1615
  let css = this.shadowRules.map(cssFor).join('\n\n');
 
 
 
 
1616
 
1617
  // v0.8.0 - raw { } blocks: verbatim CSS (@font-face, @keyframes,
1618
  // @supports, @import…), appended after the pseudo-element rules
 
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
 
 
79
  } catch { return ''; }
80
  }
81
 
82
+ // properties the engine also writes by other routes (the overlap nudge, bend-around, stack), so a mismatch there is not the author's doing
83
+ const ENGINE_ALSO_WRITES = new Set(['translate', 'transform', 'transform-origin', 'grid-area']);
84
+
85
  const ALIGN_PROPS = new Set([
86
  'place-items', 'place-content', 'align-items', 'justify-items',
87
  'align-content', 'justify-content',
 
221
  this.texAnims = new WeakMap(); // element -> running texture-shimmer Animation
222
  this.texEls = new Set(); // elements that have one, so freeze() can stop them all
223
  this.tiltEls = new Map(); // 0.10.0: element -> { spec, stop } for tilt:
224
+ this.authored = new WeakMap(); // 0.14.0: element -> Map(prop -> { value, imp }) of inline styles that belong to the page, not to us
225
+ this.staticSeen = false; // 0.14.0: is there any data-dssl-static subtree
226
+ this._forceCompute = false;
227
  }
228
 
229
  /* ------------------------------------------------------------ *
 
412
  const rules = [];
413
  const shadowRules = [];
414
  const raws = [];
415
+ const motionFrames = {};
416
  const bundles = [...this.bundles.values()].sort((a, b) => a.seq - b.seq);
417
  assignGlobalOrder(bundles);
418
  for (const b of bundles) {
 
422
  shadowRules.push(...b.shadowRules);
423
  // v0.8.0 - raw { } verbatim CSS rides the same document stylesheet
424
  if (b.raws) raws.push(...b.raws);
425
+ Object.assign(motionFrames, b.motionFrames || {}); // 0.14.0: one registry of motions across every sheet
426
  }
427
+ this.motionFrames = motionFrames;
428
  this.rules = rules;
429
  this.shadowRules = shadowRules;
430
  this.raws = raws;
 
463
  }
464
  }
465
 
466
+ /** a stable pseudo-random 0..max for this element, so a row of identical pickups does not bob in lockstep (and the same page always looks the same) */
467
+ jitterFor(el, max) {
468
+ if (!this._jit) this._jit = new WeakMap();
469
+ let r = this._jit.get(el);
470
+ if (r === undefined) {
471
+ this._jitSeed = ((this._jitSeed || 1) * 16807) % 2147483647;
472
+ r = this._jitSeed / 2147483647;
473
+ this._jit.set(el, r);
474
+ }
475
+ return Math.round(r * max);
476
+ }
477
+
478
  mql(query) {
479
  let m = this.mqlCache.get(query);
480
  if (!m) {
 
603
  try {
604
  const rm = window.matchMedia('(prefers-reduced-motion: reduce)');
605
  rm.addEventListener('change', () => { if (!this.frozen) this.scheduleComputeAll(); });
606
+ // 0.14.0: <html data-dssl-motion="full"> runs every motion whatever the system asks for (a game's motion is its content),
607
+ // "reduced" holds them all still, and anything else follows the system. DSSL.motion('full') sets it.
608
+ this.reducedMotion = { get matches() { const m = document.documentElement.getAttribute('data-dssl-motion'); return m === 'full' ? false : m === 'reduced' ? true : rm.matches; }, get system() { return rm.matches; } };
609
  } catch { this.reducedMotion = { matches: false }; }
610
 
611
  // --- `seen` state (v0.4.0): fires once when the element scrolls into view ---
 
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
779
+ else if (s.op === 'notin') { if (has && (s.values || []).includes(String(val))) return false; }
780
  }
781
  return true;
782
  }
 
801
 
802
  computeAll() {
803
  this._colorCache.clear(); // 0.9.0: tokens or the theme may have changed, so colours are looked up again
804
+ this.staticSeen = !!document.querySelector('[data-dssl-static]');
805
+ this._forceCompute = true; // a full recompute (the sheets changed, a media query flipped) reaches static subtrees too
806
  this._applying = true;
807
  const rootEl = this.scope || document.documentElement;
808
  const els = new Set([rootEl, ...this.touched]); // include previously-styled elements - their rules may have STOPPED matching
 
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;
823
  this.applyStagger();
824
  this.applyArc(); // v0.6.0 - bend-around arcs after every drain
825
  this.scheduleOverlap(); // v0.5.0
826
  }
827
 
828
+ /** restyle one subtree now, static or not (DSSL.refresh(el)) */
829
+ refreshSubtree(root) {
830
+ if (!root || root.nodeType !== 1 || !this.inScope(root)) return;
831
+ this._forceCompute = true; this._applying = true;
832
+ try { for (const el of [root, ...root.querySelectorAll('*')]) this.computeElement(el); }
833
+ finally { this._forceCompute = false; this._applying = false; }
834
+ this.applyStagger(); this.applyArc(); this.scheduleOverlap();
835
+ }
836
+
837
  computeElement(el) {
838
  if (!el || !el.style || el.nodeType !== 1) return;
839
  if (!this.inScope(el)) return;
840
+ // 0.14.0: a subtree marked data-dssl-static is styled once and then left alone: a class toggled on its parent, a media query,
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 = [];
 
888
  * against what we own, and write ONLY the difference. */
889
 
890
  const owned = this.ownMap.get(el) || new Map(); // prop -> {value, imp}
891
+ // 0.14.0: inline styles that are NOT ours. What the page wrote before the engine first looked (a style="" attribute, a script
892
+ // that placed the element) belongs to the author and wins over a rule, like in CSS; the same goes for a property a script
893
+ // changes after we wrote it. Before this the engine overwrote them, so a rule with `left: 0` undid a game's own positions.
894
+ let authored = this.authored.get(el);
895
+ if (!this.ownMap.has(el) && !authored) {
896
+ for (let i = 0; i < el.style.length; i++) {
897
+ const p = el.style[i];
898
+ if (p.startsWith('--dssl-') || p === 'grid-area') continue;
899
+ if (!authored) { authored = new Map(); this.authored.set(el, authored); }
900
+ authored.set(p, { value: el.style.getPropertyValue(p), imp: el.style.getPropertyPriority(p) });
901
+ }
902
+ }
903
+ if (authored) {
904
+ for (const [p, a] of authored) {
905
+ const cur = el.style.getPropertyValue(p);
906
+ if (cur === '') authored.delete(p); // the author took it away: the rule may have it now
907
+ else { a.value = cur; a.imp = el.style.getPropertyPriority(p); }
908
+ }
909
+ if (!authored.size) { this.authored.delete(el); authored = null; }
910
+ }
911
  const newOwned = new Map(); // prop -> {value, imp}
912
  const animSpecs = [];
913
  let cancelAnims = false;
 
989
  const single = wordsTop(lh.value).length === 1;
990
  if ((lh.seq || 0) > (sh.seq || 0) && single) newOwned.set(fam, { value: foldIntoBorder(sh.value, lh.value, lhName.slice(7)), imp: sh.imp, seq: lh.seq });
991
  newOwned.delete(lhName);
992
+ // (0.14.0: no console line here. Folding is the normal, correct way a border longhand meets its shorthand, not news.)
 
 
993
  }
994
  }
995
  for (const [fam, longhands] of Object.entries(POSITIONAL_FAMILIES)) {
 
1092
  }
1093
 
1094
  // SET pass - only what is new or actually changed.
1095
+ let wrote = false;
1096
  for (const [prop, next] of newOwned) {
1097
  const prev = owned.get(prop);
1098
+ // a script changed a property we wrote: it is the author's now (the few the engine also writes by other routes are exempt)
1099
+ if (prev && prev.rb !== undefined && !ENGINE_ALSO_WRITES.has(prop) && el.style.getPropertyValue(prop) !== prev.rb) {
1100
+ if (!authored) { authored = new Map(); this.authored.set(el, authored); }
1101
+ authored.set(prop, { value: el.style.getPropertyValue(prop), imp: el.style.getPropertyPriority(prop) });
1102
+ newOwned.delete(prop); owned.delete(prop);
1103
+ continue;
1104
+ }
1105
+ if (authored && authored.has(prop)) { newOwned.delete(prop); continue; }
1106
+ if (prev && prev.value === next.value && prev.imp === next.imp) { next.rb = prev.rb; continue; }
1107
+ wrote = true;
1108
  try {
1109
  el.style.setProperty(prop, next.value, next.imp);
1110
  } catch (why) {
 
1117
  }
1118
  }
1119
 
1120
+ // a shorthand we just wrote (margin: 0) can have wiped a longhand the author set (margin-top: 14): put theirs back
1121
+ if (authored) {
1122
+ for (const [p, a] of authored) {
1123
+ try { if (el.style.getPropertyValue(p) !== a.value) el.style.setProperty(p, a.value, a.imp); } catch { /* ignore */ }
1124
+ }
1125
+ }
1126
+ // what the browser holds for each property now, read once after every write: a longhand written after its shorthand changes how
1127
+ // the shorthand reads back, so a per-property reading taken at write time would look like someone else's edit
1128
+ if (wrote || authored) for (const [p, v] of newOwned) v.rb = el.style.getPropertyValue(p);
1129
  this.ownMap.set(el, newOwned);
1130
  this.lastApply = Date.now();
1131
 
 
1180
  const reduced = this.reducedMotion && this.reducedMotion.matches;
1181
  for (const spec of animSpecs) {
1182
  if (spec.broken || !spec.frames || !spec.frames.length) continue;
1183
+ if (reduced && !spec.essential) {
1184
+ // never silent: a game or a tutorial can lose all its motion to a system setting and look broken
1185
+ if (!this._reducedTold) {
1186
+ this._reducedTold = true;
1187
+ console.info(`[DSSL] "${spec.motion}" and any other animate: that is not marked essential did not run, because this browser asks for reduced motion (prefers-reduced-motion: reduce). Motion that IS the content should say \`essential\` inside its motion block, or set <html data-dssl-motion="full"> (DSSL.motion('full')) to run everything.`);
1188
+ }
1189
+ continue;
1190
+ }
1191
  if (anims.has(spec.key)) continue;
1192
  let frames = spec.frames.map(f => ({ ...f.props, offset: f.offset / 100 }));
1193
  if (frames.length === 1) {
 
1197
  try {
1198
  const anim = el.animate(frames, {
1199
  duration: spec.duration, easing: spec.easing,
1200
+ delay: (spec.delay || 0) + (spec.jitter ? this.jitterFor(el, spec.jitter) : 0), fill: spec.fill,
1201
  iterations: spec.iterations || 1, // v0.5.0 - `infinite`
1202
  direction: spec.direction || 'normal', // v0.5.0 - `alternate`
1203
  });
 
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
+ }
1711
+ let out = `${sel} {\n${body}\n}${extra}`;
1712
  for (const q of [...r.media].reverse()) out = `@media ${q} {\n${out}\n}`;
1713
  for (const c of [...r.containers].reverse()) out = `@container ${c} {\n${out}\n}`;
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:
1720
+ const kf = keyframesCss(this.motionFrames);
1721
+ if (kf.length) css += '\n\n' + kf.join('\n\n');
1722
 
1723
  // v0.8.0 - raw { } blocks: verbatim CSS (@font-face, @keyframes,
1724
  // @supports, @import…), appended after the pseudo-element rules
public/dssl/index.js CHANGED
@@ -15,11 +15,11 @@
15
  * ========================================================================= */
16
 
17
  import { parse } from './parser.js';
18
- import { resolve, DEFAULT_TOKENS } from './resolver.js';
19
  import { toCSS as docToCSS } from './static.js';
20
  import { DsslEngine } from './engine.js';
21
 
22
- export const VERSION = '0.13.0';
23
 
24
  let counter = 0;
25
  const loadedUrls = new Set(); // every .dssl file fetched by load(), so DSSL.watch() knows what to follow
@@ -29,6 +29,8 @@ let lastErrors = [];
29
  // Token registry shared across bundles: tokens.dssl defines the palette,
30
  // every later stylesheet resolves against it.
31
  const tokenRegistry = new Map(Object.entries(DEFAULT_TOKENS));
 
 
32
 
33
  function getEngine(opts = {}) {
34
  if (!opts.scope) {
@@ -72,7 +74,7 @@ function reportWarnings(warnings, file) {
72
  /** Parse + resolve without touching the DOM. */
73
  export function compile(source, file = 'inline.dssl') {
74
  const { ast, errors: parseErrors } = parse(source, file);
75
- const doc = resolve(ast, file, Object.fromEntries(tokenRegistry));
76
  const errors = [...parseErrors, ...doc.errors];
77
  return {
78
  ok: errors.length === 0,
@@ -96,6 +98,7 @@ export function apply(source, opts = {}) {
96
  const name = opts.name || 'inline-' + (++counter);
97
  const file = name.endsWith('.dssl') ? name : name + '.dssl';
98
  const result = compile(source, file);
 
99
  // fold this bundle's tokens into the shared registry for later bundles
100
  let registryDirty = false;
101
  for (const [k, v] of Object.entries(result.doc.tokens || {})) {
@@ -155,14 +158,15 @@ export function toCSS(input, opts = {}) {
155
  // one source string, or several (strings or already-compiled documents) in load order: tokens fold forward like <link> order
156
  const list = Array.isArray(input) ? input : [input];
157
  const docs = [], errors = [], warnings = [];
158
- let base = {};
159
  for (const [i, item] of list.entries()) {
160
  if (typeof item === 'string') {
161
  const file = (list.length === 1 ? opts.file : (opts.files && opts.files[i])) || `inline-${i + 1}.dssl`;
162
  const { ast, errors: pe } = parse(item, file);
163
- const doc = resolve(ast, file, Object.keys(base).length ? base : Object.fromEntries(tokenRegistry));
164
  docs.push(doc); errors.push(...pe, ...doc.errors); warnings.push(...(doc.warnings || []));
165
  base = { ...base, ...doc.tokens };
 
166
  } else { docs.push(item); errors.push(...(item.errors || [])); warnings.push(...(item.warnings || [])); }
167
  }
168
  const built = docToCSS(docs.length === 1 ? docs[0] : docs, { file: opts.file || 'inline.dssl', ...opts, version: VERSION });
@@ -171,7 +175,69 @@ export function toCSS(input, opts = {}) {
171
 
172
  export function signal(name, value) { for (const e of engines.values()) e.signal(name, value); }
173
  export function getSignal(name) { return engines.get('global')?.getSignal(name); }
174
- export function refresh() { for (const e of engines.values()) e.computeAll(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  export function replay(selector) { for (const e of engines.values()) e.replay(selector); }
176
  export function errors() { return lastErrors; }
177
  export function version() { return VERSION; }
@@ -179,12 +245,15 @@ export function version() { return VERSION; }
179
  // v0.7.0 - freeze()/unfreeze() every engine: the whole page stops reacting
180
  // (observers, media, state events, overlap heartbeat) and keeps its resolved
181
  // look for free. Explicit API calls still drain. Returns combined stats.
182
- export function freeze() {
 
 
183
  const stats = [];
184
  for (const e of engines.values()) stats.push(e.freeze());
185
  return stats.length === 1 ? stats[0] : stats;
186
  }
187
- export function unfreeze() {
 
188
  const stats = [];
189
  for (const e of engines.values()) stats.push(e.unfreeze());
190
  return stats.length === 1 ? stats[0] : stats;
@@ -233,6 +302,12 @@ export async function inspect(on = true) {
233
  d.inspector.start();
234
  return true;
235
  }
 
 
 
 
 
 
236
  /** DSSL.watch({ interval, css, toast }) re-applies a .dssl file when it changes on disk; DSSL.watch(false) stops */
237
  export async function watch(o = {}) {
238
  const d = await dev();
@@ -243,7 +318,7 @@ export async function watch(o = {}) {
243
 
244
  export const DSSL = {
245
  version: VERSION, compile, apply, load, toCSS, signal, getSignal, refresh, replay, errors,
246
- freeze, unfreeze, explain, inspect, watch,
247
  __debug: { engines },
248
  };
249
 
 
15
  * ========================================================================= */
16
 
17
  import { parse } from './parser.js';
18
+ 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
 
29
  // Token registry shared across bundles: tokens.dssl defines the palette,
30
  // every later stylesheet resolves against it.
31
  const tokenRegistry = new Map(Object.entries(DEFAULT_TOKENS));
32
+ // ...and so do motions (0.14.0): `motion waddle { }` in one file can be used by `animate: waddle` in another
33
+ const motionRegistry = new Map();
34
 
35
  function getEngine(opts = {}) {
36
  if (!opts.scope) {
 
74
  /** Parse + resolve without touching the DOM. */
75
  export function compile(source, file = 'inline.dssl') {
76
  const { ast, errors: parseErrors } = parse(source, file);
77
+ const doc = resolve(ast, file, Object.fromEntries(tokenRegistry), Object.fromEntries(motionRegistry));
78
  const errors = [...parseErrors, ...doc.errors];
79
  return {
80
  ok: errors.length === 0,
 
98
  const name = opts.name || 'inline-' + (++counter);
99
  const file = name.endsWith('.dssl') ? name : name + '.dssl';
100
  const result = compile(source, file);
101
+ for (const [k, v] of Object.entries(result.doc.motionFrames || {})) motionRegistry.set(k, v);
102
  // fold this bundle's tokens into the shared registry for later bundles
103
  let registryDirty = false;
104
  for (const [k, v] of Object.entries(result.doc.tokens || {})) {
 
158
  // one source string, or several (strings or already-compiled documents) in load order: tokens fold forward like <link> order
159
  const list = Array.isArray(input) ? input : [input];
160
  const docs = [], errors = [], warnings = [];
161
+ let base = {}, motionBase = {};
162
  for (const [i, item] of list.entries()) {
163
  if (typeof item === 'string') {
164
  const file = (list.length === 1 ? opts.file : (opts.files && opts.files[i])) || `inline-${i + 1}.dssl`;
165
  const { ast, errors: pe } = parse(item, file);
166
+ const doc = resolve(ast, file, Object.keys(base).length ? base : Object.fromEntries(tokenRegistry), motionBase);
167
  docs.push(doc); errors.push(...pe, ...doc.errors); warnings.push(...(doc.warnings || []));
168
  base = { ...base, ...doc.tokens };
169
+ motionBase = { ...motionBase, ...(doc.motionFrames || {}) };
170
  } else { docs.push(item); errors.push(...(item.errors || [])); warnings.push(...(item.warnings || [])); }
171
  }
172
  const built = docToCSS(docs.length === 1 ? docs[0] : docs, { file: opts.file || 'inline.dssl', ...opts, version: VERSION });
 
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
181
+ * (what you want for a pop-in). It follows reduced motion like animate: does, unless the motion or the call says essential.
182
+ * Before this you added a class, set a timer, and removed the class, once per replay.
183
+ */
184
+ export function play(el, name, opts = {}) {
185
+ const m = motionRegistry.get(name);
186
+ if (!m) {
187
+ const known = [...motionRegistry.keys()];
188
+ const hint = didYouMeanLocal(name, known);
189
+ return Promise.reject(new Error(`[DSSL] play: unknown motion "${name}"${hint ? ` (did you mean "${hint}"?)` : known.length ? ` (known: ${known.join(', ')})` : ' (no motion is defined yet)'}`));
190
+ }
191
+ if (!el || typeof el.animate !== 'function') return Promise.reject(new Error('[DSSL] play: the first argument must be an element'));
192
+ const eng = engines.get('global');
193
+ const reduced = eng ? eng.reducedMotion.matches : false;
194
+ if (reduced && !m.essential && !opts.essential) return Promise.resolve(null);
195
+ let frames = m.frames.map((f) => ({ ...f.props, offset: f.offset / 100 }));
196
+ if (frames.length === 1) frames = frames[0].offset === 0 ? [frames[0], { offset: 1 }] : [{ offset: 0 }, frames[0]];
197
+ const ms = (v, d) => (typeof v === 'string' ? (/ms$/.test(v) ? parseFloat(v) : /s$/.test(v) ? parseFloat(v) * 1000 : parseFloat(v)) : (v === undefined ? d : v));
198
+ const easing = opts.easing ? (EASINGS[opts.easing] || opts.easing) : EASINGS.smooth;
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;
210
+ if (z === null || z === undefined) root.removeAttribute('data-dssl-aura-z'); else root.setAttribute('data-dssl-aura-z', String(z));
211
+ for (const e of engines.values()) if (e.aura && e.aura.canvas) e.aura.canvas.style.zIndex = z === null || z === undefined ? '2147483000' : String(z);
212
+ return z;
213
+ }
214
+
215
+ /** DSSL.motion('full') runs every animate: whatever the system asks for, 'reduced' holds them all still, 'auto' follows the system */
216
+ export function motion(mode = 'auto') {
217
+ const root = document.documentElement;
218
+ if (mode === 'auto') root.removeAttribute('data-dssl-motion'); else root.setAttribute('data-dssl-motion', mode === 'full' ? 'full' : 'reduced');
219
+ for (const e of engines.values()) {
220
+ // stop what is running, then let the recompute start only what the new mode allows
221
+ for (const el of e.touched) { const m = e.animMap.get(el); if (m) { m.forEach((a) => { try { a.cancel(); } catch { } }); m.clear(); } }
222
+ e.computeAll();
223
+ }
224
+ return mode;
225
+ }
226
+
227
+ // a tiny did-you-mean for play(), so index.js does not need utils for it
228
+ function didYouMeanLocal(w, list) {
229
+ let best = null, bd = 3;
230
+ for (const c of list) {
231
+ const a = w, b = c; const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
232
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
233
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
234
+ if (d[a.length][b.length] < bd) { bd = d[a.length][b.length]; best = c; }
235
+ }
236
+ return best;
237
+ }
238
+
239
+ /** DSSL.refresh() restyles the whole page; DSSL.refresh(el) restyles that subtree, even if it is static */
240
+ export function refresh(root) { for (const e of engines.values()) { if (root && root.nodeType === 1) e.refreshSubtree(root); else e.computeAll(); } }
241
  export function replay(selector) { for (const e of engines.values()) e.replay(selector); }
242
  export function errors() { return lastErrors; }
243
  export function version() { return VERSION; }
 
245
  // v0.7.0 - freeze()/unfreeze() every engine: the whole page stops reacting
246
  // (observers, media, state events, overlap heartbeat) and keeps its resolved
247
  // look for free. Explicit API calls still drain. Returns combined stats.
248
+ export function freeze(root) {
249
+ // 0.14.0: freeze(el) marks ONE subtree static (styled once, then left alone); freeze() with no argument still parks the whole page
250
+ if (root && root.nodeType === 1) { root.setAttribute('data-dssl-static', ''); for (const e of engines.values()) e.staticSeen = true; return root; }
251
  const stats = [];
252
  for (const e of engines.values()) stats.push(e.freeze());
253
  return stats.length === 1 ? stats[0] : stats;
254
  }
255
+ export function unfreeze(root) {
256
+ if (root && root.nodeType === 1) { root.removeAttribute('data-dssl-static'); for (const e of engines.values()) e.refreshSubtree(root); return root; }
257
  const stats = [];
258
  for (const e of engines.values()) stats.push(e.unfreeze());
259
  return stats.length === 1 ? stats[0] : stats;
 
302
  d.inspector.start();
303
  return true;
304
  }
305
+ /** DSSL.lint(root?, { min, images, log }) resolves to what is wrong on the page as it is drawn: low contrast text, engine-made boxes, motion held back */
306
+ export async function lint(root, opts) {
307
+ const d = await dev();
308
+ return devMod.lintDocument(root || null, { explain, engines: [...engines.values()] }, opts || {});
309
+ }
310
+
311
  /** DSSL.watch({ interval, css, toast }) re-applies a .dssl file when it changes on disk; DSSL.watch(false) stops */
312
  export async function watch(o = {}) {
313
  const d = await dev();
 
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
  };
324
 
public/dssl/lint.js CHANGED
@@ -52,6 +52,25 @@ const INTERACTIVE = /^(a|button|input|select|textarea|summary)\b|\[tabindex|\[ro
52
  * @param {object} p { rules, tokenMap, file }
53
  * @returns {Array} warnings in the compiler's {file, line, message, hint} shape
54
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export function lintRules({ rules, tokenMap, file }) {
56
  const warnings = [];
57
  const cssToColor = makeColorResolver(tokenMap);
@@ -106,21 +125,6 @@ export function lintRules({ rules, tokenMap, file }) {
106
  }
107
  }
108
 
109
- // ---- alignment with no box to align in
110
- // The engine quietly turns a plain box into a grid when a rule aligns its children (place-items, justify-content...),
111
- // so the page looks right in the browser. `dssl build` cannot know what each element is, so a compiled page would not.
112
- if (!conditional && states.size === 0) {
113
- const aligns = r.decls.find((d) => ALIGN_PROPS.has(d.prop));
114
- const hasDisplay = r.decls.some((d) => d.prop === 'display' || d.prop === '@display');
115
- if (aligns && !hasDisplay) {
116
- warnings.push({
117
- file, line: aligns.line,
118
- message: `"${aligns.prop}" on "${r.sel}" only works on a flex or grid box, and this rule never says which`,
119
- hint: 'add display: grid (or flex), or use row / column / grid, so a build matches the engine: the engine adds it for you at run time, dssl build does not',
120
- });
121
- }
122
- }
123
-
124
  // ---- focus
125
  if (hasFocus) focusBases.add(r.sel.replace(/:(focus-visible|focus)\b/g, '').trim());
126
  if (!hasFocus && INTERACTIVE.test(finalCompound(r.sel).replace(/[.#:][\w-]+.*$/, (m) => m.startsWith('[') ? m : '') || finalCompound(r.sel))) {
 
52
  * @param {object} p { rules, tokenMap, file }
53
  * @returns {Array} warnings in the compiler's {file, line, message, hint} shape
54
  */
55
+ /**
56
+ * Rules that align their children and never say what kind of box they are. The engine quietly makes the box a grid when it
57
+ * runs; a `dssl build` cannot, because a stylesheet does not know what each element is. So this is reported by the build (and
58
+ * by DSSL.lint on a live page, which knows exactly which elements were promoted), not by `check`: per rule it cannot see that
59
+ * a different rule gave the same element display: flex, and it was wrong about that often enough to be noise.
60
+ */
61
+ export function alignWithoutBox(rules) {
62
+ const out = [];
63
+ for (const r of rules) {
64
+ if (r.pseudo) continue;
65
+ const conditional = (r.media && r.media.length) || (r.signals && r.signals.length) || (r.containers && r.containers.length);
66
+ const states = r.states || new Set();
67
+ if (conditional || states.size) continue;
68
+ const aligns = r.decls.find((d) => ALIGN_PROPS.has(d.prop));
69
+ if (aligns && !r.decls.some((d) => d.prop === 'display' || d.prop === '@display')) out.push({ sel: r.sel, prop: aligns.prop, line: aligns.line, file: r.file });
70
+ }
71
+ return out;
72
+ }
73
+
74
  export function lintRules({ rules, tokenMap, file }) {
75
  const warnings = [];
76
  const cssToColor = makeColorResolver(tokenMap);
 
125
  }
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  // ---- focus
129
  if (hasFocus) focusBases.add(r.sel.replace(/:(focus-visible|focus)\b/g, '').trim());
130
  if (!hasFocus && INTERACTIVE.test(finalCompound(r.sel).replace(/[.#:][\w-]+.*$/, (m) => m.startsWith('[') ? m : '') || finalCompound(r.sel))) {
public/dssl/resolver.js CHANGED
@@ -38,6 +38,7 @@ import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
38
  // exported for the editor extension: completions ride the same list the
39
  // compiler validates against, so the two can never drift apart
40
  export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,auto,currentcolor,transparent,' +
 
41
  'black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
42
  'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' +
43
  'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' +
@@ -454,13 +455,38 @@ const INTERP_RE = /\s*\bin\s+(srgb-linear|srgb|display-p3|lab|oklab|xyz-d50|xyz-
454
  * radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 layers, first on top
455
  */
456
  function buildGradient(v, ctx) {
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  const layers = splitLayers(String(v).trim());
458
- if (layers.length > 1) {
459
- const out = [];
460
- for (const l of layers) { const g = buildGradientLayer(l, ctx); if (g === null) return null; out.push(g); }
461
- return out.join(', ');
 
 
 
 
 
 
 
 
 
 
 
462
  }
463
- return buildGradientLayer(String(v).trim(), ctx);
 
464
  }
465
 
466
  function buildMesh(rest, ctx) {
@@ -787,12 +813,15 @@ const SEMANTIC = {
787
  if (['sm', 'md', 'lg', 'xl', 'none'].includes(t)) {
788
  return [{ prop: 'box-shadow', value: t === 'none' ? 'none' : `var(--dssl-shadow-${t})` }];
789
  }
 
 
790
  return [{ prop: 'box-shadow', value: resolveValue(v, ctx) }];
791
  },
792
  radius(v, ctx) {
793
  const atoms = v.trim().split(/\s+/).filter(Boolean);
794
  const out = atoms.map(a => {
795
  if (['sm', 'md', 'lg', 'full', 'xl'].includes(a)) return `var(--dssl-radius-${a})`;
 
796
  return resolveValue(a, ctx);
797
  });
798
  return [{ prop: 'border-radius', value: out.join(' ') }];
@@ -901,10 +930,20 @@ const SEMANTIC = {
901
  if (t === 'none') return [{ prop: '@animate', value: null }];
902
  const atoms = t.split(/\s+/);
903
  const spec = { motion: atoms[0], duration: 400, easing: EASINGS.smooth, fill: 'both', delay: 0 };
 
904
  for (let k = 1; k < atoms.length; k++) {
905
  const a = atoms[k];
 
 
 
 
 
 
 
 
 
906
  if (/^\d+(ms|s)?$/.test(a)) {
907
- const ms = a.endsWith('s') && !a.endsWith('ms') ? parseFloat(a) * 1000 : parseFloat(a);
908
  if (k === 1) spec.duration = ms; else spec.delay = ms;
909
  continue;
910
  }
@@ -914,7 +953,7 @@ const SEMANTIC = {
914
  // animate: pulse 1600 infinite alternate - was impossible before.
915
  if (a === 'infinite') { spec.iterations = Infinity; continue; }
916
  if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
917
- 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'])) });
918
  }
919
  return [{ prop: '@animate', value: spec }];
920
  },
@@ -1006,8 +1045,31 @@ const SEMANTIC = {
1006
  * gradient into the glyphs. gradient-flow loops background-position so a
1007
  * gradient drifts forever - one line instead of keyframe folklore. */
1008
  gradient(v, ctx) {
1009
- const img = buildGradient(v, ctx);
1010
- return img ? [{ prop: 'background-image', value: img }] : [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1011
  },
1012
  'gradient-text'(v, ctx) {
1013
  const img = buildGradient(v, ctx);
@@ -1260,6 +1322,13 @@ function normalizeMedia(query, ctx, line) {
1260
  }
1261
 
1262
  function parseSignal(expr, ctx, line) {
 
 
 
 
 
 
 
1263
  const m = expr.match(/^([A-Za-z_][\w-]*)\s*(==|!=)\s*(.+)$/);
1264
  if (m) {
1265
  let v = m[3].trim();
@@ -1275,7 +1344,7 @@ function parseSignal(expr, ctx, line) {
1275
  * The flattener
1276
  * ------------------------------------------------------------------ */
1277
 
1278
- export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1279
  const errors = [];
1280
  const rules = [];
1281
  const shadowRules = [];
@@ -1333,15 +1402,24 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1333
 
1334
  const parseMotion = (name) => {
1335
  const node = motions[name];
1336
- if (!node) return null;
 
 
1337
  if (node._parsed) return node._parsed;
1338
  node._parsed = { essential: false, frames: [] };
 
1339
  const frameOf = (label) => {
1340
- if (label === 'from') return 0;
1341
- if (label === 'to') return 100;
1342
- const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
 
1343
  return m ? parseFloat(m[1]) : null;
1344
  };
 
 
 
 
 
1345
  const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
1346
  for (const child of node.children) {
1347
  if (child.kind === 'decl' && child.prop === 'essential') {
@@ -1353,9 +1431,9 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1353
  continue;
1354
  }
1355
  if (child.kind === 'selector') {
1356
- const offset = frameOf(child.sel);
1357
- if (offset === null) {
1358
- errors.push({ file, line: child.line, message: `bad keyframe selector "${child.sel}"`, hint: 'use from, to, or at 40%' });
1359
  continue;
1360
  }
1361
  const props = {};
@@ -1386,7 +1464,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1386
  props.transform = c.transform;
1387
  if (c.origin) props.transformOrigin = c.origin;
1388
  }
1389
- node._parsed.frames.push({ offset, props });
1390
  }
1391
  }
1392
  node._parsed.frames.sort((a, b) => a.offset - b.offset);
@@ -1553,7 +1631,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1553
  // used to leak into the shadow sheet as junk CSS the browser
1554
  // silently dropped - exactly the silence this language refuses.
1555
  for (const r of resolved) {
1556
- if (r.prop.startsWith('@')) {
1557
  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' });
1558
  continue;
1559
  }
@@ -1637,7 +1715,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1637
  // nested pseudo path): no DOM handle means no nudge, animate or stack.
1638
  const decls = [];
1639
  for (const d of sub.decls) {
1640
- if (d.prop && d.prop.startsWith('@')) {
1641
  errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo rule' });
1642
  continue;
1643
  }
@@ -1743,6 +1821,36 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1743
 
1744
  // parse all motions that were referenced
1745
  for (const r of [...rules, ...shadowRules]) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1746
  for (const d of r.decls) {
1747
  if (d.prop === '@animate' && d.value && d.value.motion) {
1748
  const m = parseMotion(d.value.motion);
@@ -1755,7 +1863,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1755
  d.value.broken = true;
1756
  } else {
1757
  d.value.frames = m.frames;
1758
- d.value.essential = m.essential;
1759
  }
1760
  }
1761
  // v0.8.0 - the animation shorthand is CSS passthrough, so a motion
@@ -1764,7 +1872,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1764
  // from raw { } or another stylesheet, the warning says it can be ignored.
1765
  if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') {
1766
  const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w));
1767
- const named = words.find(w => motions[w]);
1768
  if (words.length && !named) {
1769
  warnings.push({
1770
  file, line: d.line,
 
38
  // exported for the editor extension: completions ride the same list the
39
  // compiler validates against, so the two can never drift apart
40
  export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,auto,currentcolor,transparent,' +
41
+ 'from,stroke,markers,normal,' + // 0.14.0: conic-gradient(from 0deg...), paint-order: stroke fill
42
  'black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
43
  'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' +
44
  'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' +
 
455
  * radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 layers, first on top
456
  */
457
  function buildGradient(v, ctx) {
458
+ const parts = buildGradientParts(v, ctx);
459
+ return parts ? parts.image : null;
460
+ }
461
+
462
+ /**
463
+ * The same, with each layer's own size, position and repeat. After a layer, in any order:
464
+ * tile 24 background-size 24px (tile 24 12 for width and height)
465
+ * offset 4 4 background-position
466
+ * no-repeat, repeat-x, repeat-y
467
+ * and a layer can be a plain CSS function (radial-gradient(...), url(...)) when the sentence has no word for it.
468
+ * Returns { image, size, position, repeat } where size, position and repeat are null unless some layer set one.
469
+ */
470
+ function buildGradientParts(v, ctx) {
471
  const layers = splitLayers(String(v).trim());
472
+ const out = { images: [], sizes: [], positions: [], repeats: [] };
473
+ const px = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + 'px' : x);
474
+ for (let layer of layers) {
475
+ let size = null, pos = null, rep = null;
476
+ for (;;) {
477
+ let m;
478
+ if ((m = layer.match(/\s+tile\s+(\S+)(?:\s+(\S+))?$/i)) && !/^(tile|offset)$/i.test(m[1])) { size = px(m[1]) + ' ' + px(m[2] || m[1]); layer = layer.slice(0, m.index); continue; }
479
+ if ((m = layer.match(/\s+offset\s+(\S+)\s+(\S+)$/i))) { pos = px(m[1]) + ' ' + px(m[2]); layer = layer.slice(0, m.index); continue; }
480
+ if ((m = layer.match(/\s+(no-repeat|repeat-x|repeat-y)$/i))) { rep = m[1].toLowerCase(); layer = layer.slice(0, m.index); continue; }
481
+ break;
482
+ }
483
+ const isCssFn = /^(?:repeating-)?(?:linear|radial|conic)-gradient\(|^url\(|^image-set\(|^-webkit-/i.test(layer.trim());
484
+ const img = isCssFn ? resolveValue(layer.trim(), { ...ctx, unitless: false, bareIdents: false }) : buildGradientLayer(layer, ctx);
485
+ if (img === null) return null;
486
+ out.images.push(img); out.sizes.push(size); out.positions.push(pos); out.repeats.push(rep);
487
  }
488
+ const listOf = (arr, dflt) => (arr.some((x) => x !== null) ? arr.map((x) => x || dflt).join(', ') : null);
489
+ return { image: out.images.join(', '), size: listOf(out.sizes, 'auto'), position: listOf(out.positions, '0 0'), repeat: listOf(out.repeats, 'repeat') };
490
  }
491
 
492
  function buildMesh(rest, ctx) {
 
813
  if (['sm', 'md', 'lg', 'xl', 'none'].includes(t)) {
814
  return [{ prop: 'box-shadow', value: t === 'none' ? 'none' : `var(--dssl-shadow-${t})` }];
815
  }
816
+ // 0.14.0: your own shadow token works by its bare name, like the built-in ones: tokens { shadow { pop: ... } } then shadow: pop
817
+ if (/^[A-Za-z][\w-]*$/.test(t) && ctx.tokens && ctx.tokens.has('shadow.' + t)) return [{ prop: 'box-shadow', value: `var(--dssl-shadow-${t})` }];
818
  return [{ prop: 'box-shadow', value: resolveValue(v, ctx) }];
819
  },
820
  radius(v, ctx) {
821
  const atoms = v.trim().split(/\s+/).filter(Boolean);
822
  const out = atoms.map(a => {
823
  if (['sm', 'md', 'lg', 'full', 'xl'].includes(a)) return `var(--dssl-radius-${a})`;
824
+ if (/^[A-Za-z][\w-]*$/.test(a) && ctx.tokens && ctx.tokens.has('radius.' + a)) return `var(--dssl-radius-${a})`; // 0.14.0: radius { pill: 99 } then radius: pill
825
  return resolveValue(a, ctx);
826
  });
827
  return [{ prop: 'border-radius', value: out.join(' ') }];
 
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
  }
 
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
  },
 
1045
  * gradient into the glyphs. gradient-flow loops background-position so a
1046
  * gradient drifts forever - one line instead of keyframe folklore. */
1047
  gradient(v, ctx) {
1048
+ const g = buildGradientParts(v, ctx);
1049
+ if (!g) return [];
1050
+ const out = [{ prop: 'background-image', value: g.image, fromGradient: true }];
1051
+ if (g.size) out.push({ prop: 'background-size', value: g.size });
1052
+ if (g.position) out.push({ prop: 'background-position', value: g.position });
1053
+ if (g.repeat) out.push({ prop: 'background-repeat', value: g.repeat });
1054
+ return out;
1055
+ },
1056
+ // 0.14.0: an outline round text, in one line. The stroke is drawn first and the fill over it, so the outline sits OUTSIDE
1057
+ // the letters and does not eat them (the stroke-plus-paint-order trick, written once). `inside` keeps the default centred stroke.
1058
+ // text-stroke: 6 color.ink text-stroke: 2 #fff inside
1059
+ 'text-stroke'(v, ctx) {
1060
+ const atoms = (v || '').trim().split(/\s+/).filter(Boolean);
1061
+ if (atoms.length === 1 && atoms[0] === 'none') return [{ prop: '-webkit-text-stroke', value: '0' }, { prop: 'paint-order', value: 'normal' }];
1062
+ const inside = atoms.includes('inside');
1063
+ const rest = atoms.filter((a) => a !== 'inside');
1064
+ if (!rest.length || !/^\d+(\.\d+)?(px|em|rem)?$/.test(rest[0])) {
1065
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'text-stroke needs a width first', hint: 'text-stroke: 6 color.ink' });
1066
+ return [];
1067
+ }
1068
+ const width = /^\d+(\.\d+)?$/.test(rest[0]) ? rest[0] + 'px' : rest[0];
1069
+ const color = rest[1] ? resolveValue(rest.slice(1).join(' '), { ...ctx, unitless: false, bareIdents: false }) : 'currentcolor';
1070
+ const out = [{ prop: '-webkit-text-stroke', value: `${width} ${color}` }];
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);
 
1322
  }
1323
 
1324
  function parseSignal(expr, ctx, line) {
1325
+ // 0.14.0: a set of values: @signal screen in (menu, shop, settings) and @signal screen not in (playing)
1326
+ const list = expr.match(/^([A-Za-z_][\w-]*)\s+(not\s+in|in)\s*\(([^)]*)\)\s*$/);
1327
+ if (list) {
1328
+ const values = list[3].split(',').map((x) => x.trim().replace(/^(["'])(.*)\1$/, '$2')).filter((x) => x !== '');
1329
+ if (!values.length) { ctx.errors.push({ file: ctx.file, line, message: `@signal ${list[1]} ${list[2]} ( ) has no values`, hint: '@signal screen in (menu, shop)' }); }
1330
+ return { name: list[1], op: /^not/.test(list[2]) ? 'notin' : 'in', value: values.join(','), values };
1331
+ }
1332
  const m = expr.match(/^([A-Za-z_][\w-]*)\s*(==|!=)\s*(.+)$/);
1333
  if (m) {
1334
  let v = m[3].trim();
 
1344
  * The flattener
1345
  * ------------------------------------------------------------------ */
1346
 
1347
+ export function resolve(ast, file = 'inline.dssl', baseTokens = null, baseMotions = null) {
1348
  const errors = [];
1349
  const rules = [];
1350
  const shadowRules = [];
 
1402
 
1403
  const parseMotion = (name) => {
1404
  const node = motions[name];
1405
+ // 0.14.0: a motion defined in another stylesheet counts, like a token does. `baseMotions` holds the ones the files loaded
1406
+ // before this one defined, already turned into keyframes.
1407
+ if (!node) return (baseMotions && baseMotions[name]) || null;
1408
  if (node._parsed) return node._parsed;
1409
  node._parsed = { essential: false, frames: [] };
1410
+ // from, to, at 40%, and since 0.14.0 plain CSS habits too: 0%, 40%, and a list like 0%, 100%
1411
  const frameOf = (label) => {
1412
+ const t = label.trim();
1413
+ if (t === 'from') return 0;
1414
+ if (t === 'to') return 100;
1415
+ const m = t.match(/^(?:at\s+)?([0-9.]+)\s*%?$/);
1416
  return m ? parseFloat(m[1]) : null;
1417
  };
1418
+ const framesOf = (label) => {
1419
+ const parts = label.split(',').map((x) => x.trim()).filter(Boolean);
1420
+ const offs = parts.map(frameOf);
1421
+ return offs.some((o) => o === null || o < 0 || o > 100) ? null : offs;
1422
+ };
1423
  const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
1424
  for (const child of node.children) {
1425
  if (child.kind === 'decl' && child.prop === 'essential') {
 
1431
  continue;
1432
  }
1433
  if (child.kind === 'selector') {
1434
+ const offsets = framesOf(child.sel);
1435
+ if (offsets === null) {
1436
+ errors.push({ file, line: child.line, message: `bad keyframe selector "${child.sel}"`, hint: 'use from, to, at 40%, or just 40% (a list works too: 0%, 100%). A keyframe sits between 0 and 100' });
1437
  continue;
1438
  }
1439
  const props = {};
 
1464
  props.transform = c.transform;
1465
  if (c.origin) props.transformOrigin = c.origin;
1466
  }
1467
+ for (const offset of offsets) node._parsed.frames.push({ offset, props: { ...props } });
1468
  }
1469
  }
1470
  node._parsed.frames.sort((a, b) => a.offset - b.offset);
 
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
  }
 
1715
  // nested pseudo path): no DOM handle means no nudge, animate or stack.
1716
  const decls = [];
1717
  for (const d of sub.decls) {
1718
+ if (d.prop && d.prop.startsWith('@') && d.prop !== '@animate') {
1719
  errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo rule' });
1720
  continue;
1721
  }
 
1821
 
1822
  // parse all motions that were referenced
1823
  for (const r of [...rules, ...shadowRules]) {
1824
+ // 0.14.0: gradient: writes background-image, so a second background-image (or a background: with an image in it) in the same
1825
+ // rule replaces it and the gradient is gone, with no sign of it. Say so, and say how to layer.
1826
+ {
1827
+ const g = r.decls.find((x) => x.fromGradient);
1828
+ if (g) {
1829
+ const other = r.decls.find((x) => x !== g && !x.fromGradient && (x.prop === 'background-image' || (x.prop === 'background' && /url\(|gradient\(/.test(String(x.value)))));
1830
+ if (other) warnings.push({
1831
+ file, line: Math.max(g.line || 0, other.line || 0) || g.line,
1832
+ message: `"${r.sel}" sets the background image twice (gradient: and ${other.prop}): the later one replaces the earlier, so one of them never shows`,
1833
+ hint: 'layer them in one gradient: sentence, joined with +, first on top: gradient: radial circle, #fff4 -> transparent tile 24 + linear 180, #e33 -> #a11 (a CSS radial-gradient(...) or url(...) works as a layer too)',
1834
+ });
1835
+ }
1836
+ }
1837
+ // 0.14.0: animation-delay, -duration, -iteration-count... only tune a CSS animation, and a rule that runs its motion through
1838
+ // animate: (the Web Animations API) has none, so they compiled, were written inline, and did nothing, with no word about it.
1839
+ {
1840
+ const hasCssAnim = r.decls.some((x) => x.prop === 'animation' || x.prop === 'animation-name');
1841
+ const usesAnimate = r.decls.some((x) => x.prop === '@animate' && x.value);
1842
+ for (const x of r.decls) {
1843
+ const m = /^animation-(delay|duration|iteration-count|direction|fill-mode|timing-function|play-state)$/.exec(x.prop);
1844
+ if (m && !hasCssAnim) {
1845
+ const word = { delay: 'delay', duration: 'a number after the name', 'iteration-count': 'infinite, or iterations 3', direction: 'alternate', 'fill-mode': 'both, forwards', 'timing-function': 'an easing name', 'play-state': '' }[m[1]];
1846
+ warnings.push({
1847
+ file, line: x.line,
1848
+ message: `${x.prop} does nothing here: this rule has no CSS animation to tune${usesAnimate ? ', it runs its motion through animate:' : ''}`,
1849
+ hint: usesAnimate || !hasCssAnim ? `put it in the animate: line, e.g. animate: ${(r.decls.find((y) => y.prop === '@animate' && y.value) || { value: { motion: 'name' } }).value.motion} 400 ${m[1] === 'delay' ? 'delay 200' : word || ''} (words: delay N, jitter N, iterations N, infinite, alternate, an easing, essential). Or write the whole animation: name 400ms ... shorthand` : '',
1850
+ });
1851
+ }
1852
+ }
1853
+ }
1854
  for (const d of r.decls) {
1855
  if (d.prop === '@animate' && d.value && d.value.motion) {
1856
  const m = parseMotion(d.value.motion);
 
1863
  d.value.broken = true;
1864
  } else {
1865
  d.value.frames = m.frames;
1866
+ d.value.essential = m.essential || !!d.value.essentialHere;
1867
  }
1868
  }
1869
  // v0.8.0 - the animation shorthand is CSS passthrough, so a motion
 
1872
  // from raw { } or another stylesheet, the warning says it can be ignored.
1873
  if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') {
1874
  const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w));
1875
+ const named = words.find(w => motions[w] || (baseMotions && baseMotions[w]));
1876
  if (words.length && !named) {
1877
  warnings.push({
1878
  file, line: d.line,
public/dssl/static.js CHANGED
@@ -21,7 +21,7 @@
21
  * ========================================================================= */
22
 
23
  import { buildTexture, composeTextureInto } from './texture.js';
24
- import { makeColorResolver } from './lint.js';
25
 
26
  const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
27
  const LAYER_NAMES = ['tokens', 'base', 'components', 'utilities', 'pin'];
@@ -52,6 +52,29 @@ function emitRule(r, { pin = false, pseudo = null, flat = true } = {}) {
52
  return { css: out, maps: maps.map((m) => ({ ...m, dl: m.dl + wrap })) };
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  /** a source map needs base64 VLQ numbers */
56
  const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
57
  function vlq(n) {
@@ -213,17 +236,7 @@ export function toCSS(docIn, opts = {}) {
213
 
214
  // ---- keyframes from motions (name kept, so `animation: name` works) ----
215
  const motionFrames = doc.motionFrames || {};
216
- for (const [name, m] of Object.entries(motionFrames)) {
217
- const frames = m.frames.map(f => {
218
- const off = f.offset === 0 ? '0%' : f.offset === 100 ? '100%' : `${f.offset}%`;
219
- const body = Object.entries(f.props).map(([p, v]) => ` ${kebab(p)}: ${v};`).join('\n');
220
- return `${off} {\n${body}\n}`;
221
- });
222
- if (frames.length) {
223
- chunks.push({ layer: null, css: `@keyframes ${name} {\n${frames.join('\n')}\n}` });
224
- report.keyframes++;
225
- }
226
- }
227
 
228
  // ---- regular rules ----
229
  const toColor = makeColorResolver(new Map(Object.entries(doc.tokens || {})));
@@ -247,8 +260,16 @@ export function toCSS(docIn, opts = {}) {
247
  }
248
 
249
  // ---- pseudo-element rules (the shadow-CSS lane, native in a build) ----
250
- for (const r of doc.shadowRules || []) {
251
- if (r.signals && r.signals.length) { report.skippedSignals++; continue; }
 
 
 
 
 
 
 
 
252
  const { keep, engineProps } = splitDecls(r.decls || [], kinds);
253
  report.engineDecls += engineProps;
254
  if (!keep.length) { if (engineProps) report.skippedEngine++; continue; }
@@ -282,6 +303,7 @@ export function toCSS(docIn, opts = {}) {
282
  report.raws++;
283
  }
284
 
 
285
  report.engineKinds = [...kinds].sort();
286
  let css = out.trimEnd() + '\n';
287
  let map = null;
 
21
  * ========================================================================= */
22
 
23
  import { buildTexture, composeTextureInto } from './texture.js';
24
+ import { makeColorResolver, alignWithoutBox } from './lint.js';
25
 
26
  const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
27
  const LAYER_NAMES = ['tokens', 'base', 'components', 'utilities', 'pin'];
 
52
  return { css: out, maps: maps.map((m) => ({ ...m, dl: m.dl + wrap })) };
53
  }
54
 
55
+ /** @keyframes for every motion, so `animation: name` in plain CSS works (the engine writes the same into its own stylesheet) */
56
+ export function keyframesCss(motionFrames) {
57
+ const out = [];
58
+ for (const [name, m] of Object.entries(motionFrames || {})) {
59
+ const frames = (m.frames || []).map((f) => {
60
+ const off = f.offset === 0 ? '0%' : f.offset === 100 ? '100%' : `${f.offset}%`;
61
+ const body = Object.entries(f.props).map(([p, v]) => ` ${kebab(p)}: ${v};`).join('\n');
62
+ return `${off} {\n${body}\n}`;
63
+ });
64
+ if (frames.length) out.push(`@keyframes ${name} {\n${frames.join('\n')}\n}`);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** an `animate:` spec as a CSS animation shorthand, for motions on ::before and ::after (WAAPI cannot reach a pseudo-element) */
70
+ export function animationShorthand(spec) {
71
+ if (!spec || !spec.motion || spec.broken) return null;
72
+ const parts = [spec.motion, `${spec.duration}ms`, spec.easing || 'ease', `${spec.delay || 0}ms`, spec.iterations === Infinity ? 'infinite' : String(spec.iterations || 1)];
73
+ if (spec.direction && spec.direction !== 'normal') parts.push(spec.direction);
74
+ parts.push(spec.fill || 'both');
75
+ return parts.join(' ');
76
+ }
77
+
78
  /** a source map needs base64 VLQ numbers */
79
  const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
80
  function vlq(n) {
 
236
 
237
  // ---- keyframes from motions (name kept, so `animation: name` works) ----
238
  const motionFrames = doc.motionFrames || {};
239
+ for (const css of keyframesCss(motionFrames)) { chunks.push({ layer: null, css }); report.keyframes++; }
 
 
 
 
 
 
 
 
 
 
240
 
241
  // ---- regular rules ----
242
  const toColor = makeColorResolver(new Map(Object.entries(doc.tokens || {})));
 
260
  }
261
 
262
  // ---- pseudo-element rules (the shadow-CSS lane, native in a build) ----
263
+ for (const r0 of doc.shadowRules || []) {
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; }
 
303
  report.raws++;
304
  }
305
 
306
+ report.needsBox = alignWithoutBox(allRules); // rules the engine would have given a display, and a build cannot
307
  report.engineKinds = [...kinds].sort();
308
  let css = out.trimEnd() + '\n';
309
  let map = null;
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.9.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
 
@@ -46,7 +46,7 @@ DSSL.toCSS(src) // v0.8.0: the exit lane -> { css, report }
46
  DSSL.signal('menuOpen', true) // the JS -> styles bridge
47
  DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
48
  DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
49
- DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
50
  ```
51
 
52
  ## 0.9.0: texture, aura, sliders, lint, import
@@ -63,6 +63,19 @@ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
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.13.0: shipping without the engine
67
  - `dssl build a.dssl b.dssl -o out.css [--reset] [--sourcemap] [--inline-tokens] [--native-specificity]`: several files in load order. Selectors are wrapped in :where() so source order wins like in the engine (no CSS specificity). --reset adds the small reset as the FIRST layer. --sourcemap writes out.css.map (rule and declaration level, sources embedded) so devtools show the .dssl file and line. --inline-tokens writes token values in place of var() where a token is defined once and never redefined (fastest; tokens changed at run time will not reach built rules).
68
  - Proof: scripts/test-static-parity.mjs compares every page of the site, built CSS with JavaScript off vs the engine, ~100 properties per element plus box and pseudo-elements, 5,000+ elements: identical. Engine-only features (animate, aura, tilt, bend, stretch, overlap, gradient-flow, stagger, signals, slider fill, texture shimmer) are not in a build.
@@ -98,4 +111,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
98
 
99
  ## Editor support
100
 
101
- 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.13.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.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
 
 
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
  - 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.
69
+ - Motions are shared across files like tokens (`dssl check`/`build` too). Keyframes accept `0%`, `40%`, `0%, 100%`, `from`, `to`. Every motion also exists as a real `@keyframes`, so `animation: bob 300ms` works. `animate:` works on `::before` and `::after`.
70
+ - `await DSSL.play(el, 'boing', { duration, hold })` returns a promise that settles when the motion ends (also when cancelled); `hold: true` keeps the last frame; an unknown name rejects with did-you-mean.
71
+ - `gradient:` layers with `+`, takes CSS gradient functions as layers, and `tile 24`, `offset 4 4`, `no-repeat` after a layer. `gradient:` plus `background-image:` in one rule is a warning.
72
+ - `text-stroke: 6 color.ink` (outside the letters, paint-order stroke fill), `inside` for centred, `none`. `paint-order: stroke fill` and `conic-gradient(from 0deg, ...)` are accepted. `shadow: pop` and `radius: pill` work for your own tokens.
73
+ - Your own inline styles win: a `style=""` attribute or a script's `el.style.left` beats a rule, including through `DSSL.refresh()`. `data-dssl-static` / `DSSL.freeze(el)` styles a subtree once and leaves it alone; `DSSL.refresh(el)` restyles it on demand.
74
+ - Aura: clipped to an `overflow: hidden` stage (add `unclipped` to spill), scales with an ancestor `transform: scale`, `DSSL.auraZ(20)` puts the canvas under a menu.
75
+ - `@signal screen in (menu, shop)`, `not in (...)`, `!=`.
76
+ - `DSSL.lint(root)` finds what only a live page shows: text colour against the composited background (cream on cream from two rules), and elements the engine had to turn into grids. `dssl check index.html` and `dssl build index.html` read the stylesheets a page links, in page order.
77
+ - The folded-border console message is gone. Docs now list every function on DSSL (a test keeps it that way).
78
+
79
  ## 0.13.0: shipping without the engine
80
  - `dssl build a.dssl b.dssl -o out.css [--reset] [--sourcemap] [--inline-tokens] [--native-specificity]`: several files in load order. Selectors are wrapped in :where() so source order wins like in the engine (no CSS specificity). --reset adds the small reset as the FIRST layer. --sourcemap writes out.css.map (rule and declaration level, sources embedded) so devtools show the .dssl file and line. --inline-tokens writes token values in place of var() where a token is defined once and never redefined (fastest; tokens changed at run time will not reach built rules).
81
  - Proof: scripts/test-static-parity.mjs compares every page of the site, built CSS with JavaScript off vs the engine, ~100 properties per element plus box and pseudo-elements, 5,000+ elements: identical. Engine-only features (animate, aura, tilt, bend, stretch, overlap, gradient-flow, stagger, signals, slider fill, texture shimmer) are not in a build.
 
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.
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.13.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.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");
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], ['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], ['built CSS with JavaScript off vs the engine, every page', 'test-static-parity.mjs', true],
21
  ];
22
 
23
  if (!skipBrowser && !findChrome()) {
scripts/test-v0130.mjs CHANGED
@@ -97,10 +97,11 @@ section('--inline-tokens');
97
 
98
  section('alignment without a box: the one thing a build cannot do for you');
99
  {
100
- const w = compile('.a { place-items: center }\n.b { display: grid; place-items: center }\n.c { row: center }\n.d:hover { justify-content: center }\n@media (width < 600) { .e { align-items: center } }', 't.dssl').warnings.map((x) => x.message);
101
- ok('a rule that aligns without saying display gets a warning', w.length === 1 && /"place-items" on "\.a"/.test(w[0]), JSON.stringify(w));
102
- ok('a rule with display, a flex helper, a state or a media query does not', !w.some((m) => /"\.(b|c|d|e)"/.test(m)));
103
- ok('the warning explains what differs', /dssl build does not/.test(compile('.a { place-items: center }', 't').warnings[0].hint));
 
104
  }
105
 
106
  section('source maps: devtools show the .dssl file and line');
 
97
 
98
  section('alignment without a box: the one thing a build cannot do for you');
99
  {
100
+ const src = '.a { place-items: center }\n.b { display: grid; place-items: center }\n.c { row: center }\n.d:hover { justify-content: center }\n@media (width < 600) { .e { align-items: center } }';
101
+ const nb = toCSS(src, { banner: false }).report.needsBox;
102
+ ok('the BUILD reports a rule that aligns without saying display (check no longer warns: it cannot see other rules)', nb.length === 1 && nb[0].sel === '.a' && nb[0].prop === 'place-items', JSON.stringify(nb));
103
+ ok('a rule with display, a flex helper, a state or a media query is not reported', !nb.some((x) => /^\.(b|c|d|e)$/.test(x.sel)));
104
+ ok('check stays quiet about it', compile(src, 't.dssl').warnings.length === 0);
105
  }
106
 
107
  section('source maps: devtools show the .dssl file and line');
scripts/test-v0140.mjs ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* test-v0140.mjs: the fixes and features from a review by someone building a game on DSSL (0.14.0).
3
+ * Every item they listed has an assertion here, run against real Chrome where the browser decides the answer. */
4
+ import { spawnSync } from 'node:child_process';
5
+ import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, readdirSync } 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 * as API from '../public/dssl/index.js';
10
+ import { withBrowser, ROOT } from './lib/browser-harness.mjs';
11
+ import { decodePng, px, dist } 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 eng = `DSSL.__debug.engines.get('global')`;
19
+ const errs = (src) => compile(src, 't.dssl').errors.map((e) => e.message);
20
+ const warns = (src) => compile(src, 't.dssl').warnings.map((w) => w.message);
21
+
22
+ section('1. reduced motion is never silent');
23
+ {
24
+ const MOTION = 'motion bob { from { translate: 0 0 } to { translate: 0 10px } }\nmotion boing { from { translate: 0 0 } to { translate: 0 10px } essential }';
25
+ await withBrowser(async ({ open, errors }) => {
26
+ const logs = []; const p = await open('/r'); p.on('console', (m) => logs.push(m.text()));
27
+ await ready(p); await p.waitForTimeout(400);
28
+ ok('with reduced motion on, a plain animate: does not run', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 0);
29
+ ok('a motion marked essential does', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'ess').length) === 1);
30
+ ok('so does one marked essential on the animate: line', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'line').length) === 1);
31
+ const told = await p.evaluate(() => 0); void told;
32
+ await p.evaluate(() => DSSL.refresh()); await p.waitForTimeout(100);
33
+ // the notice came when the engine skipped it, before the listener above was attached: reload and listen from the start
34
+ const q = await open('/r'); const l2 = []; q.on('console', (m) => l2.push(m.text())); await q.reload(); await ready(q); await q.waitForTimeout(300);
35
+ ok('and it SAYS so, once, in plain words, with the way out', l2.filter((t) => /reduced motion/.test(t)).length === 1 && /essential/.test(l2.find((t) => /reduced motion/.test(t)) || '') && /data-dssl-motion/.test(l2.find((t) => /reduced motion/.test(t)) || ''), JSON.stringify(l2));
36
+ await p.evaluate(() => DSSL.motion('full')); await p.waitForTimeout(300);
37
+ ok('DSSL.motion("full") runs every motion whatever the system asks for', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 1);
38
+ await p.evaluate(() => DSSL.motion('reduced')); await p.waitForTimeout(300);
39
+ ok('"reduced" holds the plain ones still again', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 0);
40
+ ok('no page errors', errors.length === 0, errors.join(' | '));
41
+ }, { pages: { '/r': page(`${MOTION}\n#plain { animate: bob 800 infinite alternate }\n#ess { animate: boing 800 infinite alternate }\n#line { animate: bob 800 infinite alternate essential }`, '<div id=plain></div><div id=ess></div><div id=line></div>') }, reducedMotion: 'reduce' });
42
+ await withBrowser(async ({ open }) => {
43
+ const p = await open('/f'); await ready(p); await p.waitForTimeout(300);
44
+ ok('<html data-dssl-motion="full"> in the markup overrides the system from the first frame', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'plain').length) === 1);
45
+ }, { pages: { '/f': page(`${MOTION}\n#plain { animate: bob 800 infinite alternate }`, '<div id=plain></div>').replace('<body', '<script>document.documentElement.setAttribute("data-dssl-motion","full")</script><body') }, reducedMotion: 'reduce' });
46
+ }
47
+
48
+ section('2. motion: words, registry, keyframes, play');
49
+ {
50
+ const spec = (src) => compile(src, 't').doc.rules.map((r) => r.decls.find((d) => d.prop === '@animate')).filter(Boolean).map((d) => d.value)[0];
51
+ const s = spec('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 delay 100 jitter 200 iterations 3 essential }');
52
+ ok('animate: reads delay, jitter, iterations and essential', s.delay === 100 && s.jitter === 200 && s.iterations === 3 && s.essential === true, JSON.stringify(s));
53
+ ok('a bare number after the duration is still the delay', spec('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 250 }').delay === 250);
54
+ ok('delay without a number is an error that shows the form', errs('motion w { from { opacity: 0 } to { opacity: 1 } }\n.x { animate: w 300 delay }').some((m) => /delay needs a number/.test(m)));
55
+ const fr = compile('motion w { 0%, 100% { opacity: 1 } 40% { opacity: 0 } }', 't').doc.motionFrames.w.frames.map((f) => f.offset);
56
+ ok('keyframes accept 0%, 40% and a list, next to from, to and at 40%', JSON.stringify(fr) === '[0,40,100]', JSON.stringify(fr));
57
+ ok('a keyframe past 100% is refused', errs('motion w { 120% { opacity: 1 } }\n.x { animate: w 1 }').some((m) => /bad keyframe selector/.test(m)));
58
+ const w = warns('motion w { from { opacity: 0 } to { opacity: 1 } }\n.y { animate: w 400; animation-delay: 200ms }');
59
+ ok('animation-delay on a rule that uses animate: gets a warning that names the replacement', w.length === 1 && /does nothing here/.test(w[0]), JSON.stringify(w));
60
+ ok('but not when the rule has a CSS animation to tune', warns('.y { animation: foo 400ms; animation-delay: 200ms }').filter((m) => /does nothing/.test(m)).length === 0);
61
+ await withBrowser(async ({ open, errors }) => {
62
+ const p = await open('/m'); await ready(p); await p.waitForTimeout(300);
63
+ ok('a motion defined in one stylesheet runs from another (shared registry)', await p.evaluate(() => document.getAnimations().filter((a) => a.effect.target.id === 'b').length) === 1);
64
+ const e = await p.evaluate(() => DSSL.errors().map((x) => x.message));
65
+ ok('with no unknown-motion error', !e.some((m) => /unknown motion/.test(m)), JSON.stringify(e));
66
+ ok('the pseudo-element animation really runs (a CSS animation, in the engine\'s stylesheet)', await p.evaluate(() => document.getAnimations().some((a) => a.effect.target.id === 'c' && a.effect.pseudoElement === '::before')));
67
+ ok('every motion is also a real @keyframes, so animation: bob 300ms works too', await p.evaluate(() => document.getAnimations().some((a) => a.effect.target.id === 'css' && a.animationName === 'bob')));
68
+ // play()
69
+ const t0 = Date.now();
70
+ const r = await p.evaluate(async () => { const el = document.getElementById('c'); const a = await DSSL.play(el, 'bob', { duration: 120 }); return { state: a.playState, id: a.effect.target.id }; });
71
+ ok('DSSL.play returns a promise that settles when the motion ends', r.state === 'finished' && r.id === 'c' && Date.now() - t0 >= 100, JSON.stringify(r));
72
+ const held = await p.evaluate(async () => { const el = document.getElementById('c'); await DSSL.play(el, 'bob', { duration: 80, hold: true }); return getComputedStyle(el).translate; });
73
+ ok('hold: true keeps the final frame', held === '0px 10px', held);
74
+ const unheld = await p.evaluate(async () => { const el = document.body.appendChild(document.createElement('div')); await DSSL.play(el, 'bob', { duration: 80 }); return getComputedStyle(el).translate; });
75
+ ok('without it the element snaps back', unheld === 'none' || unheld === '0px', unheld);
76
+ const bad = await p.evaluate(async () => { try { await DSSL.play(document.body, 'bobb'); return 'resolved'; } catch (e) { return e.message; } });
77
+ ok('an unknown motion rejects, with did-you-mean', /unknown motion "bobb"/.test(bad) && /did you mean "bob"/.test(bad), bad);
78
+ const cancelled = await p.evaluate(async () => { const el = document.getElementById('c'); const pr = DSSL.play(el, 'bob', { duration: 5000 }); el.getAnimations().forEach((a) => a.cancel()); const a = await pr; return a.playState; });
79
+ ok('a cancelled animation settles the promise too, so awaiting never hangs', cancelled === 'idle');
80
+ ok('no page errors', errors.length === 0, errors.join(' | '));
81
+ }, { pages: { '/m': `<!doctype html><meta charset=utf-8><body style="margin:0"><style type="text/dssl" data-dssl-name="a.dssl">motion bob { from { translate: 0 0 } to { translate: 0 10px } }</style><style type="text/dssl" data-dssl-name="b.dssl">#b { animate: bob 800 infinite alternate }\n#c::before { content: "x"; animate: bob 800 infinite alternate }\n#css { animation: bob 800ms infinite alternate }</style><div id=b></div><div id=c></div><div id=css></div><script type="module" src="/dssl/boot.js"></script>` }, reducedMotion: 'no-preference' });
82
+ await withBrowser(async ({ open }) => {
83
+ const p = await open('/j'); await ready(p); await p.waitForTimeout(300);
84
+ const delays = await p.evaluate(() => [...document.getAnimations()].map((a) => a.effect.getTiming().delay));
85
+ ok('jitter gives every element its own delay (not one shared value)', new Set(delays).size > 3 && delays.every((d) => d >= 0 && d <= 600), JSON.stringify(delays));
86
+ await p.reload(); await ready(p); await p.waitForTimeout(300);
87
+ const again = await p.evaluate(() => [...document.getAnimations()].map((a) => a.effect.getTiming().delay));
88
+ ok('and the same page gets the same delays every time', JSON.stringify(delays) === JSON.stringify(again));
89
+ }, { pages: { '/j': page('motion bob { from { translate: 0 0 } to { translate: 0 10px } }\n.coin { animate: bob 800 infinite alternate jitter 600 }', Array.from({ length: 8 }, () => '<i class="coin"></i>').join('')) } });
90
+ }
91
+
92
+ section('3. values and gradients');
93
+ {
94
+ const decls = (src) => { const r = compile(src, 't'); return { e: r.errors.map((x) => x.message), d: r.doc.rules.flatMap((x) => x.decls.filter((y) => !y.prop.startsWith('--')).map((y) => `${y.prop}: ${y.value}`)) }; };
95
+ let r = decls('tokens { shadow { pop: 0 4 0 #000 } radius { pill: 99 } }\n.a { shadow: pop; radius: pill }');
96
+ ok('shadow: pop and radius: pill work for your own tokens', r.e.length === 0 && r.d.includes('box-shadow: var(--dssl-shadow-pop)') && r.d.includes('border-radius: var(--dssl-radius-pill)'), JSON.stringify(r));
97
+ ok('and the built-in names still do', decls('.a { shadow: sm; radius: lg }').d.join().includes('--dssl-shadow-sm'));
98
+ ok('a name that is not a token is still an error, not a CSS keyword guess', decls('.a { shadow: nothing }').e.length === 1);
99
+ r = decls('.a { paint-order: stroke fill; background: conic-gradient(from 0deg, red, blue) }');
100
+ ok('paint-order: stroke fill and conic-gradient(from 0deg, ...) are accepted', r.e.length === 0, JSON.stringify(r.e));
101
+ r = decls('.a { text-stroke: 6 color.ink }');
102
+ ok('text-stroke writes the outline and paint-order', r.e.length === 0 && r.d.some((x) => /^-webkit-text-stroke: 6px /.test(x)) && r.d.includes('paint-order: stroke fill'), JSON.stringify(r));
103
+ ok('text-stroke inside keeps a centred stroke; none clears it', !decls('.a { text-stroke: 2 #fff inside }').d.includes('paint-order: stroke fill') && decls('.a { text-stroke: none }').d.includes('-webkit-text-stroke: 0'));
104
+ ok('text-stroke needs a width', decls('.a { text-stroke: color.ink }').e.length === 1);
105
+ r = decls('.t { gradient: linear 180, #e33 -> #a11 + radial-gradient(circle, #fff4 20%, transparent 21%) tile 24 offset 4 4 no-repeat }');
106
+ ok('gradient: layers a sentence and a plain CSS gradient, with tile, offset and no-repeat for the second', r.e.length === 0 && r.d[0].startsWith('background-image: linear-gradient(180deg, #e33, #a11), radial-gradient(circle') && r.d.includes('background-size: auto, 24px 24px') && r.d.includes('background-position: 0 0, 4px 4px') && r.d.includes('background-repeat: repeat, no-repeat'), JSON.stringify(r));
107
+ ok('a layer with no tile leaves size alone', !decls('.t { gradient: linear 90, #f00 -> #00f }').d.some((x) => /background-size/.test(x)));
108
+ const w = warns('.u { gradient: linear 90, #f00 -> #00f; background-image: radial-gradient(red, blue) }');
109
+ ok('gradient: and background-image: in one rule is a warning that says how to layer', w.length === 1 && /sets the background image twice/.test(w[0]), JSON.stringify(w));
110
+ ok('gradient: then a background shorthand with an image warns too', warns('.u { gradient: linear 90, #f00 -> #00f; background: url(x.png) }').some((m) => /twice/.test(m)));
111
+ ok('a rule with just one of them does not', warns('.u { gradient: linear 90, #f00 -> #00f; background-color: #fff }').length === 0);
112
+ }
113
+
114
+ section('4. your inline styles win');
115
+ await withBrowser(async ({ open, errors }) => {
116
+ const p = await open('/i'); await ready(p); await p.waitForTimeout(300);
117
+ const get = (sel, prop) => p.evaluate(([s, pr]) => getComputedStyle(document.querySelector(s))[pr], [sel, prop]);
118
+ ok('a style="" attribute beats a rule that sets the same property', await get('#attr', 'left') === '33px');
119
+ ok('and the rule still sets what the attribute did not say', await get('#attr', 'top') === '5px');
120
+ await p.evaluate(() => { document.querySelector('#js').style.left = '71px'; DSSL.refresh(); });
121
+ await p.waitForTimeout(150);
122
+ ok('a script that moves an element after the engine styled it keeps winning, even through a refresh', await get('#js', 'left') === '71px');
123
+ ok('a margin-top written inline survives a rule\'s margin shorthand', await get('#short', 'marginTop') === '14px' && await get('#short', 'marginLeft') === '0px');
124
+ await p.evaluate(() => { document.querySelector('#js').style.left = ''; DSSL.refresh(); });
125
+ await p.waitForTimeout(150);
126
+ ok('clearing it hands the property back to the rule', await get('#js', 'left') === '0px');
127
+ const logs = await p.evaluate(() => window.__logs.join('|'));
128
+ ok('no "folded border-color into border" noise', !/folded/.test(logs), logs);
129
+ ok('no page errors', errors.length === 0, errors.join(' | '));
130
+ }, { pages: { '/i': page('div { position: absolute }\n#attr { left: 0; top: 5px }\n#js { left: 0; top: 40px }\n#short { margin: 0 }\n#brd { border: 1 solid #fff; border-color: color.ink }\n#brd.on { border-color: red }', '<div id=attr style="left:33px"></div><div id=js></div><div id=short style="margin-top:14px;position:static"></div><div id=brd></div>', '<script>window.__logs=[];for (const k of ["log","info","warn"]) { const o=console[k]; console[k]=(...a)=>{window.__logs.push(a.join(" ")); o.apply(console,a)} }; addEventListener("load",()=>{ document.getElementById("brd").classList.toggle("on") })</script>') } });
131
+
132
+ section('5. static subtrees');
133
+ await withBrowser(async ({ open, errors }) => {
134
+ const p = await open('/s'); await ready(p);
135
+ await p.evaluate(() => { const e = DSSL.__debug.engines.get('global'); window.__n = 0; const orig = e.touched.add.bind(e.touched); e.touched.add = (el) => { window.__n++; return orig(el); }; });
136
+ await p.evaluate(() => { document.getElementById('live').classList.add('hot'); });
137
+ await p.waitForTimeout(300);
138
+ const liveN = await p.evaluate(() => window.__n);
139
+ await p.evaluate(() => { window.__n = 0; });
140
+ await p.evaluate(() => { DSSL.freeze(document.getElementById('world')); document.getElementById('world').classList.add('hot'); });
141
+ await p.waitForTimeout(300);
142
+ const staticN = await p.evaluate(() => window.__n);
143
+ ok('toggling a class on a normal subtree restyles it (a lot of elements)', liveN > 200, String(liveN));
144
+ ok('on a frozen subtree it restyles almost nothing (the root and no more)', staticN < 20, String(staticN));
145
+ ok('and the frozen children keep the look they had', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(255, 0, 0)');
146
+ await p.evaluate(() => DSSL.refresh(document.getElementById('world'))); await p.waitForTimeout(150);
147
+ ok('DSSL.refresh(el) restyles it on demand', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(0, 128, 0)');
148
+ await p.evaluate(() => { DSSL.unfreeze(document.getElementById('world')); document.getElementById('world').classList.remove('hot'); }); await p.waitForTimeout(250);
149
+ ok('unfreeze(el) makes it live again', await p.evaluate(() => getComputedStyle(document.querySelector('#world i')).color) === 'rgb(255, 0, 0)');
150
+ ok('DSSL.freeze() with no argument still parks the whole page', await p.evaluate(() => { const r = DSSL.freeze(); DSSL.unfreeze(); return r !== undefined; }));
151
+ ok('no page errors', errors.length === 0, errors.join(' | '));
152
+ }, { pages: { '/s': page('i { color: red }\n.hot i { color: green }', `<div id=world>${'<i>x</i>'.repeat(250)}</div><div id=live>${'<i>x</i>'.repeat(250)}</div>`) } });
153
+ await withBrowser(async ({ open }) => {
154
+ const p = await open('/s2'); await ready(p);
155
+ ok('data-dssl-static in the markup works from the first paint', await p.evaluate(() => { const e = DSSL.__debug.engines.get('global'); return e.staticSeen === true; }));
156
+ }, { pages: { '/s2': page('i { color: red }', '<div data-dssl-static><i>x</i></div>') } });
157
+
158
+ section('6. aura: clipped to its stage, scaled with it, and placed in the stack');
159
+ {
160
+ const AURA = '.stage { position: absolute; left: 100px; top: 100px; width: 200px; height: 200px; overflow: hidden; background: #000 }\n.orb { position: absolute; left: 70px; top: 70px; width: 60px; height: 60px; background: #111; aura-glow: #ff0000 60 essential intensity 1 }';
161
+ const redAt = (png, x, y) => px(png, x, y)[0] > 40 && px(png, x, y)[0] > px(png, x, y)[2] * 1.5;
162
+ await withBrowser(async ({ open }) => {
163
+ const p = await open('/a'); await ready(p); await p.waitForTimeout(900);
164
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 420, height: 420 } }));
165
+ ok('the glow lights the area inside the stage', redAt(png, 100 + 70 - 10, 100 + 100), JSON.stringify(px(png, 160, 200)));
166
+ ok('and is cut at the stage edge (overflow: hidden): nothing outside it', !redAt(png, 100 - 8, 200) && !redAt(png, 300 + 8, 200) && !redAt(png, 200, 100 - 8), JSON.stringify([px(png, 92, 200), px(png, 308, 200), px(png, 200, 92)]));
167
+ }, { pages: { '/a': page(AURA, '<div class=stage><div class=orb></div></div>') }, viewport: { width: 420, height: 420 } });
168
+ await withBrowser(async ({ open }) => {
169
+ const p = await open('/a'); await ready(p); await p.waitForTimeout(900);
170
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 420, height: 420 } }));
171
+ ok('with `unclipped` the light spills over the edge, as asked', redAt(png, 100 - 6, 100 + 100) || redAt(png, 100 - 6, 100 + 90) || redAt(png, 100 - 4, 100 + 100), JSON.stringify([px(png, 94, 200), px(png, 96, 200)]));
172
+ }, { pages: { '/a': page(AURA.replace('essential intensity 1', 'essential unclipped'), '<div class=stage><div class=orb style="left:0;top:70px"></div></div>') }, viewport: { width: 420, height: 420 } });
173
+ // scale: the same glow, on a stage drawn at twice the size, reaches about twice as far
174
+ const reach = async (scale) => {
175
+ let r = 0;
176
+ await withBrowser(async ({ open }) => {
177
+ const p = await open('/a'); await ready(p); await p.waitForTimeout(900);
178
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 800, height: 800 } }));
179
+ const cx = 400, cy = 400;
180
+ for (let d = 0; d < 380; d++) if (px(png, cx + d, cy)[0] > 12 && px(png, cx + d, cy)[0] > px(png, cx + d, cy)[2] * 1.3) r = d;
181
+ }, { pages: { '/a': page(`.wrap { position: absolute; left: 400px; top: 400px; width: 0; height: 0; transform: scale(${scale}); transform-origin: 0 0 }\n.orb { position: absolute; left: -20px; top: -20px; width: 40px; height: 40px; background: #111; aura-glow: #ff0000 30 essential }`, '<div class=wrap><div class=orb></div></div>') }, viewport: { width: 800, height: 800 } });
182
+ return r;
183
+ };
184
+ const r1 = await reach(1), r2 = await reach(2);
185
+ ok('inside a transform: scale(2) stage the glow reaches about twice as far', r1 > 10 && r2 > r1 * 1.6 && r2 < r1 * 2.6, `scale 1 reaches ${r1}px, scale 2 reaches ${r2}px`);
186
+ await withBrowser(async ({ open }) => {
187
+ const p = await open('/z'); await ready(p); await p.waitForTimeout(500);
188
+ ok('the canvas is above everything by default', await p.evaluate(() => getComputedStyle(document.querySelector('canvas[data-dssl-aura]')).zIndex) === '2147483000');
189
+ await p.evaluate(() => DSSL.auraZ(20));
190
+ ok('DSSL.auraZ(20) lowers it, so a menu with z-index above 20 covers the light', await p.evaluate(() => getComputedStyle(document.querySelector('canvas[data-dssl-aura]')).zIndex === '20' && document.documentElement.getAttribute('data-dssl-aura-z') === '20'));
191
+ await p.evaluate(() => DSSL.auraZ(null));
192
+ ok('and null puts it back', await p.evaluate(() => getComputedStyle(document.querySelector('canvas[data-dssl-aura]')).zIndex) === '2147483000');
193
+ }, { pages: { '/z': page('.o { aura-glow: #f00 20 essential }', '<div class=o style="width:20px;height:20px"></div>') } });
194
+ ok('`unclipped` is a known aura word, with a description', compile('.a { aura-glow: #f80 20 unclipped; aura-particle: #fd0 5 up unclipped }', 't').errors.length === 0);
195
+ }
196
+
197
+ section('7. signals: in, not in, !=');
198
+ await withBrowser(async ({ open, errors }) => {
199
+ const p = await open('/g'); await ready(p);
200
+ const color = (id) => p.evaluate((i) => getComputedStyle(document.getElementById(i)).color, id);
201
+ const pre = (id) => p.evaluate((i) => getComputedStyle(document.getElementById(i), '::before').content, id);
202
+ await p.evaluate(() => DSSL.signal('screen', 'menu')); await p.waitForTimeout(150);
203
+ ok('in (...) matches a listed value', await color('a') === 'rgb(255, 0, 0)');
204
+ ok('not in (...) is false for a listed value', await color('b') === 'rgb(0, 0, 0)');
205
+ await p.evaluate(() => DSSL.signal('screen', 'playing')); await p.waitForTimeout(150);
206
+ ok('in (...) stops matching for another value', await color('a') === 'rgb(0, 0, 0)');
207
+ ok('not in (...) matches it', await color('b') === 'rgb(0, 0, 255)');
208
+ await p.evaluate(() => DSSL.signal('screen', 'shop')); await p.waitForTimeout(150);
209
+ ok('the second listed value matches too', await color('a') === 'rgb(255, 0, 0)');
210
+ ok('!= works on a pseudo-element rule (the shadow stylesheet gets the right selector)', await pre('c') === '"neq"');
211
+ await p.evaluate(() => DSSL.signal('screen', 'menu')); await p.waitForTimeout(150);
212
+ ok('and stops when equal', (await pre('c')) === 'none' || (await pre('c')) === 'normal');
213
+ ok('an empty list is an error', errs('@signal screen in () { .a { color: red } }').length === 1);
214
+ ok('no page errors', errors.length === 0, errors.join(' | '));
215
+ }, { pages: { '/g': page('div { color: #000 }\n@signal screen in (menu, shop) { #a { color: #f00 } }\n@signal screen not in (menu, shop) { #b { color: #00f } }\n@signal screen != menu { #c::before { content: "neq" } }', '<div id=a>a</div><div id=b>b</div><div id=c>c</div>') } });
216
+
217
+ section('8. DSSL.lint(): what only a live page shows');
218
+ await withBrowser(async ({ open, errors }) => {
219
+ const p = await open('/l'); await ready(p); await p.waitForTimeout(300);
220
+ const res = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).map((f) => ({ kind: f.kind, sel: f.selector, ratio: f.ratio })));
221
+ const contrast = res.filter((f) => f.kind === 'contrast');
222
+ ok('cream text on a cream card (two rules, no warning from check) is found', contrast.some((f) => /\.card/.test(f.sel) || /\bp\b/.test(f.sel)) && contrast.every((f) => f.ratio < 4.5), JSON.stringify(res));
223
+ ok('text that is readable is not reported', !contrast.some((f) => /\.fine/.test(f.sel)), JSON.stringify(contrast));
224
+ ok('translucent backgrounds are composited: dark text on a 10% white wash over a dark page fails, light passes', contrast.some((f) => /\.wash-bad/.test(f.sel)) && !contrast.some((f) => /\.wash-ok/.test(f.sel)), JSON.stringify(contrast));
225
+ ok('text over an image or gradient is not guessed at', !contrast.some((f) => /\.pic/.test(f.sel)));
226
+ ok('large text gets the lower limit (3:1)', !contrast.some((f) => /\.big/.test(f.sel)), JSON.stringify(contrast));
227
+ ok('an element the engine made into a grid is reported', res.some((f) => f.kind === 'promoted' && /\.promo/.test(f.sel)), JSON.stringify(res));
228
+ ok('a flex box that says display is not', !res.some((f) => f.kind === 'promoted' && /\.flexy/.test(f.sel)));
229
+ const quiet = await p.evaluate(async () => { const logs = []; const o = console.groupCollapsed; console.groupCollapsed = (...a) => logs.push(a.join(' ')); await DSSL.lint(document.body, { log: false }); console.groupCollapsed = o; return logs.length; });
230
+ ok('{ log: false } stays quiet', quiet === 0);
231
+ ok('no page errors', errors.length === 0, errors.join(' | '));
232
+ }, { pages: { '/l': page('body { background: #14102a; color: #eee }\n.card { background: #f5efe0; padding: 10 }\n.fine { color: #222 }\n.wash-bad { background: #ffffff1a; color: #333 }\n.wash-ok { background: #ffffff1a; color: #fff }\n.pic { background-image: linear-gradient(red, blue); color: #801 }\n.big { color: #888; font-size: 30; background: #fff }\n.promo { place-items: center }\n.flexy { display: flex; place-items: center }', '<div class=card style="color:#f0e6d0"><p>cream on cream</p></div><div class=card><p class=fine>fine</p></div><div class=wash-bad>bad</div><div class=wash-ok>ok</div><div class=pic>pic</div><div class=big>BIG</div><div class=promo><span>p</span></div><div class=flexy>f</div>') } });
233
+
234
+ section('9. dssl check and build read the page');
235
+ {
236
+ const dir = mkdtempSync(path.join(tmpdir(), 'dssl-page-'));
237
+ mkdirSync(path.join(dir, 'css'));
238
+ writeFileSync(path.join(dir, 'css/a.dssl'), 'tokens { color { c: #123456 } }\nmotion pop { from { opacity: 0 } to { opacity: 1 } }\n');
239
+ writeFileSync(path.join(dir, 'css/b.dssl'), '.x { color: color.c; animate: pop 300 }\n');
240
+ writeFileSync(path.join(dir, 'index.html'), '<!doctype html><link rel="stylesheet/dssl" href="css/a.dssl"><link rel="stylesheet/dssl" href="/css/b.dssl"><style type="text/dssl">.y { opacity: .5 }</style><link rel="stylesheet" href="x.css">');
241
+ const run = (...a) => spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), ...a], { encoding: 'utf8' });
242
+ let r = run('check', path.join(dir, 'index.html'));
243
+ ok('check index.html finds three stylesheets in page order and they pass (a token and a motion from the first file, used in the second)', r.status === 0, r.stderr + r.stdout);
244
+ writeFileSync(path.join(dir, 'index.html'), '<!doctype html><link rel="stylesheet/dssl" href="css/b.dssl"><link rel="stylesheet/dssl" href="css/a.dssl">');
245
+ r = run('check', path.join(dir, 'index.html'));
246
+ ok('in the WRONG order it fails, as the browser would (the page order is what counts)', r.status === 1 && /unknown/.test(r.stderr), r.stderr);
247
+ writeFileSync(path.join(dir, 'index.html'), '<!doctype html><link rel="stylesheet/dssl" href="css/a.dssl"><link rel="stylesheet/dssl" href="css/b.dssl"><style type="text/dssl">.y { opacity: .5 }</style>');
248
+ r = run('build', path.join(dir, 'index.html'), '-o', path.join(dir, 'out.css'));
249
+ ok('build index.html compiles them together', r.status === 0 && /--dssl-color-c: #123456/.test(readFileSync(path.join(dir, 'out.css'), 'utf8')) && /@keyframes pop/.test(readFileSync(path.join(dir, 'out.css'), 'utf8')), r.stderr);
250
+ writeFileSync(path.join(dir, 'empty.html'), '<!doctype html><p>none</p>');
251
+ r = run('check', path.join(dir, 'empty.html'));
252
+ ok('a page with no DSSL says so (exit 2)', r.status === 2 && /no <link rel="stylesheet\/dssl">/.test(r.stderr), r.stderr);
253
+ writeFileSync(path.join(dir, 'missing.html'), '<link rel="stylesheet/dssl" href="nope.dssl">');
254
+ r = run('check', path.join(dir, 'missing.html'));
255
+ ok('a link to a file that is not there is reported with where it looked', r.status === 2 && /not on disk/.test(r.stderr), r.stderr);
256
+ }
257
+
258
+ section('10. the docs match the code');
259
+ {
260
+ const read = (f) => readFileSync(path.join(ROOT, f), 'utf8');
261
+ const V = API.VERSION;
262
+ const guide = read('GUIDE.md'), readme = read('README.md'), llms = read('llms.txt'), usage = read('engine-package/USAGE.md');
263
+ ok('llms.txt names the engine version', new RegExp(`Version: ${V.replace(/\./g, '\\.')} \\(engine\\)`).test(llms), llms.match(/Version: [\d.]+/)?.[0]);
264
+ ok('the guide\'s API block shows the real version, and calls it a string', guide.includes(`DSSL.version // "${V}"`), guide.match(/DSSL\.version.*/)?.[0]);
265
+ ok('the README\'s API block does too', readme.includes(`DSSL.version // "${V}"`));
266
+ ok('README sections say the current version, not an old one', readme.includes(`What works today (v${V})`) && readme.includes(`v${V}, experimental`));
267
+ ok('llms.txt and public/llms.txt are the same file', llms === read('public/llms.txt'));
268
+ const keys = Object.keys(API.DSSL).filter((k) => !k.startsWith('__'));
269
+ for (const [label, text] of [['the guide', guide], ['the README', readme], ['USAGE.md', usage], ['llms.txt', llms]]) {
270
+ const missing = keys.filter((k) => !new RegExp(`DSSL\\.${k}\\b|\\b${k}\\(`).test(text));
271
+ ok(`${label} documents every function on DSSL (${keys.length})`, missing.length === 0, 'missing: ' + missing.join(', '));
272
+ }
273
+ ok('DSSL.errors is documented as a function and DSSL.version as a string', /DSSL\.errors\(\)/.test(usage) && /DSSL\.version`? .*string|a string/.test(usage));
274
+ ok('the guide does not call version() a function', !/DSSL\.version\(\)/.test(guide));
275
+ const files = readdirSync(path.join(ROOT, 'public/dssl')).filter((f) => f.endsWith('.js'));
276
+ const table = guide.split('\n').find((l) => l.startsWith('| `public/dssl/`')) || '';
277
+ ok(`the guide's engine file table names all ${files.length} modules, so it cannot say "six" again`, files.every((f) => table.includes('`' + f + '`')), files.filter((f) => !table.includes('`' + f + '`')).join(', '));
278
+ ok('the docs site does not say the engine is two files', !/engine is two static files/.test(read('src/components/dssl/views/DocsView.tsx')));
279
+ }
280
+
281
+ console.log(`\n${pass} passed, ${fail} failed`);
282
+ process.exit(fail ? 1 : 0);
scripts/test-v050.mjs CHANGED
@@ -257,9 +257,10 @@ console.log(`${pass} passed, ${fail} failed`);
257
 
258
  // 14. engine-native props inside pseudo blocks fail loud (no junk shadow CSS)
259
  {
260
- const a = compile('.card::before { animate: pop 400 }');
261
- check('14 nested animate-in-pseudo is LOUD', a.errors.length === 1 && /engine-native/.test(a.errors[0].message), JSON.stringify(a.errors));
262
- check('14 nothing leaked to shadow CSS', a.doc.shadowRules.length === 0 && a.doc.rules.length === 0);
 
263
 
264
  const b = compile('.card::before { overlap: deny }');
265
  check('14 top-level overlap-in-pseudo is LOUD', b.errors.length === 1 && /engine-native/.test(b.errors[0].message), JSON.stringify(b.errors));
 
257
 
258
  // 14. engine-native props inside pseudo blocks fail loud (no junk shadow CSS)
259
  {
260
+ // 0.14.0: animate: on a pseudo-element is allowed now (it becomes a CSS animation); what is still engine-only fails loud
261
+ const a = compile('motion pop { from { opacity: 0 } to { opacity: 1 } }\n.card::before { content: "x"; animate: pop 400 }');
262
+ check('14 animate on a pseudo-element is allowed since 0.14 and becomes a CSS animation', a.errors.length === 0 && a.doc.shadowRules.length === 1 && a.doc.shadowRules[0].decls.some((d) => d.prop === '@animate'), JSON.stringify(a.errors));
263
+ check('14 an unknown motion there is still LOUD', compile('.card::before { content: "x"; animate: pop 400 }').errors.length === 1);
264
 
265
  const b = compile('.card::before { overlap: deny }');
266
  check('14 top-level overlap-in-pseudo is LOUD', b.errors.length === 1 && /engine-native/.test(b.errors[0].message), JSON.stringify(b.errors));
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-3])\./.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-4])\./.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,19 @@ const ENTRIES: {
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  {
15
  version: "v0.13.0",
16
  date: "growth phase \u00b7 the compiled page is the page",
 
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
14
+ {
15
+ version: "v0.14.0",
16
+ date: "growth phase \u00b7 made for games",
17
+ title: "A review from an agent that built a game, and all twelve problems fixed.",
18
+ why: "someone built a real game on DSSL and wrote down what bit them. Most of it was fair: motion that vanished without a word, two rules fighting over one background, settings that compiled and did nothing, docs that did not match the code.",
19
+ kind: "add",
20
+ body:
21
+ "Reduced motion no longer kills animate: in silence. The console says so once and how to get out: write essential in the motion, or call DSSL.motion('full'). animate: reads delay, jitter (a different random delay per element, the same on every load), iterations and essential. Motions are shared across files like tokens, keyframes take 0% and 40%, every motion is also a real @keyframes, and animate: works on ::before and ::after. DSSL.play(el, 'boing', { hold: true }) returns a promise. Your own inline styles now win over rules, a data-dssl-static subtree is styled once and left alone, and an aura is clipped to its stage, follows its scale, and can sit under a menu with DSSL.auraZ(). Gradients layer with + and take tile, offset and no-repeat, text-stroke is a real word, shadow: pop and radius: pill work for your own tokens, @signal takes in (a, b), not in and !=. DSSL.lint(root) checks the text colour against the real composited background, which found cream-on-cream that no single rule showed. dssl check and dssl build take an index.html and read the stylesheets it links. The docs were out of date in six places; a test now compares them with the real API. Not verified: Firefox and Safari.",
22
+ code: `motion boing { 0% { scale: 1 } 40% { scale: 1.3 } 100% { scale: 1 } }
23
+ .coin { animate: boing 600 infinite jitter 400 essential }
24
+ await DSSL.play(hero, 'boing', { hold: true })
25
+ @signal screen in (menu, shop) { .hud { opacity: 0 } }`,
26
+ },
27
  {
28
  version: "v0.13.0",
29
  date: "growth phase \u00b7 the compiled page is the page",
src/components/dssl/views/DocsView.tsx CHANGED
@@ -73,7 +73,7 @@ export default function DocsView() {
73
  <section className="doc-block" id="start">
74
  <h2 className="section-title">Quickstart: three steps</h2>
75
  <p className="doc-lead">
76
- No bundler, no build step, no dependencies. The engine is two static files;
77
  your styles are plain <code>.dssl</code> text the browser fetches and applies
78
  itself.
79
  </p>
 
73
  <section className="doc-block" id="start">
74
  <h2 className="section-title">Quickstart: three steps</h2>
75
  <p className="doc-lead">
76
+ No bundler, no build step, no dependencies. The engine is a folder of plain JavaScript files, loaded by one script tag;
77
  your styles are plain <code>.dssl</code> text the browser fetches and applies
78
  itself.
79
  </p>
src/lib/vocab.json CHANGED
@@ -342,6 +342,10 @@
342
  {
343
  "key": "essential",
344
  "doc": "keep animating even when the visitor prefers reduced motion"
 
 
 
 
345
  }
346
  ],
347
  "particleKeys": [
@@ -420,6 +424,10 @@
420
  {
421
  "key": "essential",
422
  "doc": "keep animating even when the visitor prefers reduced motion"
 
 
 
 
423
  }
424
  ],
425
  "sliderClauses": [
 
342
  {
343
  "key": "essential",
344
  "doc": "keep animating even when the visitor prefers reduced motion"
345
+ },
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": [
 
424
  {
425
  "key": "essential",
426
  "doc": "keep animating even when the visitor prefers reduced motion"
427
+ },
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": [