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