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