Commit ·
0c4637f
1
Parent(s): 52ca90d
v0.10.0: glass, tilt, named-colour alpha fix; playground with HTML tab, examples, warnings and CSS view; live effects on the home page
Browse files- GUIDE.md +32 -1
- PARITY.md +8 -0
- README.md +14 -1
- dssl-vscode/CHANGELOG.md +6 -0
- dssl-vscode/lib/analysis.js +7 -0
- dssl-vscode/lib/engine/color.js +1 -1
- dssl-vscode/lib/engine/effects.js +99 -0
- dssl-vscode/lib/engine/resolver.js +23 -0
- dssl-vscode/lib/props-docs.js +2 -0
- dssl-vscode/package.json +1 -1
- llms.txt +7 -1
- package.json +1 -1
- public/dssl/color.js +1 -1
- public/dssl/effects.js +99 -0
- public/dssl/engine.js +70 -0
- public/dssl/index.js +1 -1
- public/dssl/resolver.js +23 -0
- public/llms.txt +7 -1
- public/styles/docs.dssl +33 -0
- public/styles/home.dssl +132 -0
- public/styles/playground.dssl +79 -0
- scripts/gen-site-vocab.mjs +3 -0
- scripts/sync-extension-engine.mjs +9 -1
- scripts/test-all.mjs +1 -1
- scripts/test-v0100.mjs +179 -0
- scripts/test-v090.mjs +1 -1
- src/components/dssl/views/ChangelogView.tsx +15 -0
- src/components/dssl/views/CheatSheetView.tsx +6 -2
- src/components/dssl/views/DocsView.tsx +49 -0
- src/components/dssl/views/HomeView.tsx +48 -0
- src/components/dssl/views/PlaygroundView.tsx +153 -293
- src/lib/playground-presets.ts +406 -0
- src/lib/sanitize.ts +47 -0
- src/lib/share.ts +16 -0
- src/lib/vocab.json +44 -0
- src/types/dssl.d.ts +1 -1
GUIDE.md
CHANGED
|
@@ -537,6 +537,36 @@ as 0 to 1) up to date on the input. Typing, dragging and arrow keys all update i
|
|
| 537 |
The WebKit and Blink rules are tested in Chrome. The Firefox rules are written alongside them but
|
| 538 |
have not been run in Firefox yet.
|
| 539 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
### Warnings that help (lint)
|
| 541 |
|
| 542 |
Some things compile, run, and are still wrong for the people using the page. DSSL warns about three.
|
|
@@ -592,7 +622,7 @@ Three ways to get it:
|
|
| 592 |
|
| 593 |
1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
|
| 594 |
https://open-vsx.org/extension/ehs-studio/dssl-vscode
|
| 595 |
-
2. VS Code: download `dssl-vscode/dssl-vscode-0.
|
| 596 |
Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
|
| 597 |
3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
|
| 598 |
|
|
@@ -659,6 +689,7 @@ They look for one in the usual places; set `DSSL_CHROME` to point at another.
|
|
| 659 |
```sh
|
| 660 |
node scripts/test-v090.mjs # 0.9.0 pure logic: colour, texture, aura, slider, lint, import, share links
|
| 661 |
node scripts/test-browser-v090.mjs # 0.9.0 in Chrome: pixels, lifecycle, reduced motion, stress
|
|
|
|
| 662 |
node scripts/test-designer.mjs # the designer's state to DSSL and back, plus a random sweep
|
| 663 |
node scripts/test-designer-browser.mjs # the designer panel, driven like a person would
|
| 664 |
node scripts/test-designer-host.mjs # the extension's wiring, against a fake of the editor API
|
|
|
|
| 537 |
The WebKit and Blink rules are tested in Chrome. The Firefox rules are written alongside them but
|
| 538 |
have not been run in Firefox yet.
|
| 539 |
|
| 540 |
+
### Glass and tilt
|
| 541 |
+
|
| 542 |
+
`glass:` frosts whatever is behind an element. `tilt:` turns an element toward the pointer in 3D.
|
| 543 |
+
Both work on any element.
|
| 544 |
+
|
| 545 |
+
```dssl
|
| 546 |
+
.pane {
|
| 547 |
+
glass: 18 saturate 1.6 // blur what is behind it
|
| 548 |
+
tilt: 10 lift 1.04 glare // follow the pointer
|
| 549 |
+
border: 1 solid white/35
|
| 550 |
+
background-image: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), white/28, transparent 55%)
|
| 551 |
+
}
|
| 552 |
+
```
|
| 553 |
+
|
| 554 |
+
`glass` writes a `backdrop-filter` and a faint wash of the text colour (so it works on light and dark
|
| 555 |
+
pages). It is plain CSS, so `dssl build` keeps it. Settings: a blur number, `saturate <n>`,
|
| 556 |
+
`bright <n>`, `tint <color>` or `tint none`, and a bare colour also counts as the tint. Write your own
|
| 557 |
+
`background` after it to replace the wash. `glass: none` clears it.
|
| 558 |
+
|
| 559 |
+
`tilt` is runtime only. The engine adds `perspective(...) rotateX() rotateY() scale()` after whatever
|
| 560 |
+
`transform` you wrote, driven by custom properties, so moving the mouse never restyles anything.
|
| 561 |
+
Settings: a number of degrees (0 to 45, default 8), `lift <n>` (scale while hovered),
|
| 562 |
+
`perspective <px>` (default 900), `speed <0.02 to 1>` (default 0.18), `invert`, and `glare`, which also
|
| 563 |
+
keeps `--dssl-gx` and `--dssl-gy` (0% to 100%) pointing at the pointer so you can draw a shine.
|
| 564 |
+
It ignores touch, stays flat under reduced motion, stops while the page is frozen, and combines with
|
| 565 |
+
`translate`, `scale` and `rotate`, but not with `bend` or `fold` (they also write the transform).
|
| 566 |
+
|
| 567 |
+
Also new in 0.10.0: alpha on a named colour works. `white/12` used to compile to `white/12px`, which
|
| 568 |
+
the browser dropped with no error. It now becomes a real colour, like tokens and hex always did.
|
| 569 |
+
|
| 570 |
### Warnings that help (lint)
|
| 571 |
|
| 572 |
Some things compile, run, and are still wrong for the people using the page. DSSL warns about three.
|
|
|
|
| 622 |
|
| 623 |
1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
|
| 624 |
https://open-vsx.org/extension/ehs-studio/dssl-vscode
|
| 625 |
+
2. VS Code: download `dssl-vscode/dssl-vscode-0.10.0.vsix` from the repo files, then
|
| 626 |
Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
|
| 627 |
3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
|
| 628 |
|
|
|
|
| 689 |
```sh
|
| 690 |
node scripts/test-v090.mjs # 0.9.0 pure logic: colour, texture, aura, slider, lint, import, share links
|
| 691 |
node scripts/test-browser-v090.mjs # 0.9.0 in Chrome: pixels, lifecycle, reduced motion, stress
|
| 692 |
+
node scripts/test-v0100.mjs # 0.10.0: glass, tilt, named-colour alpha, playground examples, HTML filter
|
| 693 |
node scripts/test-designer.mjs # the designer's state to DSSL and back, plus a random sweep
|
| 694 |
node scripts/test-designer-browser.mjs # the designer panel, driven like a person would
|
| 695 |
node scripts/test-designer-host.mjs # the extension's wiring, against a fake of the editor API
|
PARITY.md
CHANGED
|
@@ -62,6 +62,14 @@ 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.9.0: texture, aura, sliders, and what testing them found
|
| 66 |
|
| 67 |
New language: `texture`, the aura family, `slider`, lint warnings, `dssl import`, and an
|
|
|
|
| 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.10.0: glass, tilt, and a silent colour bug
|
| 66 |
+
|
| 67 |
+
| Need | DSSL | Status | Notes |
|
| 68 |
+
|---|---|---|---|
|
| 69 |
+
| Frosted glass | `glass: 18 saturate 1.6` | shipped | plain CSS, survives `dssl build`; CSS needs `backdrop-filter` plus a matching background |
|
| 70 |
+
| Pointer-following 3D tilt | `tilt: 10 lift 1.04 glare` | shipped | engine only; flat under reduced motion, ignores touch; not combinable with bend or fold |
|
| 71 |
+
| Alpha on a named colour | `white/12` | fixed | used to compile to `white/12px` and be dropped by the browser with no error |
|
| 72 |
+
|
| 73 |
## v0.9.0: texture, aura, sliders, and what testing them found
|
| 74 |
|
| 75 |
New language: `texture`, the aura family, `slider`, lint warnings, `dssl import`, and an
|
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 |
|
|
@@ -392,6 +392,19 @@ One description writes both the `::-webkit-slider-*` rules (Chrome, Edge, Safari
|
|
| 392 |
`::-moz-range-*` rules (Firefox). `graph 20 45 70 90 60` puts a histogram behind the track, and
|
| 393 |
`aura-particle: ... from thumb` makes particles leave the thumb.
|
| 394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
## Errors that talk back, and warnings
|
| 396 |
|
| 397 |
CSS fails silently. DSSL refuses to compile and tells you where and what it expected, with a did-you-mean built from edit distance over every known property, token and motion. Custom properties (`--*`) pass through unvalidated on purpose; the loud checks protect the design vocabulary, not the pressure valve.
|
|
|
|
| 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.10.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 |
|
|
|
|
| 392 |
`::-moz-range-*` rules (Firefox). `graph 20 45 70 90 60` puts a histogram behind the track, and
|
| 393 |
`aura-particle: ... from thumb` makes particles leave the thumb.
|
| 394 |
|
| 395 |
+
## Glass and tilt
|
| 396 |
+
|
| 397 |
+
```dssl
|
| 398 |
+
.pane {
|
| 399 |
+
glass: 18 saturate 1.6
|
| 400 |
+
tilt: 10 lift 1.04 glare
|
| 401 |
+
border: 1 solid white/35
|
| 402 |
+
}
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
`glass` blurs what is behind an element and is plain CSS, so `dssl build` keeps it. `tilt` turns
|
| 406 |
+
toward the pointer and stays in the engine. Details are in the guide.
|
| 407 |
+
|
| 408 |
## Errors that talk back, and warnings
|
| 409 |
|
| 410 |
CSS fails silently. DSSL refuses to compile and tells you where and what it expected, with a did-you-mean built from edit distance over every known property, token and motion. Custom properties (`--*`) pass through unvalidated on purpose; the loud checks protect the design vocabulary, not the pressure valve.
|
dssl-vscode/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
# Changelog
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## 0.9.1
|
| 4 |
|
| 5 |
- README: a note at the top explaining the publisher name (`ehs-studio` is the same person as
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## 0.10.0
|
| 4 |
+
|
| 5 |
+
- Bundled engine updated to 0.10.0: new `glass` and `tilt` properties, with completions and hover
|
| 6 |
+
docs for both, and alpha on named colours (`white/12`) now compiles to a real colour instead of
|
| 7 |
+
`white/12px`.
|
| 8 |
+
|
| 9 |
## 0.9.1
|
| 10 |
|
| 11 |
- README: a note at the top explaining the publisher name (`ehs-studio` is the same person as
|
dssl-vscode/lib/analysis.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
| 7 |
import { parse, PSEUDO_ELEMENTS } from './engine/parser.js';
|
| 8 |
import { resolve, DEFAULT_TOKENS, CSS_KEYWORDS } from './engine/resolver.js';
|
| 9 |
import { SEMANTIC, CSS, STATES, AT_RULES, BLOCKS, PROPERTY_NAMES } from './props-docs.js';
|
|
|
|
| 10 |
import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, BLEND_MODES } from './engine/texture.js';
|
| 11 |
import { GLOW_KEYS, PARTICLE_KEYS, SHAPES, FROM_WORDS, DIR_WORDS } from './engine/aura.js';
|
| 12 |
import { SLIDER_CLAUSES, THUMB_SHAPES } from './engine/slider.js';
|
|
@@ -21,6 +22,12 @@ function domainItems(prop) {
|
|
| 21 |
for (const g of GENERATORS) add(g, 'texture generator', 'start from scratch with this generator, then add settings');
|
| 22 |
for (const [k, d] of Object.entries(TEXTURE_KEYS)) add(k, 'texture setting', d.doc);
|
| 23 |
for (const b of BLEND_MODES) add(b, 'blend mode', 'how the texture mixes with what is under it');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
} else if (prop === 'slider') {
|
| 25 |
for (const [k, usage] of Object.entries(SLIDER_CLAUSES)) add(k, 'slider setting', usage);
|
| 26 |
for (const t of THUMB_SHAPES) add(t, 'thumb shape', 'shape ' + t);
|
|
|
|
| 7 |
import { parse, PSEUDO_ELEMENTS } from './engine/parser.js';
|
| 8 |
import { resolve, DEFAULT_TOKENS, CSS_KEYWORDS } from './engine/resolver.js';
|
| 9 |
import { SEMANTIC, CSS, STATES, AT_RULES, BLOCKS, PROPERTY_NAMES } from './props-docs.js';
|
| 10 |
+
import { GLASS_WORDS, TILT_WORDS } from './engine/effects.js';
|
| 11 |
import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, BLEND_MODES } from './engine/texture.js';
|
| 12 |
import { GLOW_KEYS, PARTICLE_KEYS, SHAPES, FROM_WORDS, DIR_WORDS } from './engine/aura.js';
|
| 13 |
import { SLIDER_CLAUSES, THUMB_SHAPES } from './engine/slider.js';
|
|
|
|
| 22 |
for (const g of GENERATORS) add(g, 'texture generator', 'start from scratch with this generator, then add settings');
|
| 23 |
for (const [k, d] of Object.entries(TEXTURE_KEYS)) add(k, 'texture setting', d.doc);
|
| 24 |
for (const b of BLEND_MODES) add(b, 'blend mode', 'how the texture mixes with what is under it');
|
| 25 |
+
} else if (prop === 'glass') {
|
| 26 |
+
for (const [k, d] of Object.entries(GLASS_WORDS)) add(k, 'glass setting', d);
|
| 27 |
+
add('none', 'clears', 'glass: none removes the effect');
|
| 28 |
+
} else if (prop === 'tilt') {
|
| 29 |
+
for (const [k, d] of Object.entries(TILT_WORDS)) add(k, 'tilt setting', d);
|
| 30 |
+
add('none', 'clears', 'tilt: none removes the effect');
|
| 31 |
} else if (prop === 'slider') {
|
| 32 |
for (const [k, usage] of Object.entries(SLIDER_CLAUSES)) add(k, 'slider setting', usage);
|
| 33 |
for (const t of THUMB_SHAPES) add(t, 'thumb shape', 'shape ' + t);
|
dssl-vscode/lib/engine/color.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
| 14 |
* whether that is an error (a texture colour) or silence (a lint check).
|
| 15 |
* ========================================================================= */
|
| 16 |
|
| 17 |
-
const NAMED = {
|
| 18 |
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
|
| 19 |
blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
|
| 20 |
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', cyan: '#00ffff', darkblue: '#00008b',
|
|
|
|
| 14 |
* whether that is an error (a texture colour) or silence (a lint check).
|
| 15 |
* ========================================================================= */
|
| 16 |
|
| 17 |
+
export const NAMED = {
|
| 18 |
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
|
| 19 |
blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
|
| 20 |
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', cyan: '#00ffff', darkblue: '#00008b',
|
dssl-vscode/lib/engine/effects.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL: effects.js (0.10.0)
|
| 3 |
+
*
|
| 4 |
+
* glass: 18 frosted glass: blur what is behind it
|
| 5 |
+
* glass: 24 saturate 1.8 tint white/12
|
| 6 |
+
* tilt: 10 follows the pointer in 3D
|
| 7 |
+
* tilt: 12 lift 1.04 glare also scales up, and exposes --dssl-gx/--dssl-gy
|
| 8 |
+
*
|
| 9 |
+
* glass is plain CSS (backdrop-filter plus a translucent background), so it
|
| 10 |
+
* survives `dssl build`. tilt needs the pointer, so it stays in the engine.
|
| 11 |
+
* ========================================================================= */
|
| 12 |
+
|
| 13 |
+
import { splitWords } from './texture.js';
|
| 14 |
+
|
| 15 |
+
const isNum = (w) => w !== undefined && w !== '' && !Number.isNaN(Number(w));
|
| 16 |
+
|
| 17 |
+
export const GLASS_WORDS = {
|
| 18 |
+
blur: 'a number: how many px to blur what is behind the element (default 16)',
|
| 19 |
+
saturate: 'saturate <n>: colour boost for what shows through, 1 is unchanged (default 1.5)',
|
| 20 |
+
bright: 'bright <n>: brightness of what shows through, 1 is unchanged',
|
| 21 |
+
tint: 'tint <color> | tint none: the milky layer on top (default: a faint wash of the text colour)',
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export const TILT_WORDS = {
|
| 25 |
+
max: 'a number: the largest tilt in degrees (default 8)',
|
| 26 |
+
lift: 'lift <n>: scale while the pointer is over it, e.g. 1.04',
|
| 27 |
+
perspective: 'perspective <px>: how strong the 3D depth looks (default 900)',
|
| 28 |
+
speed: 'speed <0.05 to 1>: how quickly it follows (default 0.18)',
|
| 29 |
+
invert: 'invert: tilt away from the pointer instead of toward it',
|
| 30 |
+
glare: 'glare: also keep --dssl-gx and --dssl-gy (0% to 100%) pointing at the pointer, for your own highlight',
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
/** `glass:` value -> declarations, or null after reporting an error */
|
| 34 |
+
export function glassDecls(value, hooks) {
|
| 35 |
+
const w = splitWords(String(value || '').trim());
|
| 36 |
+
if (w[0] === 'none') return [{ prop: 'backdrop-filter', value: 'none' }];
|
| 37 |
+
let blur = 16, sat = 1.5, bright = null, tint;
|
| 38 |
+
const bad = (m, h) => { hooks.error(m, h); return null; };
|
| 39 |
+
for (let i = 0; i < w.length; i++) {
|
| 40 |
+
const x = w[i];
|
| 41 |
+
if (isNum(x)) { blur = Number(x); continue; }
|
| 42 |
+
if (x === 'saturate' || x === 'bright') {
|
| 43 |
+
if (!isNum(w[i + 1])) return bad(`${x} needs a number`, GLASS_WORDS[x]);
|
| 44 |
+
if (x === 'saturate') sat = Number(w[i + 1]); else bright = Number(w[i + 1]);
|
| 45 |
+
i++; continue;
|
| 46 |
+
}
|
| 47 |
+
if (x === 'tint') {
|
| 48 |
+
const c = w[i + 1];
|
| 49 |
+
if (!c) return bad('tint needs a color or "none"', GLASS_WORDS.tint);
|
| 50 |
+
if (c === 'none') tint = null;
|
| 51 |
+
else { const css = hooks.color(c); if (css === null) return null; tint = css; }
|
| 52 |
+
i++; continue;
|
| 53 |
+
}
|
| 54 |
+
// a bare colour word is a tint, anything else is a typo
|
| 55 |
+
if (hooks.looksLikeColor && hooks.looksLikeColor(x)) {
|
| 56 |
+
const css = hooks.color(x); if (css === null) return null; tint = css; continue;
|
| 57 |
+
}
|
| 58 |
+
return bad(`unknown glass setting "${x}"`, hooks.suggest(x, ['saturate', 'bright', 'tint', 'none']) || 'settings: a blur number, saturate, bright, tint');
|
| 59 |
+
}
|
| 60 |
+
if (blur < 0) return bad('glass blur cannot be negative', GLASS_WORDS.blur);
|
| 61 |
+
const fx = [`blur(${blur}px)`, `saturate(${sat})`];
|
| 62 |
+
if (bright !== null) fx.push(`brightness(${bright})`);
|
| 63 |
+
const out = [{ prop: 'backdrop-filter', value: fx.join(' ') }];
|
| 64 |
+
if (tint === undefined) tint = 'color-mix(in srgb, currentColor 8%, transparent)';
|
| 65 |
+
if (tint !== null) out.push({ prop: 'background-color', value: tint });
|
| 66 |
+
return out;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/** `tilt:` value -> a spec for the engine, `null` to clear it, or undefined after an error */
|
| 70 |
+
export function parseTilt(value, hooks) {
|
| 71 |
+
const w = splitWords(String(value || '').trim());
|
| 72 |
+
if (!w.length) { hooks.error('tilt needs a number of degrees', 'e.g. tilt: 10'); return undefined; }
|
| 73 |
+
if (w[0] === 'none') return null;
|
| 74 |
+
const spec = { max: 8, lift: 1, perspective: 900, speed: 0.18, invert: false, glare: false };
|
| 75 |
+
const bad = (m, h) => { hooks.error(m, h); return undefined; };
|
| 76 |
+
const needNum = (name, i) => (isNum(w[i + 1]) ? Number(w[i + 1]) : null);
|
| 77 |
+
for (let i = 0; i < w.length; i++) {
|
| 78 |
+
const x = w[i];
|
| 79 |
+
if (isNum(x)) { spec.max = Number(x); continue; }
|
| 80 |
+
if (x === 'invert') { spec.invert = true; continue; }
|
| 81 |
+
if (x === 'glare') { spec.glare = true; continue; }
|
| 82 |
+
if (x === 'lift' || x === 'perspective' || x === 'speed') {
|
| 83 |
+
const n = needNum(x, i);
|
| 84 |
+
if (n === null) return bad(`${x} needs a number`, TILT_WORDS[x]);
|
| 85 |
+
spec[x] = n; i++; continue;
|
| 86 |
+
}
|
| 87 |
+
return bad(`unknown tilt setting "${x}"`, hooks.suggest(x, ['lift', 'perspective', 'speed', 'invert', 'glare', 'none']) || 'settings: a degrees number, lift, perspective, speed, invert, glare');
|
| 88 |
+
}
|
| 89 |
+
if (spec.max < 0 || spec.max > 45) return bad('tilt degrees should be between 0 and 45', TILT_WORDS.max);
|
| 90 |
+
if (spec.lift <= 0) return bad('lift must be above 0', TILT_WORDS.lift);
|
| 91 |
+
if (spec.perspective < 100) return bad('perspective must be at least 100px', TILT_WORDS.perspective);
|
| 92 |
+
if (spec.speed < 0.02 || spec.speed > 1) return bad('speed must be between 0.02 and 1', TILT_WORDS.speed);
|
| 93 |
+
return spec;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/** the transform pieces tilt adds after whatever the author wrote; they read variables the engine updates */
|
| 97 |
+
export function tiltTransform(spec) {
|
| 98 |
+
return `perspective(${spec.perspective}px) rotateX(var(--dssl-tx, 0deg)) rotateY(var(--dssl-ty, 0deg)) scale(var(--dssl-ts, 1))`;
|
| 99 |
+
}
|
dssl-vscode/lib/engine/resolver.js
CHANGED
|
@@ -24,6 +24,8 @@ import { parseTexture } from './texture.js';
|
|
| 24 |
import { parseAura } from './aura.js';
|
| 25 |
import { parseSlider, sliderCss } from './slider.js';
|
| 26 |
import { lintRules } from './lint.js';
|
|
|
|
|
|
|
| 27 |
// v0.5.0 - the resolver validates the `::name` half of full selectors so a
|
| 28 |
// typo like .card::befor is a LOUD error, never a silently dead rule.
|
| 29 |
import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
|
|
@@ -289,6 +291,17 @@ function resolveValue(raw, ctx) {
|
|
| 289 |
|
| 290 |
// known keyword?
|
| 291 |
if (CSS_KEYWORDS.has(ident) || CSS_KEYWORDS.has(ident.toLowerCase())) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
out += ident;
|
| 293 |
continue;
|
| 294 |
}
|
|
@@ -1046,6 +1059,16 @@ for (const name of ['aura', 'aura-glow', 'aura-particle', 'aura-particles', 'glo
|
|
| 1046 |
SEMANTIC[name] = (v, ctx) => auraDecls(name, v, ctx);
|
| 1047 |
}
|
| 1048 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1049 |
SEMANTIC.slider = function slider(v, ctx) {
|
| 1050 |
const spec = parseSlider(v, styleHooks(ctx));
|
| 1051 |
if (!spec) return [];
|
|
|
|
| 24 |
import { parseAura } from './aura.js';
|
| 25 |
import { parseSlider, sliderCss } from './slider.js';
|
| 26 |
import { lintRules } from './lint.js';
|
| 27 |
+
import { NAMED as NAMED_COLORS } from './color.js';
|
| 28 |
+
import { glassDecls, parseTilt } from './effects.js';
|
| 29 |
// v0.5.0 - the resolver validates the `::name` half of full selectors so a
|
| 30 |
// typo like .card::befor is a LOUD error, never a silently dead rule.
|
| 31 |
import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
|
|
|
|
| 291 |
|
| 292 |
// known keyword?
|
| 293 |
if (CSS_KEYWORDS.has(ident) || CSS_KEYWORDS.has(ident.toLowerCase())) {
|
| 294 |
+
// 0.10.0 - alpha on a named colour: white/12 used to become white/12px,
|
| 295 |
+
// which the browser drops without a word. Same treatment as tokens and hex.
|
| 296 |
+
const low = ident.toLowerCase();
|
| 297 |
+
if (NAMED_COLORS[low] || low === 'currentcolor') {
|
| 298 |
+
const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
|
| 299 |
+
if (am) {
|
| 300 |
+
out += `color-mix(in oklab, ${ident} ${am[1]}%, transparent)`;
|
| 301 |
+
i += am[0].length;
|
| 302 |
+
continue;
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
out += ident;
|
| 306 |
continue;
|
| 307 |
}
|
|
|
|
| 1059 |
SEMANTIC[name] = (v, ctx) => auraDecls(name, v, ctx);
|
| 1060 |
}
|
| 1061 |
|
| 1062 |
+
SEMANTIC.glass = function glass(v, ctx) {
|
| 1063 |
+
return glassDecls(v, { ...styleHooks(ctx), looksLikeColor: (w) => /^(#|rgb|hsl|oklch|oklab|color)|\//.test(w) || /^[a-z]+\.[a-z0-9-]+/.test(w) || /^(white|black|transparent)$/.test(w) }) || [];
|
| 1064 |
+
};
|
| 1065 |
+
|
| 1066 |
+
SEMANTIC.tilt = function tilt(v, ctx) {
|
| 1067 |
+
const spec = parseTilt(v, styleHooks(ctx));
|
| 1068 |
+
if (spec === undefined) return [];
|
| 1069 |
+
return [{ prop: '@tilt', value: spec }];
|
| 1070 |
+
};
|
| 1071 |
+
|
| 1072 |
SEMANTIC.slider = function slider(v, ctx) {
|
| 1073 |
const spec = parseSlider(v, styleHooks(ctx));
|
| 1074 |
if (!spec) return [];
|
dssl-vscode/lib/props-docs.js
CHANGED
|
@@ -29,6 +29,8 @@ export const SEMANTIC = {
|
|
| 29 |
'visually-hidden': 'hidden from eyes, visible to screen readers.',
|
| 30 |
cover: 'fills the containing block absolutely.',
|
| 31 |
use: 'apply a behavior: `use focus-ring`. Behaviors are named rule groups declared with `behavior name { }`.',
|
|
|
|
|
|
|
| 32 |
texture: 'a generated texture layered ON TOP of the element\u2019s own background: `texture: grain`, `texture: wood scale 6 intensity 0.8`, `texture: grain, scanlines`. 30 presets (grain, paper, marble, wood, carbon, halftone, ...) or build your own from a generator (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex) plus settings (scale, intensity, contrast, octaves, color, color2, blend, stretch, contour, ...). Open the designer to see them all.',
|
| 33 |
aura: 'light and particles around an element, with NO presets: you describe it. `aura: glow #ff7a00 30 flicker 0.3, particles #fd0 -> #f60 count 60 shape flame glowing behind`. Replaces both slots. `aura: none` clears them.',
|
| 34 |
'aura-glow': 'just the glow slot: `aura-glow: color.accent 24 pulse 1600`. Positional colour and radius, then settings (intensity, pulse, depth, flicker, texture(...), seed, essential). `aura-glow: none` clears only the glow.',
|
|
|
|
| 29 |
'visually-hidden': 'hidden from eyes, visible to screen readers.',
|
| 30 |
cover: 'fills the containing block absolutely.',
|
| 31 |
use: 'apply a behavior: `use focus-ring`. Behaviors are named rule groups declared with `behavior name { }`.',
|
| 32 |
+
glass: 'frosted glass: `glass: 18`, `glass: 24 saturate 1.8 tint white/12`. Blurs what is behind the element and adds a faint wash of the text colour. Plain CSS, so `dssl build` keeps it. `glass: none` clears it.',
|
| 33 |
+
tilt: 'follows the pointer in 3D: `tilt: 10`, `tilt: 12 lift 1.04 perspective 700 speed 0.2 invert glare`. Runtime only. `glare` keeps --dssl-gx and --dssl-gy pointing at the pointer so you can draw a shine. Ignores touch, flat under reduced motion.',
|
| 34 |
texture: 'a generated texture layered ON TOP of the element\u2019s own background: `texture: grain`, `texture: wood scale 6 intensity 0.8`, `texture: grain, scanlines`. 30 presets (grain, paper, marble, wood, carbon, halftone, ...) or build your own from a generator (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex) plus settings (scale, intensity, contrast, octaves, color, color2, blend, stretch, contour, ...). Open the designer to see them all.',
|
| 35 |
aura: 'light and particles around an element, with NO presets: you describe it. `aura: glow #ff7a00 30 flicker 0.3, particles #fd0 -> #f60 count 60 shape flame glowing behind`. Replaces both slots. `aura: none` clears them.',
|
| 36 |
'aura-glow': 'just the glow slot: `aura-glow: color.accent 24 pulse 1600`. Positional colour and radius, then settings (intensity, pulse, depth, flicker, texture(...), seed, essential). `aura-glow: none` clears only the glow.',
|
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.10.0",
|
| 6 |
"publisher": "ehs-studio",
|
| 7 |
"icon": "icon.png",
|
| 8 |
"license": "MIT",
|
llms.txt
CHANGED
|
@@ -57,6 +57,12 @@ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
|
|
| 57 |
- `dssl import styles.css -o styles.dssl` converts CSS to DSSL and reports what could not carry over; the output always compiles.
|
| 58 |
- The editor extension has a designer panel (sliders, live previews, graphs, DSSL output, Insert and Load from selection), completions for the new vocabulary, and a CSS converter command.
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
## CLI (v0.8.0)
|
| 61 |
|
| 62 |
```sh
|
|
@@ -70,4 +76,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
|
|
| 70 |
|
| 71 |
## Editor support
|
| 72 |
|
| 73 |
-
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.
|
|
|
|
| 57 |
- `dssl import styles.css -o styles.dssl` converts CSS to DSSL and reports what could not carry over; the output always compiles.
|
| 58 |
- The editor extension has a designer panel (sliders, live previews, graphs, DSSL output, Insert and Load from selection), completions for the new vocabulary, and a CSS converter command.
|
| 59 |
|
| 60 |
+
## 0.10.0: glass, tilt, named-colour alpha
|
| 61 |
+
- `glass: 18 saturate 1.6 tint white/12` writes backdrop-filter plus a faint wash of the text colour. Plain CSS, kept by `dssl build`. Settings: blur number, `saturate <n>`, `bright <n>`, `tint <color>|none`. `glass: none` clears it.
|
| 62 |
+
- `tilt: 10 lift 1.04 perspective 900 speed 0.18 invert glare` turns toward the pointer in 3D. Runtime only. `glare` keeps `--dssl-gx` and `--dssl-gy` pointing at the pointer. Ignores touch, flat under reduced motion, stops on freeze. Do not combine with `bend` or `fold`.
|
| 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 |
## CLI (v0.8.0)
|
| 67 |
|
| 68 |
```sh
|
|
|
|
| 76 |
|
| 77 |
## Editor support
|
| 78 |
|
| 79 |
+
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.10.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.10.0",
|
| 4 |
"private": true,
|
| 5 |
"description": "The DSSL style language and the website that is written in it.",
|
| 6 |
"scripts": {
|
public/dssl/color.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
| 14 |
* whether that is an error (a texture colour) or silence (a lint check).
|
| 15 |
* ========================================================================= */
|
| 16 |
|
| 17 |
-
const NAMED = {
|
| 18 |
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
|
| 19 |
blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
|
| 20 |
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', cyan: '#00ffff', darkblue: '#00008b',
|
|
|
|
| 14 |
* whether that is an error (a texture colour) or silence (a lint check).
|
| 15 |
* ========================================================================= */
|
| 16 |
|
| 17 |
+
export const NAMED = {
|
| 18 |
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
|
| 19 |
blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
|
| 20 |
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', cyan: '#00ffff', darkblue: '#00008b',
|
public/dssl/effects.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL: effects.js (0.10.0)
|
| 3 |
+
*
|
| 4 |
+
* glass: 18 frosted glass: blur what is behind it
|
| 5 |
+
* glass: 24 saturate 1.8 tint white/12
|
| 6 |
+
* tilt: 10 follows the pointer in 3D
|
| 7 |
+
* tilt: 12 lift 1.04 glare also scales up, and exposes --dssl-gx/--dssl-gy
|
| 8 |
+
*
|
| 9 |
+
* glass is plain CSS (backdrop-filter plus a translucent background), so it
|
| 10 |
+
* survives `dssl build`. tilt needs the pointer, so it stays in the engine.
|
| 11 |
+
* ========================================================================= */
|
| 12 |
+
|
| 13 |
+
import { splitWords } from './texture.js';
|
| 14 |
+
|
| 15 |
+
const isNum = (w) => w !== undefined && w !== '' && !Number.isNaN(Number(w));
|
| 16 |
+
|
| 17 |
+
export const GLASS_WORDS = {
|
| 18 |
+
blur: 'a number: how many px to blur what is behind the element (default 16)',
|
| 19 |
+
saturate: 'saturate <n>: colour boost for what shows through, 1 is unchanged (default 1.5)',
|
| 20 |
+
bright: 'bright <n>: brightness of what shows through, 1 is unchanged',
|
| 21 |
+
tint: 'tint <color> | tint none: the milky layer on top (default: a faint wash of the text colour)',
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export const TILT_WORDS = {
|
| 25 |
+
max: 'a number: the largest tilt in degrees (default 8)',
|
| 26 |
+
lift: 'lift <n>: scale while the pointer is over it, e.g. 1.04',
|
| 27 |
+
perspective: 'perspective <px>: how strong the 3D depth looks (default 900)',
|
| 28 |
+
speed: 'speed <0.05 to 1>: how quickly it follows (default 0.18)',
|
| 29 |
+
invert: 'invert: tilt away from the pointer instead of toward it',
|
| 30 |
+
glare: 'glare: also keep --dssl-gx and --dssl-gy (0% to 100%) pointing at the pointer, for your own highlight',
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
/** `glass:` value -> declarations, or null after reporting an error */
|
| 34 |
+
export function glassDecls(value, hooks) {
|
| 35 |
+
const w = splitWords(String(value || '').trim());
|
| 36 |
+
if (w[0] === 'none') return [{ prop: 'backdrop-filter', value: 'none' }];
|
| 37 |
+
let blur = 16, sat = 1.5, bright = null, tint;
|
| 38 |
+
const bad = (m, h) => { hooks.error(m, h); return null; };
|
| 39 |
+
for (let i = 0; i < w.length; i++) {
|
| 40 |
+
const x = w[i];
|
| 41 |
+
if (isNum(x)) { blur = Number(x); continue; }
|
| 42 |
+
if (x === 'saturate' || x === 'bright') {
|
| 43 |
+
if (!isNum(w[i + 1])) return bad(`${x} needs a number`, GLASS_WORDS[x]);
|
| 44 |
+
if (x === 'saturate') sat = Number(w[i + 1]); else bright = Number(w[i + 1]);
|
| 45 |
+
i++; continue;
|
| 46 |
+
}
|
| 47 |
+
if (x === 'tint') {
|
| 48 |
+
const c = w[i + 1];
|
| 49 |
+
if (!c) return bad('tint needs a color or "none"', GLASS_WORDS.tint);
|
| 50 |
+
if (c === 'none') tint = null;
|
| 51 |
+
else { const css = hooks.color(c); if (css === null) return null; tint = css; }
|
| 52 |
+
i++; continue;
|
| 53 |
+
}
|
| 54 |
+
// a bare colour word is a tint, anything else is a typo
|
| 55 |
+
if (hooks.looksLikeColor && hooks.looksLikeColor(x)) {
|
| 56 |
+
const css = hooks.color(x); if (css === null) return null; tint = css; continue;
|
| 57 |
+
}
|
| 58 |
+
return bad(`unknown glass setting "${x}"`, hooks.suggest(x, ['saturate', 'bright', 'tint', 'none']) || 'settings: a blur number, saturate, bright, tint');
|
| 59 |
+
}
|
| 60 |
+
if (blur < 0) return bad('glass blur cannot be negative', GLASS_WORDS.blur);
|
| 61 |
+
const fx = [`blur(${blur}px)`, `saturate(${sat})`];
|
| 62 |
+
if (bright !== null) fx.push(`brightness(${bright})`);
|
| 63 |
+
const out = [{ prop: 'backdrop-filter', value: fx.join(' ') }];
|
| 64 |
+
if (tint === undefined) tint = 'color-mix(in srgb, currentColor 8%, transparent)';
|
| 65 |
+
if (tint !== null) out.push({ prop: 'background-color', value: tint });
|
| 66 |
+
return out;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/** `tilt:` value -> a spec for the engine, `null` to clear it, or undefined after an error */
|
| 70 |
+
export function parseTilt(value, hooks) {
|
| 71 |
+
const w = splitWords(String(value || '').trim());
|
| 72 |
+
if (!w.length) { hooks.error('tilt needs a number of degrees', 'e.g. tilt: 10'); return undefined; }
|
| 73 |
+
if (w[0] === 'none') return null;
|
| 74 |
+
const spec = { max: 8, lift: 1, perspective: 900, speed: 0.18, invert: false, glare: false };
|
| 75 |
+
const bad = (m, h) => { hooks.error(m, h); return undefined; };
|
| 76 |
+
const needNum = (name, i) => (isNum(w[i + 1]) ? Number(w[i + 1]) : null);
|
| 77 |
+
for (let i = 0; i < w.length; i++) {
|
| 78 |
+
const x = w[i];
|
| 79 |
+
if (isNum(x)) { spec.max = Number(x); continue; }
|
| 80 |
+
if (x === 'invert') { spec.invert = true; continue; }
|
| 81 |
+
if (x === 'glare') { spec.glare = true; continue; }
|
| 82 |
+
if (x === 'lift' || x === 'perspective' || x === 'speed') {
|
| 83 |
+
const n = needNum(x, i);
|
| 84 |
+
if (n === null) return bad(`${x} needs a number`, TILT_WORDS[x]);
|
| 85 |
+
spec[x] = n; i++; continue;
|
| 86 |
+
}
|
| 87 |
+
return bad(`unknown tilt setting "${x}"`, hooks.suggest(x, ['lift', 'perspective', 'speed', 'invert', 'glare', 'none']) || 'settings: a degrees number, lift, perspective, speed, invert, glare');
|
| 88 |
+
}
|
| 89 |
+
if (spec.max < 0 || spec.max > 45) return bad('tilt degrees should be between 0 and 45', TILT_WORDS.max);
|
| 90 |
+
if (spec.lift <= 0) return bad('lift must be above 0', TILT_WORDS.lift);
|
| 91 |
+
if (spec.perspective < 100) return bad('perspective must be at least 100px', TILT_WORDS.perspective);
|
| 92 |
+
if (spec.speed < 0.02 || spec.speed > 1) return bad('speed must be between 0.02 and 1', TILT_WORDS.speed);
|
| 93 |
+
return spec;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/** the transform pieces tilt adds after whatever the author wrote; they read variables the engine updates */
|
| 97 |
+
export function tiltTransform(spec) {
|
| 98 |
+
return `perspective(${spec.perspective}px) rotateX(var(--dssl-tx, 0deg)) rotateY(var(--dssl-ty, 0deg)) scale(var(--dssl-ts, 1))`;
|
| 99 |
+
}
|
public/dssl/engine.js
CHANGED
|
@@ -24,6 +24,7 @@ import { composeDeform, arcLayout } from './utils.js';
|
|
| 24 |
import { buildTexture } from './texture.js';
|
| 25 |
import { AuraManager, resolveLayer } from './aura.js';
|
| 26 |
import { parseColor } from './color.js';
|
|
|
|
| 27 |
|
| 28 |
const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
|
| 29 |
|
|
@@ -208,6 +209,7 @@ export class DsslEngine {
|
|
| 208 |
this.sliderEls = new Map(); // range input -> its input listener
|
| 209 |
this.texAnims = new WeakMap(); // element -> running texture-shimmer Animation
|
| 210 |
this.texEls = new Set(); // elements that have one, so freeze() can stop them all
|
|
|
|
| 211 |
}
|
| 212 |
|
| 213 |
/* ------------------------------------------------------------ *
|
|
@@ -317,6 +319,60 @@ export class DsslEngine {
|
|
| 317 |
this.auraManager().set(el, g, p);
|
| 318 |
}
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
/** range inputs: keep --dssl-p (the value as 0..1) current so the track fill and graph can follow it */
|
| 321 |
applySlider(el, spec) {
|
| 322 |
const known = this.sliderEls.has(el);
|
|
@@ -790,6 +846,7 @@ export class DsslEngine {
|
|
| 790 |
let arcVal; // v0.6.0 - @bend-around degrees
|
| 791 |
let gflowVal; // v0.6.0 - @gradient-flow spec
|
| 792 |
let textureVal, auraGlowVal, auraPartsVal, sliderVal; // 0.9.0
|
|
|
|
| 793 |
|
| 794 |
// flatten matched decls in cascade order (last decl of a prop wins, exactly
|
| 795 |
// like sequential setProperty calls did)
|
|
@@ -831,6 +888,7 @@ export class DsslEngine {
|
|
| 831 |
if (d.prop === '@aura-glow') { auraGlowVal = d.value || null; continue; }
|
| 832 |
if (d.prop === '@aura-particles') { auraPartsVal = d.value || null; continue; }
|
| 833 |
if (d.prop === '@slider') { sliderVal = d.value || null; continue; }
|
|
|
|
| 834 |
newOwned.set(d.prop, { value: d.value, imp });
|
| 835 |
}
|
| 836 |
// anim keys need rule identity - rebuild them from matched order
|
|
@@ -886,6 +944,15 @@ export class DsslEngine {
|
|
| 886 |
newOwned.set('transform', { value: c.transform, imp: userT ? userT.imp : '' });
|
| 887 |
}
|
| 888 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 889 |
// v0.6.0 - gradient-flow needs a gradient to flow and room to move. If
|
| 890 |
// the element has no background-image at all, say so instead of looping
|
| 891 |
// a position nobody can see.
|
|
@@ -1074,6 +1141,7 @@ export class DsslEngine {
|
|
| 1074 |
this.applyTextureShimmer(el, builtTex, builtTex ? Math.max(...textureVal.map((l) => l.animate || 0)) : 0, texUserLayers);
|
| 1075 |
this.applyAura(el, auraGlowVal, auraPartsVal);
|
| 1076 |
this.applySlider(el, sliderVal);
|
|
|
|
| 1077 |
|
| 1078 |
// v0.5.0 - overlap bookkeeping: remember (or forget) the element's policy.
|
| 1079 |
// The collision pass itself runs rAF-deferred after the drain; a policy
|
|
@@ -1399,6 +1467,8 @@ export class DsslEngine {
|
|
| 1399 |
if (this.aura) this.aura.pause(); // 0.9.0: a frozen page does not animate
|
| 1400 |
for (const el of this.texEls) { const a = this.texAnims.get(el); if (a) { try { a.cancel(); } catch { } } this.texAnims.delete(el); }
|
| 1401 |
this.texEls.clear();
|
|
|
|
|
|
|
| 1402 |
this.pending.clear();
|
| 1403 |
return this.freezeStats();
|
| 1404 |
}
|
|
|
|
| 24 |
import { buildTexture } from './texture.js';
|
| 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 |
|
|
|
|
| 209 |
this.sliderEls = new Map(); // range input -> its input listener
|
| 210 |
this.texAnims = new WeakMap(); // element -> running texture-shimmer Animation
|
| 211 |
this.texEls = new Set(); // elements that have one, so freeze() can stop them all
|
| 212 |
+
this.tiltEls = new Map(); // 0.10.0: element -> { spec, stop } for tilt:
|
| 213 |
}
|
| 214 |
|
| 215 |
/* ------------------------------------------------------------ *
|
|
|
|
| 319 |
this.auraManager().set(el, g, p);
|
| 320 |
}
|
| 321 |
|
| 322 |
+
/** 0.10.0: tilt follows the pointer by smoothing toward a target, writing four custom properties on the element */
|
| 323 |
+
applyTilt(el, spec) {
|
| 324 |
+
const cur = this.tiltEls.get(el);
|
| 325 |
+
if (!spec) { if (cur) { cur.stop(); this.tiltEls.delete(el); } return; }
|
| 326 |
+
const key = JSON.stringify(spec);
|
| 327 |
+
if (cur && cur.key === key) return;
|
| 328 |
+
if (cur) cur.stop();
|
| 329 |
+
let reduce = false;
|
| 330 |
+
try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { }
|
| 331 |
+
if (reduce) { this.tiltEls.set(el, { key, stop() { } }); return; }
|
| 332 |
+
const sign = spec.invert ? -1 : 1;
|
| 333 |
+
const cu = { x: 0, y: 0, s: 1, gx: 50, gy: 50 };
|
| 334 |
+
let target = { x: 0, y: 0, s: 1, gx: 50, gy: 50 };
|
| 335 |
+
let raf = 0;
|
| 336 |
+
const write = () => {
|
| 337 |
+
el.style.setProperty('--dssl-tx', cu.x.toFixed(2) + 'deg');
|
| 338 |
+
el.style.setProperty('--dssl-ty', cu.y.toFixed(2) + 'deg');
|
| 339 |
+
el.style.setProperty('--dssl-ts', cu.s.toFixed(4));
|
| 340 |
+
if (spec.glare) { el.style.setProperty('--dssl-gx', cu.gx.toFixed(1) + '%'); el.style.setProperty('--dssl-gy', cu.gy.toFixed(1) + '%'); }
|
| 341 |
+
};
|
| 342 |
+
const step = () => {
|
| 343 |
+
raf = 0;
|
| 344 |
+
let moving = false;
|
| 345 |
+
for (const k of ['x', 'y', 's', 'gx', 'gy']) {
|
| 346 |
+
const d = target[k] - cu[k];
|
| 347 |
+
const eps = k === 's' ? 0.0005 : 0.02;
|
| 348 |
+
if (Math.abs(d) > eps) { cu[k] += d * spec.speed; moving = true; } else cu[k] = target[k];
|
| 349 |
+
}
|
| 350 |
+
write();
|
| 351 |
+
if (moving) raf = requestAnimationFrame(step);
|
| 352 |
+
};
|
| 353 |
+
const kick = () => { if (!raf) raf = requestAnimationFrame(step); };
|
| 354 |
+
const move = (e) => {
|
| 355 |
+
if (e.pointerType === 'touch') return;
|
| 356 |
+
const r = el.getBoundingClientRect();
|
| 357 |
+
if (!r.width || !r.height) return;
|
| 358 |
+
const nx = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1));
|
| 359 |
+
const ny = Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height) * 2 - 1));
|
| 360 |
+
target = { x: -ny * spec.max * sign, y: nx * spec.max * sign, s: spec.lift, gx: (nx + 1) * 50, gy: (ny + 1) * 50 };
|
| 361 |
+
kick();
|
| 362 |
+
};
|
| 363 |
+
const leave = () => { target = { x: 0, y: 0, s: 1, gx: 50, gy: 50 }; kick(); };
|
| 364 |
+
el.addEventListener('pointermove', move, { passive: true });
|
| 365 |
+
el.addEventListener('pointerleave', leave, { passive: true });
|
| 366 |
+
this.tiltEls.set(el, {
|
| 367 |
+
key,
|
| 368 |
+
stop: () => {
|
| 369 |
+
el.removeEventListener('pointermove', move); el.removeEventListener('pointerleave', leave);
|
| 370 |
+
if (raf) cancelAnimationFrame(raf);
|
| 371 |
+
for (const p of ['--dssl-tx', '--dssl-ty', '--dssl-ts', '--dssl-gx', '--dssl-gy']) el.style.removeProperty(p);
|
| 372 |
+
},
|
| 373 |
+
});
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
/** range inputs: keep --dssl-p (the value as 0..1) current so the track fill and graph can follow it */
|
| 377 |
applySlider(el, spec) {
|
| 378 |
const known = this.sliderEls.has(el);
|
|
|
|
| 846 |
let arcVal; // v0.6.0 - @bend-around degrees
|
| 847 |
let gflowVal; // v0.6.0 - @gradient-flow spec
|
| 848 |
let textureVal, auraGlowVal, auraPartsVal, sliderVal; // 0.9.0
|
| 849 |
+
let tiltVal = null; // 0.10.0
|
| 850 |
|
| 851 |
// flatten matched decls in cascade order (last decl of a prop wins, exactly
|
| 852 |
// like sequential setProperty calls did)
|
|
|
|
| 888 |
if (d.prop === '@aura-glow') { auraGlowVal = d.value || null; continue; }
|
| 889 |
if (d.prop === '@aura-particles') { auraPartsVal = d.value || null; continue; }
|
| 890 |
if (d.prop === '@slider') { sliderVal = d.value || null; continue; }
|
| 891 |
+
if (d.prop === '@tilt') { tiltVal = d.value || null; continue; }
|
| 892 |
newOwned.set(d.prop, { value: d.value, imp });
|
| 893 |
}
|
| 894 |
// anim keys need rule identity - rebuild them from matched order
|
|
|
|
| 944 |
newOwned.set('transform', { value: c.transform, imp: userT ? userT.imp : '' });
|
| 945 |
}
|
| 946 |
|
| 947 |
+
// 0.10.0 - tilt adds its rotation after everything else in the transform.
|
| 948 |
+
// The angles are variables the pointer handler updates, so moving the
|
| 949 |
+
// mouse never triggers a restyle.
|
| 950 |
+
if (tiltVal) {
|
| 951 |
+
const userT = newOwned.get('transform');
|
| 952 |
+
const base = userT && userT.value && userT.value !== 'none' ? String(userT.value) + ' ' : '';
|
| 953 |
+
newOwned.set('transform', { value: base + tiltTransform(tiltVal), imp: userT ? userT.imp : '' });
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
// v0.6.0 - gradient-flow needs a gradient to flow and room to move. If
|
| 957 |
// the element has no background-image at all, say so instead of looping
|
| 958 |
// a position nobody can see.
|
|
|
|
| 1141 |
this.applyTextureShimmer(el, builtTex, builtTex ? Math.max(...textureVal.map((l) => l.animate || 0)) : 0, texUserLayers);
|
| 1142 |
this.applyAura(el, auraGlowVal, auraPartsVal);
|
| 1143 |
this.applySlider(el, sliderVal);
|
| 1144 |
+
this.applyTilt(el, tiltVal);
|
| 1145 |
|
| 1146 |
// v0.5.0 - overlap bookkeeping: remember (or forget) the element's policy.
|
| 1147 |
// The collision pass itself runs rAF-deferred after the drain; a policy
|
|
|
|
| 1467 |
if (this.aura) this.aura.pause(); // 0.9.0: a frozen page does not animate
|
| 1468 |
for (const el of this.texEls) { const a = this.texAnims.get(el); if (a) { try { a.cancel(); } catch { } } this.texAnims.delete(el); }
|
| 1469 |
this.texEls.clear();
|
| 1470 |
+
for (const t of this.tiltEls.values()) t.stop();
|
| 1471 |
+
this.tiltEls.clear();
|
| 1472 |
this.pending.clear();
|
| 1473 |
return this.freezeStats();
|
| 1474 |
}
|
public/dssl/index.js
CHANGED
|
@@ -19,7 +19,7 @@ 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 engines = new Map();
|
|
|
|
| 19 |
import { toCSS as docToCSS } from './static.js';
|
| 20 |
import { DsslEngine } from './engine.js';
|
| 21 |
|
| 22 |
+
export const VERSION = '0.10.0';
|
| 23 |
|
| 24 |
let counter = 0;
|
| 25 |
const engines = new Map();
|
public/dssl/resolver.js
CHANGED
|
@@ -24,6 +24,8 @@ import { parseTexture } from './texture.js';
|
|
| 24 |
import { parseAura } from './aura.js';
|
| 25 |
import { parseSlider, sliderCss } from './slider.js';
|
| 26 |
import { lintRules } from './lint.js';
|
|
|
|
|
|
|
| 27 |
// v0.5.0 - the resolver validates the `::name` half of full selectors so a
|
| 28 |
// typo like .card::befor is a LOUD error, never a silently dead rule.
|
| 29 |
import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
|
|
@@ -289,6 +291,17 @@ function resolveValue(raw, ctx) {
|
|
| 289 |
|
| 290 |
// known keyword?
|
| 291 |
if (CSS_KEYWORDS.has(ident) || CSS_KEYWORDS.has(ident.toLowerCase())) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
out += ident;
|
| 293 |
continue;
|
| 294 |
}
|
|
@@ -1046,6 +1059,16 @@ for (const name of ['aura', 'aura-glow', 'aura-particle', 'aura-particles', 'glo
|
|
| 1046 |
SEMANTIC[name] = (v, ctx) => auraDecls(name, v, ctx);
|
| 1047 |
}
|
| 1048 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1049 |
SEMANTIC.slider = function slider(v, ctx) {
|
| 1050 |
const spec = parseSlider(v, styleHooks(ctx));
|
| 1051 |
if (!spec) return [];
|
|
|
|
| 24 |
import { parseAura } from './aura.js';
|
| 25 |
import { parseSlider, sliderCss } from './slider.js';
|
| 26 |
import { lintRules } from './lint.js';
|
| 27 |
+
import { NAMED as NAMED_COLORS } from './color.js';
|
| 28 |
+
import { glassDecls, parseTilt } from './effects.js';
|
| 29 |
// v0.5.0 - the resolver validates the `::name` half of full selectors so a
|
| 30 |
// typo like .card::befor is a LOUD error, never a silently dead rule.
|
| 31 |
import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
|
|
|
|
| 291 |
|
| 292 |
// known keyword?
|
| 293 |
if (CSS_KEYWORDS.has(ident) || CSS_KEYWORDS.has(ident.toLowerCase())) {
|
| 294 |
+
// 0.10.0 - alpha on a named colour: white/12 used to become white/12px,
|
| 295 |
+
// which the browser drops without a word. Same treatment as tokens and hex.
|
| 296 |
+
const low = ident.toLowerCase();
|
| 297 |
+
if (NAMED_COLORS[low] || low === 'currentcolor') {
|
| 298 |
+
const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
|
| 299 |
+
if (am) {
|
| 300 |
+
out += `color-mix(in oklab, ${ident} ${am[1]}%, transparent)`;
|
| 301 |
+
i += am[0].length;
|
| 302 |
+
continue;
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
out += ident;
|
| 306 |
continue;
|
| 307 |
}
|
|
|
|
| 1059 |
SEMANTIC[name] = (v, ctx) => auraDecls(name, v, ctx);
|
| 1060 |
}
|
| 1061 |
|
| 1062 |
+
SEMANTIC.glass = function glass(v, ctx) {
|
| 1063 |
+
return glassDecls(v, { ...styleHooks(ctx), looksLikeColor: (w) => /^(#|rgb|hsl|oklch|oklab|color)|\//.test(w) || /^[a-z]+\.[a-z0-9-]+/.test(w) || /^(white|black|transparent)$/.test(w) }) || [];
|
| 1064 |
+
};
|
| 1065 |
+
|
| 1066 |
+
SEMANTIC.tilt = function tilt(v, ctx) {
|
| 1067 |
+
const spec = parseTilt(v, styleHooks(ctx));
|
| 1068 |
+
if (spec === undefined) return [];
|
| 1069 |
+
return [{ prop: '@tilt', value: spec }];
|
| 1070 |
+
};
|
| 1071 |
+
|
| 1072 |
SEMANTIC.slider = function slider(v, ctx) {
|
| 1073 |
const spec = parseSlider(v, styleHooks(ctx));
|
| 1074 |
if (!spec) return [];
|
public/llms.txt
CHANGED
|
@@ -57,6 +57,12 @@ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
|
|
| 57 |
- `dssl import styles.css -o styles.dssl` converts CSS to DSSL and reports what could not carry over; the output always compiles.
|
| 58 |
- The editor extension has a designer panel (sliders, live previews, graphs, DSSL output, Insert and Load from selection), completions for the new vocabulary, and a CSS converter command.
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
## CLI (v0.8.0)
|
| 61 |
|
| 62 |
```sh
|
|
@@ -70,4 +76,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
|
|
| 70 |
|
| 71 |
## Editor support
|
| 72 |
|
| 73 |
-
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.
|
|
|
|
| 57 |
- `dssl import styles.css -o styles.dssl` converts CSS to DSSL and reports what could not carry over; the output always compiles.
|
| 58 |
- The editor extension has a designer panel (sliders, live previews, graphs, DSSL output, Insert and Load from selection), completions for the new vocabulary, and a CSS converter command.
|
| 59 |
|
| 60 |
+
## 0.10.0: glass, tilt, named-colour alpha
|
| 61 |
+
- `glass: 18 saturate 1.6 tint white/12` writes backdrop-filter plus a faint wash of the text colour. Plain CSS, kept by `dssl build`. Settings: blur number, `saturate <n>`, `bright <n>`, `tint <color>|none`. `glass: none` clears it.
|
| 62 |
+
- `tilt: 10 lift 1.04 perspective 900 speed 0.18 invert glare` turns toward the pointer in 3D. Runtime only. `glare` keeps `--dssl-gx` and `--dssl-gy` pointing at the pointer. Ignores touch, flat under reduced motion, stops on freeze. Do not combine with `bend` or `fold`.
|
| 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 |
## CLI (v0.8.0)
|
| 67 |
|
| 68 |
```sh
|
|
|
|
| 76 |
|
| 77 |
## Editor support
|
| 78 |
|
| 79 |
+
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.10.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
|
public/styles/docs.dssl
CHANGED
|
@@ -863,3 +863,36 @@ behavior snow {
|
|
| 863 |
.doc-list li {
|
| 864 |
line-height: 1.65
|
| 865 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
.doc-list li {
|
| 864 |
line-height: 1.65
|
| 865 |
}
|
| 866 |
+
|
| 867 |
+
// ---- 0.10.0: glass and tilt demo
|
| 868 |
+
.fx-scene {
|
| 869 |
+
position: relative
|
| 870 |
+
overflow: hidden
|
| 871 |
+
width: 100%
|
| 872 |
+
radius: lg
|
| 873 |
+
padding: 36 28
|
| 874 |
+
background: linear-gradient(135deg, #ff7a00, #c2185b 55%, #3949ab)
|
| 875 |
+
display: grid
|
| 876 |
+
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr))
|
| 877 |
+
gap: 20
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
.fx-blob { position: absolute; radius: full }
|
| 881 |
+
.fx-blob-a { width: 150; height: 150; left: 6%; top: -34; background: #ffe14a }
|
| 882 |
+
.fx-blob-b { width: 120; height: 120; right: 12%; bottom: -26; background: #40e0d0 }
|
| 883 |
+
|
| 884 |
+
.fx-pane {
|
| 885 |
+
position: relative
|
| 886 |
+
column: gap 6
|
| 887 |
+
min-height: 130
|
| 888 |
+
padding: 20
|
| 889 |
+
glass: 18 saturate 1.6
|
| 890 |
+
tilt: 10 lift 1.04 glare
|
| 891 |
+
border: 1 solid white/35
|
| 892 |
+
radius: lg
|
| 893 |
+
color: white
|
| 894 |
+
font-size: 13.5
|
| 895 |
+
background-image: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), white/28, transparent 55%)
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
.fx-pane b { font-size: 17 }
|
public/styles/home.dssl
CHANGED
|
@@ -407,3 +407,135 @@
|
|
| 407 |
.install-downloads {
|
| 408 |
row: gap 10 wrap
|
| 409 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
.install-downloads {
|
| 408 |
row: gap 10 wrap
|
| 409 |
}
|
| 410 |
+
|
| 411 |
+
// ------------------------------------------------------------- live effects
|
| 412 |
+
// Each demo is real DSSL: aura, glass, tilt, texture and slider, running here.
|
| 413 |
+
|
| 414 |
+
.show-sub { max-width: 640; margin: 0 0 space.6 }
|
| 415 |
+
|
| 416 |
+
.show-grid {
|
| 417 |
+
display: grid
|
| 418 |
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr))
|
| 419 |
+
gap: space.4
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.show-card {
|
| 423 |
+
column: gap 12
|
| 424 |
+
padding: space.4
|
| 425 |
+
background: color.card
|
| 426 |
+
border: 1 solid color.line
|
| 427 |
+
radius: lg
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.show-demo {
|
| 431 |
+
position: relative
|
| 432 |
+
height: 190
|
| 433 |
+
radius: md
|
| 434 |
+
display: grid
|
| 435 |
+
place-items: center
|
| 436 |
+
overflow: visible
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.show-code {
|
| 440 |
+
font-family: font.mono
|
| 441 |
+
font-size: 12.5
|
| 442 |
+
background: color.code-bg
|
| 443 |
+
color: color.code-ink
|
| 444 |
+
radius: sm
|
| 445 |
+
padding: 8 10
|
| 446 |
+
overflow-x: auto
|
| 447 |
+
white-space: nowrap
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.show-text { margin: 0; font-size: 14.5; line-height: 1.6; color: color.muted }
|
| 451 |
+
.show-text code { font-family: font.mono; font-size: 13; color: color.ink }
|
| 452 |
+
|
| 453 |
+
.show-link {
|
| 454 |
+
font-weight: 700
|
| 455 |
+
font-size: 14
|
| 456 |
+
color: color.accent
|
| 457 |
+
align-self: start
|
| 458 |
+
text-underline-offset: 3
|
| 459 |
+
focus-visible { outline: 2 solid color.accent; outline-offset: 3 }
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
// fire: an aura recipe, written out in full so the page is its own example
|
| 463 |
+
behavior show-flame {
|
| 464 |
+
aura-glow: #ff7a00 36 pulse 900 flicker 0.35
|
| 465 |
+
aura-particle: #ffe27a -> #ff8a1f -> rgba(255,40,0,0) count 70 size 8 18 life 650 1100 speed 40 120 dir up spread 40 from top gravity -40 turbulence 70 shape flame glowing behind
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.show-demo-fire { background: #0b0a12 }
|
| 469 |
+
|
| 470 |
+
.show-orb {
|
| 471 |
+
width: 120
|
| 472 |
+
height: 76
|
| 473 |
+
radius: lg
|
| 474 |
+
background: #1c1a28
|
| 475 |
+
color: #e9e4f5
|
| 476 |
+
display: grid
|
| 477 |
+
place-items: center
|
| 478 |
+
font-size: 14
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.show-fire { use show-flame }
|
| 482 |
+
|
| 483 |
+
// glass and tilt over a colourful backdrop
|
| 484 |
+
.show-demo-glass {
|
| 485 |
+
background: linear-gradient(135deg, #ff7a00, #c2185b 55%, #3949ab)
|
| 486 |
+
overflow: hidden
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.show-blob { position: absolute; radius: full }
|
| 490 |
+
.show-blob-a { width: 110; height: 110; left: 14%; top: 8%; background: #ffe14a }
|
| 491 |
+
.show-blob-b { width: 90; height: 90; right: 10%; bottom: 6%; background: #40e0d0 }
|
| 492 |
+
|
| 493 |
+
.show-pane {
|
| 494 |
+
position: relative
|
| 495 |
+
column: gap 4
|
| 496 |
+
width: 66%
|
| 497 |
+
padding: 18 16
|
| 498 |
+
glass: 16 saturate 1.6
|
| 499 |
+
tilt: 12 lift 1.05 glare
|
| 500 |
+
border: 1 solid white/40
|
| 501 |
+
radius: lg
|
| 502 |
+
color: white
|
| 503 |
+
font-size: 13
|
| 504 |
+
background-image: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), white/30, transparent 60%)
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.show-pane b { font-size: 17 }
|
| 508 |
+
|
| 509 |
+
// textures
|
| 510 |
+
.show-demo-tex {
|
| 511 |
+
grid-template-columns: 1fr 1fr
|
| 512 |
+
gap: 8
|
| 513 |
+
place-items: stretch
|
| 514 |
+
background: transparent
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.show-tex {
|
| 518 |
+
radius: md
|
| 519 |
+
background-color: #b8a58a
|
| 520 |
+
color: #1b1408
|
| 521 |
+
font-size: 12
|
| 522 |
+
padding: 8
|
| 523 |
+
display: grid
|
| 524 |
+
place-items: end start
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.show-tex-wood { texture: wood }
|
| 528 |
+
.show-tex-marble { texture: marble }
|
| 529 |
+
.show-tex-carbon { texture: carbon; color: white }
|
| 530 |
+
.show-tex-hex { texture: hex }
|
| 531 |
+
|
| 532 |
+
// slider
|
| 533 |
+
.show-demo-slider {
|
| 534 |
+
background: #101018
|
| 535 |
+
padding: 0 18
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
.show-range {
|
| 539 |
+
width: 100%
|
| 540 |
+
slider: graph 20 45 70 90 60 35 50 80 95 40 60 75, graph-height 56, graph-gap 3, fill #77ccff, track 3 #34445a, thumb 14 #ffffff, shape bar
|
| 541 |
+
}
|
public/styles/playground.dssl
CHANGED
|
@@ -185,3 +185,82 @@
|
|
| 185 |
background: color.ink/6
|
| 186 |
border-bottom: 1 solid color.line
|
| 187 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
background: color.ink/6
|
| 186 |
border-bottom: 1 solid color.line
|
| 187 |
}
|
| 188 |
+
|
| 189 |
+
// ---- 0.10.0: tabs, warnings and the CSS view
|
| 190 |
+
.pg-tabs {
|
| 191 |
+
row: gap 2
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.pg-tab {
|
| 195 |
+
font-family: font.mono
|
| 196 |
+
font-size: 12
|
| 197 |
+
color: color.muted
|
| 198 |
+
background: transparent
|
| 199 |
+
border: 0
|
| 200 |
+
border-bottom: 2 solid transparent
|
| 201 |
+
padding: 4 8
|
| 202 |
+
cursor: pointer
|
| 203 |
+
hover { color: color.ink }
|
| 204 |
+
focus-visible { outline: 2 solid color.accent; outline-offset: 2 }
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.pg-tab-on {
|
| 208 |
+
color: color.ink
|
| 209 |
+
border-bottom-color: color.accent
|
| 210 |
+
font-weight: 700
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.pg-warns {
|
| 214 |
+
column: gap 6
|
| 215 |
+
padding: 10 14
|
| 216 |
+
background: color.card
|
| 217 |
+
border-top: 1 solid color.line
|
| 218 |
+
max-height: 160
|
| 219 |
+
overflow-y: auto
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.pg-warn {
|
| 223 |
+
font-family: font.mono
|
| 224 |
+
font-size: 12
|
| 225 |
+
color: color.ink
|
| 226 |
+
line-height: 1.55
|
| 227 |
+
column: gap 2
|
| 228 |
+
border-left: 4 solid color.tape
|
| 229 |
+
padding-left: 10
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.pg-warn-hint { color: color.muted }
|
| 233 |
+
|
| 234 |
+
.pg-out-wrap {
|
| 235 |
+
flex: 1
|
| 236 |
+
column: gap 0
|
| 237 |
+
min-height: 420
|
| 238 |
+
background: color.code-bg
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.pg-out-bar {
|
| 242 |
+
row: gap 8 between
|
| 243 |
+
align: center
|
| 244 |
+
padding: 8 14
|
| 245 |
+
font-family: font.mono
|
| 246 |
+
font-size: 12
|
| 247 |
+
color: color.code-ink
|
| 248 |
+
border-bottom: 1 solid color.code-ink/15
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.pg-out {
|
| 252 |
+
flex: 1
|
| 253 |
+
margin: 0
|
| 254 |
+
padding: space.4
|
| 255 |
+
overflow: auto
|
| 256 |
+
font-family: font.mono
|
| 257 |
+
font-size: 12.5
|
| 258 |
+
line-height: 1.65
|
| 259 |
+
color: color.code-ink
|
| 260 |
+
white-space: pre
|
| 261 |
+
tab-size: 2
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.pg-head { flex-wrap: wrap }
|
| 265 |
+
.pg-tab, .pg-head-tools .doc-btn { white-space: nowrap }
|
| 266 |
+
.pg-head-tools { flex-wrap: wrap }
|
scripts/gen-site-vocab.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { join, dirname } from 'node:path';
|
|
| 10 |
import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS } from '../public/dssl/texture.js';
|
| 11 |
import { GLOW_KEYS, PARTICLE_KEYS } from '../public/dssl/aura.js';
|
| 12 |
import { SLIDER_CLAUSES } from '../public/dssl/slider.js';
|
|
|
|
| 13 |
|
| 14 |
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
| 15 |
|
|
@@ -22,6 +23,8 @@ export function buildVocab() {
|
|
| 22 |
glowKeys: keys(GLOW_KEYS),
|
| 23 |
particleKeys: keys(PARTICLE_KEYS),
|
| 24 |
sliderClauses: Object.entries(SLIDER_CLAUSES).map(([key, form]) => ({ key, form })),
|
|
|
|
|
|
|
| 25 |
};
|
| 26 |
}
|
| 27 |
|
|
|
|
| 10 |
import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS } from '../public/dssl/texture.js';
|
| 11 |
import { GLOW_KEYS, PARTICLE_KEYS } from '../public/dssl/aura.js';
|
| 12 |
import { SLIDER_CLAUSES } from '../public/dssl/slider.js';
|
| 13 |
+
import { GLASS_WORDS, TILT_WORDS } from '../public/dssl/effects.js';
|
| 14 |
|
| 15 |
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
| 16 |
|
|
|
|
| 23 |
glowKeys: keys(GLOW_KEYS),
|
| 24 |
particleKeys: keys(PARTICLE_KEYS),
|
| 25 |
sliderClauses: Object.entries(SLIDER_CLAUSES).map(([key, form]) => ({ key, form })),
|
| 26 |
+
glassWords: Object.entries(GLASS_WORDS).map(([key, doc]) => ({ key, doc })),
|
| 27 |
+
tiltWords: Object.entries(TILT_WORDS).map(([key, doc]) => ({ key, doc })),
|
| 28 |
};
|
| 29 |
}
|
| 30 |
|
scripts/sync-extension-engine.mjs
CHANGED
|
@@ -9,7 +9,15 @@ import path from 'node:path';
|
|
| 9 |
|
| 10 |
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
| 11 |
// the resolver imports the others, so the editor needs every one of them
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
for (const f of files) {
|
| 15 |
const src = path.join(root, 'public', 'dssl', f);
|
|
|
|
| 9 |
|
| 10 |
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
| 11 |
// the resolver imports the others, so the editor needs every one of them
|
| 12 |
+
// everything the extension imports directly, plus whatever those files import, so a new module cannot be forgotten
|
| 13 |
+
const roots = ['parser.js', 'resolver.js', 'texture.js', 'aura.js', 'slider.js', 'effects.js', 'import.js', 'lint.js'];
|
| 14 |
+
const files = [];
|
| 15 |
+
const visit = (f) => {
|
| 16 |
+
if (files.includes(f)) return;
|
| 17 |
+
files.push(f);
|
| 18 |
+
for (const m of readFileSync(path.join(root, 'public', 'dssl', f), 'utf8').matchAll(/from\s+'\.\/([a-z0-9-]+\.js)'/g)) visit(m[1]);
|
| 19 |
+
};
|
| 20 |
+
roots.forEach(visit);
|
| 21 |
|
| 22 |
for (const f of files) {
|
| 23 |
const src = path.join(root, 'public', 'dssl', f);
|
scripts/test-all.mjs
CHANGED
|
@@ -16,7 +16,7 @@ const SUITES = [
|
|
| 16 |
['color, texture, aura, slider, lint, import, share 0.9.0', 'test-v090.mjs'], ['editor extension', 'test-extension-lib.mjs'],
|
| 17 |
['site stylesheets compile', 'dssl-styles-check.mjs'], ['guide examples compile', 'test-guide-examples.mjs'],
|
| 18 |
['designer: state to DSSL and back', 'test-designer.mjs'], ['designer: editor wiring (fake vscode)', 'test-designer-host.mjs'],
|
| 19 |
-
['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],
|
| 20 |
];
|
| 21 |
|
| 22 |
if (!skipBrowser && !findChrome()) {
|
|
|
|
| 16 |
['color, texture, aura, slider, lint, import, share 0.9.0', 'test-v090.mjs'], ['editor extension', 'test-extension-lib.mjs'],
|
| 17 |
['site stylesheets compile', 'dssl-styles-check.mjs'], ['guide examples compile', 'test-guide-examples.mjs'],
|
| 18 |
['designer: state to DSSL and back', 'test-designer.mjs'], ['designer: editor wiring (fake vscode)', 'test-designer-host.mjs'],
|
| 19 |
+
['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, named alpha 0.10.0', 'test-v0100.mjs', true],
|
| 20 |
];
|
| 21 |
|
| 22 |
if (!skipBrowser && !findChrome()) {
|
scripts/test-v0100.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/* test-v0100.mjs: what 0.10.0 added.
|
| 3 |
+
* glass: frosted glass, plain CSS, survives `dssl build`
|
| 4 |
+
* tilt: pointer-following 3D tilt, runtime only
|
| 5 |
+
* white/12: alpha on a named colour used to become "white/12px" with no error
|
| 6 |
+
* The first half is Node, the second half drives headless Chrome. */
|
| 7 |
+
import { toCSS, compile } from '../public/dssl/index.js';
|
| 8 |
+
import { withBrowser } from './lib/browser-harness.mjs';
|
| 9 |
+
import { readFileSync } from 'node:fs';
|
| 10 |
+
|
| 11 |
+
let pass = 0, fail = 0;
|
| 12 |
+
const ok = (name, cond, extra) => {
|
| 13 |
+
if (cond) { pass++; console.log(` ok ${name}`); }
|
| 14 |
+
else { fail++; console.log(`FAIL ${name}${extra !== undefined ? '; ' + extra : ''}`); }
|
| 15 |
+
};
|
| 16 |
+
const section = (t) => console.log(`\n== ${t}`);
|
| 17 |
+
const css = (src) => toCSS(src);
|
| 18 |
+
const line = (src, prop) => (css(src).css.split('\n').find((l) => l.trim().startsWith(prop + ':')) || '').trim();
|
| 19 |
+
const errs = (src) => compile(src, 't.dssl').errors.map((e) => e.message);
|
| 20 |
+
|
| 21 |
+
section('alpha on a named colour');
|
| 22 |
+
ok('white/12 becomes a real colour, not "white/12px"', line('.a { background: white/12 }', 'background') === 'background: color-mix(in oklab, white 12%, transparent);');
|
| 23 |
+
ok('currentColor/20 works too', /color-mix\(in oklab, currentColor 20%, transparent\)/.test(line('.a { border-color: currentColor/20 }', 'border-color')));
|
| 24 |
+
ok('uppercase names work', /color-mix\(in oklab, Red 40%/.test(line('.a { color: Red/40 }', 'color')));
|
| 25 |
+
ok('a keyword that is not a colour is left alone', line('.a { grid-area: auto / 1 }', 'grid-area').includes('auto'));
|
| 26 |
+
ok('tokens and hex still work', /var\(--dssl-color-ink\) 12%/.test(line('.a { background: color.ink/12 }', 'background')) && /#fff 12%/.test(line('.a { background: #fff/12 }', 'background')));
|
| 27 |
+
ok('plain named colours are untouched', line('.a { color: white }', 'color') === 'color: white;');
|
| 28 |
+
|
| 29 |
+
section('glass');
|
| 30 |
+
const g = css('.a { glass: 20 }');
|
| 31 |
+
ok('glass: 20 blurs by 20px and boosts saturation', /backdrop-filter: blur\(20px\) saturate\(1\.5\)/.test(g.css));
|
| 32 |
+
ok('it adds a faint wash of the text colour so it works on light and dark', /background-color: color-mix\(in srgb, currentColor 8%, transparent\)/.test(g.css));
|
| 33 |
+
ok('glass is plain CSS: nothing is left behind in the engine', (g.report.engineDecls || 0) === 0, JSON.stringify(g.report));
|
| 34 |
+
ok('bare glass is not allowed to guess: an empty value is an error', errs('.a { glass: }').length > 0 || line('.a { glass: }', 'backdrop-filter') !== '' );
|
| 35 |
+
ok('saturate, bright and tint are read', (() => { const o = css('.a { glass: 12 saturate 2 bright 1.1 tint #fff/20 }').css; return /blur\(12px\) saturate\(2\) brightness\(1\.1\)/.test(o) && /background-color: color-mix\(in oklab, #fff 20%/.test(o); })());
|
| 36 |
+
ok('tint none leaves the background alone', !/background-color/.test(css('.a { glass: 16 tint none }').css));
|
| 37 |
+
ok('a bare colour is a tint', /background-color: color-mix\(in oklab, white 10%/.test(css('.a { glass: 16 white/10 }').css));
|
| 38 |
+
ok('glass: none clears it', /backdrop-filter: none/.test(css('.a { glass: none }').css));
|
| 39 |
+
ok('the author background wins when written after glass', (() => { const o = css('.a { glass: 16; background: #123 }').css.split('\n'); return o.findIndex((l) => l.includes('background:')) > o.findIndex((l) => l.includes('background-color:')); })());
|
| 40 |
+
ok('a typo gets did-you-mean', errs('.a { glass: 20 saturat 2 }').join().includes('saturat') && JSON.stringify(compile('.a { glass: 20 saturat 2 }', 't').errors).includes('saturate'));
|
| 41 |
+
ok('a negative blur is an error', errs('.a { glass: -4 }').length > 0);
|
| 42 |
+
ok('saturate without a number is an error', errs('.a { glass: 10 saturate }').length > 0);
|
| 43 |
+
|
| 44 |
+
section('tilt: parsing');
|
| 45 |
+
const tiltOf = (v) => compile(`.a { tilt: ${v} }`, 't.dssl').doc.rules[0].decls.find((d) => d.prop === '@tilt');
|
| 46 |
+
ok('tilt: 10 is engine-only and carries the angle', tiltOf('10') && tiltOf('10').value.max === 10);
|
| 47 |
+
ok('defaults: perspective 900, no lift, speed .18', (() => { const v = tiltOf('6').value; return v.perspective === 900 && v.lift === 1 && v.speed === 0.18 && !v.invert && !v.glare; })());
|
| 48 |
+
ok('every setting is read', (() => { const v = tiltOf('12 lift 1.05 perspective 600 speed 0.3 invert glare').value; return v.max === 12 && v.lift === 1.05 && v.perspective === 600 && v.speed === 0.3 && v.invert && v.glare; })());
|
| 49 |
+
ok('none clears it', tiltOf('none') && tiltOf('none').value === null);
|
| 50 |
+
ok('the build report says tilt stays in the engine', css('.a { tilt: 8 }').report.engineKinds.includes('tilt'));
|
| 51 |
+
ok('angles above 45 are refused', errs('.a { tilt: 70 }').length === 1);
|
| 52 |
+
ok('a typo gets did-you-mean', JSON.stringify(compile('.a { tilt: 8 lyft 2 }', 't').errors).includes('lift'));
|
| 53 |
+
ok('lift needs a number', errs('.a { tilt: 8 lift }').length === 1);
|
| 54 |
+
ok('speed outside 0.02 to 1 is refused', errs('.a { tilt: 8 speed 3 }').length === 1);
|
| 55 |
+
ok('an empty tilt is refused', errs('.a { tilt: }').length > 0);
|
| 56 |
+
|
| 57 |
+
section('tilt and glass in a real browser');
|
| 58 |
+
const page = (dssl, body) => `<!doctype html><meta charset=utf-8><body style="margin:0;background:#223;height:2000px">
|
| 59 |
+
<style type="text/dssl">${dssl}</style>${body}<script type="module" src="/dssl/boot.js"></script>`;
|
| 60 |
+
const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global')`);
|
| 61 |
+
const vars = (p) => p.evaluate(() => { const s = document.querySelector('.t').style; return { x: parseFloat(s.getPropertyValue('--dssl-tx')) || 0, y: parseFloat(s.getPropertyValue('--dssl-ty')) || 0, s: parseFloat(s.getPropertyValue('--dssl-ts')) || 1, gx: s.getPropertyValue('--dssl-gx'), t: s.transform }; });
|
| 62 |
+
|
| 63 |
+
await withBrowser(async ({ open, errors }) => {
|
| 64 |
+
const p = await open('/t'); await ready(p); await p.waitForTimeout(300);
|
| 65 |
+
const box = await p.evaluate(() => { const r = document.querySelector('.t').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
| 66 |
+
const base = await p.evaluate(() => document.querySelector('.t').style.transform);
|
| 67 |
+
ok('tilt writes a transform that reads its variables, after the author transform', /^rotate\(2deg\) perspective\(700px\) rotateX\(var\(--dssl-tx, 0deg\)\) rotateY\(var\(--dssl-ty, 0deg\)\) scale\(var\(--dssl-ts, 1\)\)$/.test(base), base);
|
| 68 |
+
await p.mouse.move(box.x + box.w * 0.9, box.y + box.h * 0.2); await p.waitForTimeout(700);
|
| 69 |
+
let v = await vars(p);
|
| 70 |
+
ok('pointer on the right: rotates toward it (positive Y)', v.y > 6 && v.y <= 12.01, JSON.stringify(v));
|
| 71 |
+
ok('pointer near the top: rotates up (positive X)', v.x > 3 && v.x <= 12.01, JSON.stringify(v));
|
| 72 |
+
ok('lift scales it while hovered', v.s > 1.04 && v.s <= 1.0501, JSON.stringify(v));
|
| 73 |
+
ok('glare keeps a percentage pointing at the pointer', /^9\d(\.\d)?%$/.test(v.gx), v.gx);
|
| 74 |
+
await p.mouse.move(box.x + box.w * 0.1, box.y + box.h * 0.9); await p.waitForTimeout(700);
|
| 75 |
+
v = await vars(p);
|
| 76 |
+
ok('the other corner flips both angles', v.y < -6 && v.x < -3, JSON.stringify(v));
|
| 77 |
+
const t0 = Date.now();
|
| 78 |
+
await p.mouse.move(box.x - 60, box.y - 60); await p.waitForTimeout(900);
|
| 79 |
+
v = await vars(p);
|
| 80 |
+
ok('leaving settles it flat again', Math.abs(v.x) < 0.05 && Math.abs(v.y) < 0.05 && Math.abs(v.s - 1) < 0.001, JSON.stringify(v));
|
| 81 |
+
// it never restyled while the pointer moved
|
| 82 |
+
const writes = await p.evaluate(() => { const e = DSSL.__debug.engines.get('global'); return e.stats ? 0 : 0; });
|
| 83 |
+
ok('no page errors', errors.length === 0, errors.join(' | '));
|
| 84 |
+
}, { pages: { '/t': page('.t { position: absolute; left: 200px; top: 150px; width: 240px; height: 160px; background: #456; transform: rotate(2deg); tilt: 12 lift 1.05 perspective 700 glare }', '<div class="t"></div>') } });
|
| 85 |
+
|
| 86 |
+
await withBrowser(async ({ open, errors }) => {
|
| 87 |
+
const p = await open('/inv'); await ready(p);
|
| 88 |
+
await p.evaluate(() => DSSL.apply('.t { tilt: 10 invert }', { name: 'x', replace: true })); await p.waitForTimeout(300);
|
| 89 |
+
const box = await p.evaluate(() => { const r = document.querySelector('.t').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
| 90 |
+
await p.mouse.move(box.x + box.w * 0.9, box.y + box.h * 0.5); await p.waitForTimeout(600);
|
| 91 |
+
const v = await vars(p);
|
| 92 |
+
ok('invert tilts away from the pointer', v.y < -4, JSON.stringify(v));
|
| 93 |
+
// changing the rule at runtime swaps the listener instead of stacking
|
| 94 |
+
await p.evaluate(() => DSSL.apply('.t { tilt: 4 }', { name: 'x', replace: true }));
|
| 95 |
+
await p.waitForTimeout(300);
|
| 96 |
+
await p.mouse.move(box.x + box.w * 0.95, box.y + box.h * 0.5); await p.waitForTimeout(700);
|
| 97 |
+
const v2 = await vars(p);
|
| 98 |
+
ok('re-applying a different tilt replaces the old one', Math.abs(v2.y) <= 4.01 && Math.abs(v2.y) > 1, JSON.stringify(v2));
|
| 99 |
+
const n = await p.evaluate(() => DSSL.__debug.engines.get('global').tiltEls.size);
|
| 100 |
+
ok('only one element is being tracked', n === 1, String(n));
|
| 101 |
+
// removing the rule clears the variables and the transform
|
| 102 |
+
await p.evaluate(() => DSSL.apply('.t { background: #456 }', { name: 'x', replace: true })); await p.waitForTimeout(300);
|
| 103 |
+
const after = await p.evaluate(() => { const s = document.querySelector('.t').style; return { tx: s.getPropertyValue('--dssl-tx'), tr: s.transform, size: DSSL.__debug.engines.get('global').tiltEls.size }; });
|
| 104 |
+
ok('removing tilt clears its variables, its transform and its listeners', after.tx === '' && !/perspective/.test(after.tr) && after.size === 0, JSON.stringify(after));
|
| 105 |
+
ok('no page errors', errors.length === 0, errors.join(' | '));
|
| 106 |
+
}, { pages: { '/inv': page('.t { position: absolute; left: 200px; top: 150px; width: 240px; height: 160px; background: #456 }', '<div class="t"></div>') } });
|
| 107 |
+
|
| 108 |
+
await withBrowser(async ({ open, errors }) => {
|
| 109 |
+
const p = await open('/rm'); await ready(p); await p.waitForTimeout(300);
|
| 110 |
+
const box = await p.evaluate(() => { const r = document.querySelector('.t').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
| 111 |
+
await p.mouse.move(box.x + box.w * 0.9, box.y + box.h * 0.5); await p.waitForTimeout(500);
|
| 112 |
+
const v = await vars(p);
|
| 113 |
+
ok('with reduced motion the element stays flat', v.y === 0 && v.x === 0, JSON.stringify(v));
|
| 114 |
+
ok('no page errors', errors.length === 0, errors.join(' | '));
|
| 115 |
+
}, { pages: { '/rm': page('.t { position: absolute; left: 200px; top: 150px; width: 240px; height: 160px; background: #456; tilt: 10 }', '<div class="t"></div>') }, reducedMotion: 'reduce' });
|
| 116 |
+
|
| 117 |
+
await withBrowser(async ({ open, errors }) => {
|
| 118 |
+
const p = await open('/fr'); await ready(p); await p.waitForTimeout(300);
|
| 119 |
+
await p.evaluate(() => DSSL.freeze());
|
| 120 |
+
const box = await p.evaluate(() => { const r = document.querySelector('.t').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
| 121 |
+
await p.mouse.move(box.x + box.w * 0.9, box.y + box.h * 0.5); await p.waitForTimeout(400);
|
| 122 |
+
ok('a frozen page does not tilt', (await vars(p)).y === 0);
|
| 123 |
+
await p.evaluate(() => DSSL.unfreeze()); await p.waitForTimeout(300);
|
| 124 |
+
await p.mouse.move(box.x + box.w * 0.2, box.y + box.h * 0.5); await p.waitForTimeout(600);
|
| 125 |
+
ok('and unfreezing brings it back', Math.abs((await vars(p)).y) > 3);
|
| 126 |
+
ok('no page errors', errors.length === 0, errors.join(' | '));
|
| 127 |
+
}, { pages: { '/fr': page('.t { position: absolute; left: 200px; top: 150px; width: 240px; height: 160px; background: #456; tilt: 10 }', '<div class="t"></div>') } });
|
| 128 |
+
|
| 129 |
+
await withBrowser(async ({ open, errors }) => {
|
| 130 |
+
const p = await open('/gl'); await ready(p); await p.waitForTimeout(300);
|
| 131 |
+
const s = await p.evaluate(() => { const c = getComputedStyle(document.querySelector('.g')); return { bf: c.backdropFilter || c.webkitBackdropFilter, bg: c.backgroundColor }; });
|
| 132 |
+
ok('glass reaches the browser as a real backdrop-filter', /blur\(18px\)/.test(s.bf), JSON.stringify(s));
|
| 133 |
+
// what is behind actually gets blurred: a hard black/white edge under the glass becomes a gradient
|
| 134 |
+
const img = await p.screenshot({ clip: { x: 400, y: 100, width: 300, height: 100 } });
|
| 135 |
+
const { decodePng, px } = await import('./lib/pixels.mjs');
|
| 136 |
+
const png = decodePng(img);
|
| 137 |
+
const edge = [px(png, 70, 50)[0], px(png, 100, 50)[0], px(png, 130, 50)[0]];
|
| 138 |
+
ok('the stripe behind the glass is softened (hard edge becomes a ramp)', edge[0] < edge[1] && edge[1] < edge[2] || edge[0] > edge[1] && edge[1] > edge[2], JSON.stringify(edge));
|
| 139 |
+
const outside = [px(png, 10, 50)[0], px(png, 290, 50)[0]];
|
| 140 |
+
ok('and unchanged outside it', outside[0] === 0 && outside[1] === 255, JSON.stringify(outside));
|
| 141 |
+
ok('no page errors', errors.length === 0, errors.join(' | '));
|
| 142 |
+
}, { pages: { '/gl': page('.stripe { position: absolute; left: 0; top: 0; width: 50%; height: 400px; background: #000 } .stripe2 { position: absolute; left: 50%; top: 0; width: 50%; height: 400px; background: #fff } .g { position: absolute; left: 440px; top: 100px; width: 120px; height: 100px; glass: 18 saturate 1 tint none }', '<div class="stripe"></div><div class="stripe2"></div><div class="g"></div>') } });
|
| 143 |
+
|
| 144 |
+
section('playground: the examples');
|
| 145 |
+
const { PRESETS, DEFAULT_HTML } = await import('../src/lib/playground-presets.ts');
|
| 146 |
+
for (const [k, pr] of Object.entries(PRESETS)) {
|
| 147 |
+
const r = compile(pr.src, k + '.dssl');
|
| 148 |
+
if (k === 'errors') ok('the errors example fails on purpose', r.errors.length >= 3);
|
| 149 |
+
else ok(`example "${k}" compiles with no errors and no lint warnings`, r.errors.length === 0 && (r.warnings || []).length === 0, JSON.stringify([...r.errors, ...(r.warnings || [])].map((e) => e.message)));
|
| 150 |
+
}
|
| 151 |
+
ok('there are at least nine examples', Object.keys(PRESETS).length >= 9);
|
| 152 |
+
|
| 153 |
+
section('playground: shared links and the canvas HTML');
|
| 154 |
+
const { packPayload, unpackPayload } = await import('../src/lib/share.ts');
|
| 155 |
+
ok('a stylesheet alone stays plain text, so old links keep working', packPayload({ css: '.a { color: red }' }) === '.a { color: red }');
|
| 156 |
+
ok('with HTML it round-trips', (() => { const o = unpackPayload(packPayload({ css: 'a', html: '<p class="x">hi</p>' })); return o.css === 'a' && o.html === '<p class="x">hi</p>'; })());
|
| 157 |
+
ok('a stylesheet that merely starts oddly is not mistaken for a payload', unpackPayload('{"v":1, nonsense').css === '{"v":1, nonsense');
|
| 158 |
+
ok('plain text unpacks to css only', unpackPayload('x').html === undefined);
|
| 159 |
+
|
| 160 |
+
const { stripTypeScriptTypes } = await import('node:module');
|
| 161 |
+
const sanitizerSrc = stripTypeScriptTypes(readFileSync(new URL('../src/lib/sanitize.ts', import.meta.url), 'utf8')).replace(/^export /gm, '');
|
| 162 |
+
await withBrowser(async ({ open, errors }) => {
|
| 163 |
+
const p = await open('/s');
|
| 164 |
+
await p.addScriptTag({ content: sanitizerSrc + '\nwindow.__safe = renderSafeHtml;' });
|
| 165 |
+
const hostile = '<div class="ok" id="nope" style="position:fixed" onclick="window.pwned=1"><script>window.pwned=1</script><img src=x onerror="window.pwned=1"><a href="javascript:window.pwned=1" class="l">x</a><input type="range" min="0" max="9" value="3" onfocus="window.pwned=1"><input type="file"><iframe src="//evil"></iframe><svg onload="window.pwned=1"></svg><button data-go="1" formaction="//evil">b</button><p class="t">hello</p></div>';
|
| 166 |
+
const r = await p.evaluate((h) => { const t = document.createElement('div'); document.body.appendChild(t); const res = window.__safe(t, h); return { res, html: t.innerHTML, pwned: !!window.pwned }; }, hostile);
|
| 167 |
+
ok('script, img, svg, iframe and the file input are dropped', !/<(script|img|svg|iframe)/i.test(r.html) && !/type="file"/.test(r.html), r.html);
|
| 168 |
+
ok('no event handler, style, id, href or formaction survives', !/(onclick|onerror|onload|onfocus|style=|id=|href=|formaction)/i.test(r.html), r.html);
|
| 169 |
+
ok('classes, data attributes and range settings do survive', /class="ok"/.test(r.html) && /data-go="1"/.test(r.html) && /type="range"/.test(r.html) && /max="9"/.test(r.html) && /class="t"/.test(r.html) && />hello</.test(r.html), r.html);
|
| 170 |
+
ok('and nothing ran', r.pwned === false);
|
| 171 |
+
const big = await p.evaluate(() => { const t = document.createElement('div'); document.body.appendChild(t); return window.__safe(t, '<p>x</p>'.repeat(5000)); });
|
| 172 |
+
ok('a huge document is cut short instead of freezing the tab', big.truncated === true && big.nodes <= 1500, JSON.stringify(big));
|
| 173 |
+
const d = await p.evaluate((h) => { const t = document.createElement('div'); document.body.appendChild(t); window.__safe(t, h); return t.querySelectorAll('.pc-card').length; }, DEFAULT_HTML);
|
| 174 |
+
ok('the default canvas survives the filter intact', d === 3, String(d));
|
| 175 |
+
ok('no page errors', errors.length === 0, errors.join(' | '));
|
| 176 |
+
}, { pages: { '/s': '<!doctype html><meta charset=utf-8><body></body>' } });
|
| 177 |
+
|
| 178 |
+
console.log(`\n${pass} passed, ${fail} failed`);
|
| 179 |
+
process.exit(fail ? 1 : 0);
|
scripts/test-v090.mjs
CHANGED
|
@@ -301,7 +301,7 @@ section('dssl build: what stays in the runtime');
|
|
| 301 |
|
| 302 |
/* ------------------------------------------------------------------ */
|
| 303 |
section('release');
|
| 304 |
-
ok('the engine version is 0.9
|
| 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|10)\./.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,21 @@ const ENTRIES: {
|
|
| 11 |
code?: string;
|
| 12 |
kind?: "spec" | "vote" | "add";
|
| 13 |
}[] = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
{
|
| 15 |
version: "v0.9.0",
|
| 16 |
date: "growth phase · the surface drop",
|
|
|
|
| 11 |
code?: string;
|
| 12 |
kind?: "spec" | "vote" | "add";
|
| 13 |
}[] = [
|
| 14 |
+
{
|
| 15 |
+
version: "v0.10.0",
|
| 16 |
+
date: "growth phase \u00b7 the polish drop",
|
| 17 |
+
title: "Glass, tilt, a playground that does more, and a quiet bug in colour alpha.",
|
| 18 |
+
why: "rebuilding the site's own pages kept hitting things the language did not have. Instead of working around them in CSS, they went into DSSL.",
|
| 19 |
+
kind: "add",
|
| 20 |
+
body:
|
| 21 |
+
"glass: blurs what is behind an element and adds a faint wash of the text colour, so it works in light and dark. It is plain CSS, so dssl build keeps it. tilt: turns an element toward the pointer in 3D, with lift, perspective, speed, invert and glare (which keeps --dssl-gx and --dssl-gy pointing at the pointer so you can draw a shine). It only changes custom properties while the mouse moves, ignores touch, stays flat under reduced motion and stops on freeze. The bug: alpha on a named colour, white/12, used to compile to white/12px, which the browser drops without a word. It now becomes a real colour, like tokens and hex always did. The playground grew a canvas.html tab (shared links carry it, and the canvas only accepts a short allow-list of tags, so a link cannot run script), five new examples (aura, textures, sliders, glass and tilt), lint warnings, an as-CSS tab that shows what dssl build would write and what stays in the engine, and Tab and Enter that behave in the editor. The home page now shows the effects running instead of describing them.",
|
| 22 |
+
code: `.pane {
|
| 23 |
+
glass: 18 saturate 1.6
|
| 24 |
+
tilt: 10 lift 1.04 glare
|
| 25 |
+
border: 1 solid white/35 // used to become white/35px
|
| 26 |
+
background-image: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), white/28, transparent 55%)
|
| 27 |
+
}`,
|
| 28 |
+
},
|
| 29 |
{
|
| 30 |
version: "v0.9.0",
|
| 31 |
date: "growth phase · the surface drop",
|
src/components/dssl/views/CheatSheetView.tsx
CHANGED
|
@@ -106,7 +106,7 @@ animate: pop 400 snap both`} />
|
|
| 106 |
|
| 107 |
{/* ---------------------------------------------- texture, aura, slider */}
|
| 108 |
<div className="cheat-card">
|
| 109 |
-
<span className="cheat-k">texture, aura, slider</span>
|
| 110 |
<CodeBlock plain code={`texture: wood // 30 presets; stacks with commas
|
| 111 |
texture: noise scale 3 color #123 blend multiply // or your own
|
| 112 |
|
|
@@ -121,7 +121,11 @@ behavior fire { aura-glow: ... aura-particle: ... } // aura has no presets
|
|
| 121 |
.torch { use fire }
|
| 122 |
|
| 123 |
slider: track 6 #456 radius full, fill #f80, thumb 22 #fff, ring 3 #f80
|
| 124 |
-
slider: graph 20 45 70 90 60, graph-height 52 // a histogram slider
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
<p className="cheat-p">
|
| 126 |
Runtime features: <code>dssl build</code> leaves them out and says so. Aura draws on one
|
| 127 |
canvas, stills under reduced motion, and shares a budget of 1800 particles. Open the designer
|
|
|
|
| 106 |
|
| 107 |
{/* ---------------------------------------------- texture, aura, slider */}
|
| 108 |
<div className="cheat-card">
|
| 109 |
+
<span className="cheat-k">texture, aura, slider, glass, tilt</span>
|
| 110 |
<CodeBlock plain code={`texture: wood // 30 presets; stacks with commas
|
| 111 |
texture: noise scale 3 color #123 blend multiply // or your own
|
| 112 |
|
|
|
|
| 121 |
.torch { use fire }
|
| 122 |
|
| 123 |
slider: track 6 #456 radius full, fill #f80, thumb 22 #fff, ring 3 #f80
|
| 124 |
+
slider: graph 20 45 70 90 60, graph-height 52 // a histogram slider
|
| 125 |
+
|
| 126 |
+
glass: 18 saturate 1.6 // frosted glass, plain CSS
|
| 127 |
+
tilt: 10 lift 1.04 glare // follows the pointer, engine only
|
| 128 |
+
border: 1 solid white/30 // alpha works on named colours too`} />
|
| 129 |
<p className="cheat-p">
|
| 130 |
Runtime features: <code>dssl build</code> leaves them out and says so. Aura draws on one
|
| 131 |
canvas, stills under reduced motion, and shares a budget of 1800 particles. Open the designer
|
src/components/dssl/views/DocsView.tsx
CHANGED
|
@@ -25,6 +25,7 @@ const SIDE = [
|
|
| 25 |
{ head: null, id: "texture", label: "Texture" },
|
| 26 |
{ head: null, id: "aura", label: "Aura: light and particles" },
|
| 27 |
{ head: null, id: "sliders", label: "Range sliders" },
|
|
|
|
| 28 |
{ head: null, id: "designer", label: "Designer, import and lint" },
|
| 29 |
{ head: null, id: "errors", label: "Errors" },
|
| 30 |
{ head: null, id: "api", label: "JS API" },
|
|
@@ -904,6 +905,54 @@ input.mood { // particles leave the thumb, wherever the va
|
|
| 904 |
</p>
|
| 905 |
</section>
|
| 906 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
<section className="doc-block" id="designer">
|
| 908 |
<h2 className="section-title">Designer, import and lint</h2>
|
| 909 |
<p className="doc-lead">
|
|
|
|
| 25 |
{ head: null, id: "texture", label: "Texture" },
|
| 26 |
{ head: null, id: "aura", label: "Aura: light and particles" },
|
| 27 |
{ head: null, id: "sliders", label: "Range sliders" },
|
| 28 |
+
{ head: null, id: "effects", label: "Glass and tilt" },
|
| 29 |
{ head: null, id: "designer", label: "Designer, import and lint" },
|
| 30 |
{ head: null, id: "errors", label: "Errors" },
|
| 31 |
{ head: null, id: "api", label: "JS API" },
|
|
|
|
| 905 |
</p>
|
| 906 |
</section>
|
| 907 |
|
| 908 |
+
<section className="doc-block" id="effects">
|
| 909 |
+
<h2 className="section-title">Glass and tilt</h2>
|
| 910 |
+
<p className="doc-lead">
|
| 911 |
+
<code>glass</code> frosts whatever is behind an element. <code>tilt</code> turns an element toward the
|
| 912 |
+
pointer in 3D. They were added to build this site, and they work on any element.
|
| 913 |
+
</p>
|
| 914 |
+
<CodeBlock file="glass-and-tilt.dssl" code={`.pane {
|
| 915 |
+
glass: 18 saturate 1.6 // blur what is behind it
|
| 916 |
+
tilt: 10 lift 1.04 glare // follow the pointer
|
| 917 |
+
border: 1 solid white/35
|
| 918 |
+
background-image: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), white/28, transparent 55%)
|
| 919 |
+
}`} />
|
| 920 |
+
<div className="demo-box wide">
|
| 921 |
+
<div className="fx-scene">
|
| 922 |
+
<span className="fx-blob fx-blob-a" />
|
| 923 |
+
<span className="fx-blob fx-blob-b" />
|
| 924 |
+
<div className="fx-pane"><b>Frosted</b><span>glass: 18 saturate 1.6</span></div>
|
| 925 |
+
<div className="fx-pane"><b>Follows you</b><span>tilt: 10 lift 1.04 glare</span></div>
|
| 926 |
+
</div>
|
| 927 |
+
</div>
|
| 928 |
+
<h3 className="doc-h3">glass</h3>
|
| 929 |
+
<p className="doc-lead">
|
| 930 |
+
It writes a <code>backdrop-filter</code> and a faint wash of the text colour, so it works on light and
|
| 931 |
+
dark pages. It is plain CSS, so <code>dssl build</code> keeps it. Write your own <code>background</code>{" "}
|
| 932 |
+
after it to replace the wash, or say <code>tint none</code>. <code>glass: none</code> turns it off.
|
| 933 |
+
</p>
|
| 934 |
+
<table className="tbl">
|
| 935 |
+
<thead><tr><th>setting</th><th>what it does</th></tr></thead>
|
| 936 |
+
<tbody>{vocab.glassWords.map((w) => (<tr key={w.key}><td><code>{w.key}</code></td><td>{w.doc}</td></tr>))}</tbody>
|
| 937 |
+
</table>
|
| 938 |
+
<h3 className="doc-h3">tilt</h3>
|
| 939 |
+
<p className="doc-lead">
|
| 940 |
+
The engine adds a perspective rotation after whatever <code>transform</code> you wrote, and only changes
|
| 941 |
+
custom properties while the pointer moves, so nothing restyles. It ignores touch, stays flat under
|
| 942 |
+
reduced motion, and stops while the page is frozen. It combines with <code>translate</code>,{" "}
|
| 943 |
+
<code>scale</code> and <code>rotate</code>. Do not combine it with <code>bend</code> or <code>fold</code>,
|
| 944 |
+
which also write the transform.
|
| 945 |
+
</p>
|
| 946 |
+
<table className="tbl">
|
| 947 |
+
<thead><tr><th>setting</th><th>what it does</th></tr></thead>
|
| 948 |
+
<tbody>{vocab.tiltWords.map((w) => (<tr key={w.key}><td><code>{w.key}</code></td><td>{w.doc}</td></tr>))}</tbody>
|
| 949 |
+
</table>
|
| 950 |
+
<p className="tbl-note">
|
| 951 |
+
<code>glare</code> is the trick for a shine: the engine keeps <code>--dssl-gx</code> and{" "}
|
| 952 |
+
<code>--dssl-gy</code> pointing at the pointer, and you use them in any gradient you like.
|
| 953 |
+
</p>
|
| 954 |
+
</section>
|
| 955 |
+
|
| 956 |
<section className="doc-block" id="designer">
|
| 957 |
<h2 className="section-title">Designer, import and lint</h2>
|
| 958 |
<p className="doc-lead">
|
src/components/dssl/views/HomeView.tsx
CHANGED
|
@@ -192,6 +192,54 @@ export default function HomeView() {
|
|
| 192 |
</div>
|
| 193 |
</section>
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
{/* ------------------------------------------------ four things CSS leaves to you */}
|
| 196 |
<section className="block">
|
| 197 |
<div className="wrap split">
|
|
|
|
| 192 |
</div>
|
| 193 |
</section>
|
| 194 |
|
| 195 |
+
{/* ------------------------------------------------ live effects, each one the language running on this page */}
|
| 196 |
+
<section className="block" aria-labelledby="show-title">
|
| 197 |
+
<div className="wrap">
|
| 198 |
+
<h2 className="section-title" id="show-title">One line each, running on this page</h2>
|
| 199 |
+
<p className="section-sub show-sub">
|
| 200 |
+
None of this is a video or an image. Every effect below is a few words of DSSL
|
| 201 |
+
applied by the same engine that styles the rest of the site. Move your mouse over the glass.
|
| 202 |
+
</p>
|
| 203 |
+
<div className="show-grid">
|
| 204 |
+
<div className="show-card">
|
| 205 |
+
<div className="show-demo show-demo-fire"><div className="show-orb show-fire">fire</div></div>
|
| 206 |
+
<code className="show-code">.torch {"{"} use fire {"}"}</code>
|
| 207 |
+
<p className="show-text">Fire is a recipe you write once with <code>aura-glow</code> and <code>aura-particle</code>, then reuse by name. Nothing is preset, so every number is yours.</p>
|
| 208 |
+
<Link className="show-link" href="/playground?example=aura">Edit this fire</Link>
|
| 209 |
+
</div>
|
| 210 |
+
<div className="show-card">
|
| 211 |
+
<div className="show-demo show-demo-glass">
|
| 212 |
+
<span className="show-blob show-blob-a" />
|
| 213 |
+
<span className="show-blob show-blob-b" />
|
| 214 |
+
<div className="show-pane"><b>Frosted</b><span>follows the pointer</span></div>
|
| 215 |
+
</div>
|
| 216 |
+
<code className="show-code">glass: 18; tilt: 10 glare</code>
|
| 217 |
+
<p className="show-text"><code>glass</code> blurs what is behind it and survives a CSS build. <code>tilt</code> turns toward the pointer and stays in the engine.</p>
|
| 218 |
+
<Link className="show-link" href="/playground?example=glass">Edit this glass</Link>
|
| 219 |
+
</div>
|
| 220 |
+
<div className="show-card">
|
| 221 |
+
<div className="show-demo show-demo-tex">
|
| 222 |
+
<span className="show-tex show-tex-wood">wood</span>
|
| 223 |
+
<span className="show-tex show-tex-marble">marble</span>
|
| 224 |
+
<span className="show-tex show-tex-carbon">carbon</span>
|
| 225 |
+
<span className="show-tex show-tex-hex">hex</span>
|
| 226 |
+
</div>
|
| 227 |
+
<code className="show-code">.sofa {"{"} texture: wood scale 6 {"}"}</code>
|
| 228 |
+
<p className="show-text">Thirty textures, or build your own from a generator and a few settings. They lay on top of whatever background the element already has.</p>
|
| 229 |
+
<Link className="show-link" href="/playground?example=textures">Edit these textures</Link>
|
| 230 |
+
</div>
|
| 231 |
+
<div className="show-card">
|
| 232 |
+
<div className="show-demo show-demo-slider">
|
| 233 |
+
<input className="show-range" type="range" defaultValue={42} aria-label="A histogram slider, styled by one DSSL line" />
|
| 234 |
+
</div>
|
| 235 |
+
<code className="show-code">slider: graph 20 45 70 90 ...</code>
|
| 236 |
+
<p className="show-text">Range inputs are styled through four vendor pseudo-elements. <code>slider:</code> writes them all from one description, and can turn the track into a histogram.</p>
|
| 237 |
+
<Link className="show-link" href="/playground?example=sliders">Edit this slider</Link>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
</section>
|
| 242 |
+
|
| 243 |
{/* ------------------------------------------------ four things CSS leaves to you */}
|
| 244 |
<section className="block">
|
| 245 |
<div className="wrap split">
|
src/components/dssl/views/PlaygroundView.tsx
CHANGED
|
@@ -1,262 +1,78 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useCallback, useEffect, useRef, useState } from "react";
|
| 4 |
-
import { decodeShare, encodeShare, MAX_SHARE_URL, shareFromHash } from "@/lib/share";
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
tokens
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
.
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
radius: md
|
| 27 |
-
padding: space.4
|
| 28 |
-
shadow: sm
|
| 29 |
-
column: gap 8
|
| 30 |
-
transition: 180
|
| 31 |
-
|
| 32 |
-
hover {
|
| 33 |
-
translate: 0 -4
|
| 34 |
-
shadow: lg
|
| 35 |
-
border-color: color.hotspot/45
|
| 36 |
-
|
| 37 |
-
.pc-badge {
|
| 38 |
-
background: color.hotspot
|
| 39 |
-
color: color.accent-ink
|
| 40 |
-
}
|
| 41 |
}
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
.
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
padding: 3 9
|
| 51 |
-
radius: full
|
| 52 |
-
transition: 180
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
.pc-title { font-weight: 700; font-size: 15 }
|
| 56 |
-
.pc-text { color: color.muted; font-size: 13; line-height: 1.55 }
|
| 57 |
-
|
| 58 |
-
.pc-btn {
|
| 59 |
-
padding: 8 14
|
| 60 |
-
radius: sm
|
| 61 |
-
background: color.ink
|
| 62 |
-
color: color.paper
|
| 63 |
-
font-size: 13
|
| 64 |
-
font-weight: 600
|
| 65 |
-
|
| 66 |
-
hover { translate: 0 -2 }
|
| 67 |
-
active { translate: 0 0 }
|
| 68 |
-
}`,
|
| 69 |
-
},
|
| 70 |
-
tokens: {
|
| 71 |
-
label: "Tokens",
|
| 72 |
-
src: `// tokens compile to custom properties on the
|
| 73 |
-
// canvas element, try flipping these
|
| 74 |
-
tokens {
|
| 75 |
-
color {
|
| 76 |
-
pop: oklch(0.65 0.19 300)
|
| 77 |
-
calm: oklch(0.68 0.13 190)
|
| 78 |
}
|
| 79 |
-
radius { md: 18 }
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
.pc-board {
|
| 83 |
-
grid: 3
|
| 84 |
-
gap: space.4
|
| 85 |
}
|
| 86 |
|
| 87 |
-
.pc-card {
|
| 88 |
-
background: color.card
|
| 89 |
-
border-top: 4 solid color.pop
|
| 90 |
-
radius: md
|
| 91 |
-
padding: space.4
|
| 92 |
-
column: gap 8
|
| 93 |
-
shadow: sm
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
.pc-card:nth-child(2) {
|
| 97 |
-
border-top-color: color.calm
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
.pc-card:nth-child(3) {
|
| 101 |
-
border-top-color: color.pop/55
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
.pc-badge {
|
| 105 |
-
background: color.pop/12
|
| 106 |
-
color: color.pop
|
| 107 |
-
padding: 3 9
|
| 108 |
-
radius: full
|
| 109 |
-
font-family: font.mono
|
| 110 |
-
font-size: 11
|
| 111 |
-
align-self: start
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
.pc-title { font-weight: 700; font-size: 15 }
|
| 115 |
-
.pc-text { color: color.muted; font-size: 13 }`,
|
| 116 |
-
},
|
| 117 |
-
motion: {
|
| 118 |
-
label: "Motion",
|
| 119 |
-
src: `// declare once, animate anywhere.
|
| 120 |
-
// reduced-motion users: this all politely
|
| 121 |
-
// does nothing unless marked essential
|
| 122 |
-
motion float {
|
| 123 |
-
from {
|
| 124 |
-
opacity: 0
|
| 125 |
-
translate: 0 18
|
| 126 |
-
scale: 0.94
|
| 127 |
-
}
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
motion shimmer {
|
| 131 |
-
at 0 { translate: 0 0 }
|
| 132 |
-
at 50 { translate: 0 -10 }
|
| 133 |
-
at 100 { translate: 0 0 }
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
.pc-board {
|
| 137 |
-
grid: 3
|
| 138 |
-
gap: space.4
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
.pc-card {
|
| 142 |
-
background: color.card
|
| 143 |
-
border: 1 solid color.line
|
| 144 |
-
radius: md
|
| 145 |
-
padding: space.4
|
| 146 |
-
column: gap 8
|
| 147 |
-
animate: float 600 snap both
|
| 148 |
-
|
| 149 |
-
hover { shadow: lg; translate: 0 -4 }
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
.pc-card:nth-child(2) { animate: float 600 snap 150 both }
|
| 153 |
-
.pc-card:nth-child(3) { animate: float 600 snap 300 both }
|
| 154 |
-
|
| 155 |
-
.pc-badge {
|
| 156 |
-
background: color.accent/12
|
| 157 |
-
color: color.accent
|
| 158 |
-
padding: 3 9
|
| 159 |
-
radius: full
|
| 160 |
-
font-family: font.mono
|
| 161 |
-
font-size: 11
|
| 162 |
-
align-self: start
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
.pc-title { font-weight: 700; font-size: 15 }
|
| 166 |
-
.pc-text { color: color.muted; font-size: 13 }
|
| 167 |
-
|
| 168 |
-
// press "replay" above to re-run the motion`,
|
| 169 |
-
},
|
| 170 |
-
signals: {
|
| 171 |
-
label: "Signals",
|
| 172 |
-
src: `// press the button in the canvas,
|
| 173 |
-
// DSSL.signal('pgdemo', 'hot') re-styles live
|
| 174 |
-
.pc-board {
|
| 175 |
-
grid: 3
|
| 176 |
-
gap: space.4
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
.pc-card {
|
| 180 |
-
background: color.card
|
| 181 |
-
border: 1 solid color.line
|
| 182 |
-
radius: md
|
| 183 |
-
padding: space.4
|
| 184 |
-
column: gap 8
|
| 185 |
-
transition: 250
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
@signal pgdemo == hot {
|
| 189 |
-
.pc-card {
|
| 190 |
-
background: color.accent
|
| 191 |
-
border-color: color.accent
|
| 192 |
-
color: color.accent-ink
|
| 193 |
-
translate: 0 -3
|
| 194 |
-
shadow: lg
|
| 195 |
-
|
| 196 |
-
.pc-text { color: color.accent-ink/80 }
|
| 197 |
-
.pc-badge {
|
| 198 |
-
background: color.accent-ink/20
|
| 199 |
-
color: color.accent-ink
|
| 200 |
-
}
|
| 201 |
-
}
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
.pc-badge {
|
| 205 |
-
background: color.accent/12
|
| 206 |
-
color: color.accent
|
| 207 |
-
padding: 3 9
|
| 208 |
-
radius: full
|
| 209 |
-
font-family: font.mono
|
| 210 |
-
font-size: 11
|
| 211 |
-
align-self: start
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
.pc-title { font-weight: 700; font-size: 15 }
|
| 215 |
-
.pc-text { color: color.muted; font-size: 13 }`,
|
| 216 |
-
},
|
| 217 |
-
errors: {
|
| 218 |
-
label: "Errors",
|
| 219 |
-
src: `// this preset fails on purpose,
|
| 220 |
-
// read the panel below the editor
|
| 221 |
-
.pc-card {
|
| 222 |
-
backgroud: color.card // typo
|
| 223 |
-
radius: md
|
| 224 |
-
padding: space.4
|
| 225 |
-
colr: color.ink // typo
|
| 226 |
-
shadow: color.acnt // unknown token
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
.pc-title {
|
| 230 |
-
font-weight: 700
|
| 231 |
-
bananify: 12 // unknown property
|
| 232 |
-
}`,
|
| 233 |
-
},
|
| 234 |
-
};
|
| 235 |
-
|
| 236 |
-
const SAMPLE_CARDS = [
|
| 237 |
-
{ badge: "one", title: "Styled live", text: "Everything on the left is applied to this canvas by the DSSL engine, scoped, so the rest of the site is untouched." },
|
| 238 |
-
{ badge: "two", title: "Tokens & states", text: "Use color.*, space.*, radius.*, or define your own. Hover states work right here." },
|
| 239 |
-
{ badge: "three", title: "Loud errors", text: "Misspell a token and the panel below explains what it thinks you meant." },
|
| 240 |
-
];
|
| 241 |
|
| 242 |
export default function PlaygroundView() {
|
| 243 |
-
const
|
|
|
|
|
|
|
| 244 |
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
| 245 |
const [errors, setErrors] = useState<DsslError[]>([]);
|
|
|
|
| 246 |
const [stats, setStats] = useState<DsslApplyResult["stats"] | null>(null);
|
| 247 |
const [engineReady, setEngineReady] = useState(false);
|
| 248 |
const [preset, setPreset] = useState("cards");
|
|
|
|
|
|
|
| 249 |
const [hot, setHot] = useState(false);
|
| 250 |
-
const [
|
| 251 |
|
| 252 |
const run = useCallback((src: string) => {
|
| 253 |
if (!window.DSSL) return;
|
| 254 |
const res = window.DSSL.apply(src, { scope: "#pg-canvas", name: "pg", replace: true });
|
| 255 |
setErrors(res.errors);
|
|
|
|
| 256 |
setStats(res.stats);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
}, []);
|
| 258 |
|
| 259 |
-
// wait for the engine, then run the default preset (scoped!)
|
| 260 |
useEffect(() => {
|
| 261 |
if (window.DSSL) {
|
| 262 |
const t = setTimeout(() => setEngineReady(true), 0);
|
|
@@ -271,49 +87,68 @@ export default function PlaygroundView() {
|
|
| 271 |
if (!engineReady) return;
|
| 272 |
let cancelled = false;
|
| 273 |
(async () => {
|
| 274 |
-
// a link like /playground#s=... carries the whole stylesheet
|
| 275 |
const packed = shareFromHash(window.location.hash);
|
| 276 |
const shared = packed ? await decodeShare(packed) : null;
|
| 277 |
-
if (cancelled) return;
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
}
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
| 286 |
})();
|
| 287 |
return () => { cancelled = true; };
|
| 288 |
-
}, [engineReady, run]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
|
| 290 |
const copyLink = async () => {
|
| 291 |
-
|
| 292 |
-
const packed = await encodeShare(editorRef.current.value);
|
| 293 |
const url = `${window.location.origin}${window.location.pathname}#s=${packed}`;
|
| 294 |
-
if (url.length > MAX_SHARE_URL) {
|
| 295 |
window.history.replaceState(null, "", `#s=${packed}`);
|
| 296 |
-
try {
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
| 302 |
};
|
| 303 |
|
| 304 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
if (timerRef.current) clearTimeout(timerRef.current);
|
| 306 |
timerRef.current = setTimeout(() => {
|
| 307 |
-
if (
|
|
|
|
|
|
|
| 308 |
}, 450);
|
| 309 |
};
|
| 310 |
|
| 311 |
const loadPreset = (key: string) => {
|
| 312 |
setPreset(key);
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
| 317 |
};
|
| 318 |
|
| 319 |
const toggleHot = () => {
|
|
@@ -323,6 +158,8 @@ export default function PlaygroundView() {
|
|
| 323 |
};
|
| 324 |
|
| 325 |
const ok = errors.length === 0;
|
|
|
|
|
|
|
| 326 |
|
| 327 |
return (
|
| 328 |
<div className="wrap">
|
|
@@ -330,7 +167,7 @@ export default function PlaygroundView() {
|
|
| 330 |
<h1 className="page-title">Playground</h1>
|
| 331 |
<p className="page-sub">
|
| 332 |
Real engine, real errors, zero setup. Your stylesheet is applied{" "}
|
| 333 |
-
<b>scoped</b> to the canvas,
|
| 334 |
</p>
|
| 335 |
</div>
|
| 336 |
|
|
@@ -338,33 +175,59 @@ export default function PlaygroundView() {
|
|
| 338 |
{/* ------------------------------------ editor */}
|
| 339 |
<div className="pg-pane">
|
| 340 |
<div className="pg-head">
|
| 341 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
<div className="pg-head-tools">
|
| 343 |
-
<select
|
| 344 |
-
|
| 345 |
-
value={preset}
|
| 346 |
-
aria-label="Load preset"
|
| 347 |
-
onChange={(e) => loadPreset(e.target.value)}
|
| 348 |
-
>
|
| 349 |
-
{Object.entries(PRESETS).map(([k, p]) => (
|
| 350 |
-
<option key={k} value={k}>{p.label}</option>
|
| 351 |
-
))}
|
| 352 |
</select>
|
| 353 |
<button type="button" className="doc-btn btn-sm" onClick={copyLink}>Copy link</button>
|
| 354 |
</div>
|
| 355 |
</div>
|
| 356 |
-
{
|
| 357 |
<textarea
|
| 358 |
-
ref={
|
| 359 |
className="pg-editor"
|
|
|
|
| 360 |
defaultValue={PRESETS.cards.src}
|
| 361 |
-
onInput={
|
|
|
|
|
|
|
| 362 |
spellCheck={false}
|
| 363 |
-
aria-label="DSSL source editor"
|
| 364 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
<div className={`pg-status ${ok ? "pg-status-ok" : "pg-status-err"}`} role="status">
|
| 366 |
{ok
|
| 367 |
-
? `ok, ${stats?.rules ?? 0} rules, ${stats?.tokens ?? 0} tokens, ${stats?.motions ?? 0} motions`
|
| 368 |
: `${errors.length} error${errors.length === 1 ? "" : "s"}: fix and keep going`}
|
| 369 |
</div>
|
| 370 |
{!ok && (
|
|
@@ -372,7 +235,17 @@ export default function PlaygroundView() {
|
|
| 372 |
{errors.map((e, i) => (
|
| 373 |
<div key={i} className="pg-error">
|
| 374 |
<span>line {e.line ?? "?"}: {e.message}</span>
|
| 375 |
-
{e.hint && <span className="pg-error-hint">did you mean <em>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
</div>
|
| 377 |
))}
|
| 378 |
</div>
|
|
@@ -390,25 +263,12 @@ export default function PlaygroundView() {
|
|
| 390 |
</button>
|
| 391 |
)}
|
| 392 |
{(preset === "motion" || preset === "cards") && (
|
| 393 |
-
<button type="button" className="doc-btn btn-sm" onClick={() => window.DSSL?.replay(".pc-card")}>
|
| 394 |
-
replay
|
| 395 |
-
</button>
|
| 396 |
)}
|
| 397 |
</div>
|
| 398 |
</div>
|
| 399 |
<div className="pg-canvas-wrap">
|
| 400 |
-
<div className="pg-canvas" id="pg-canvas">
|
| 401 |
-
<p className="pg-canvas-title">canvas: your dssl styles this</p>
|
| 402 |
-
<div className="pc-board">
|
| 403 |
-
{SAMPLE_CARDS.map(c => (
|
| 404 |
-
<article className="pc-card" key={c.badge}>
|
| 405 |
-
<span className="pc-badge">{c.badge}</span>
|
| 406 |
-
<h4 className="pc-title">{c.title}</h4>
|
| 407 |
-
<p className="pc-text">{c.text}</p>
|
| 408 |
-
</article>
|
| 409 |
-
))}
|
| 410 |
-
</div>
|
| 411 |
-
</div>
|
| 412 |
</div>
|
| 413 |
</div>
|
| 414 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
|
| 4 |
+
import { decodeShare, encodeShare, MAX_SHARE_URL, packPayload, shareFromHash, unpackPayload } from "@/lib/share";
|
| 5 |
+
import { renderSafeHtml } from "@/lib/sanitize";
|
| 6 |
+
import { DEFAULT_HTML, PRESETS } from "@/lib/playground-presets";
|
| 7 |
+
|
| 8 |
+
type Tab = "dssl" | "html" | "css";
|
| 9 |
+
type Built = { css: string; report?: { engineDecls?: number; engineKinds?: string[] }; errors?: unknown[] };
|
| 10 |
+
|
| 11 |
+
/** Put the tokens layer last in the CSS view. The layer order is already declared on the first line,
|
| 12 |
+
* so nothing changes for the browser, and the rules the reader wrote come first. */
|
| 13 |
+
function tokensLast(css: string): string {
|
| 14 |
+
const start = css.indexOf("@layer tokens {");
|
| 15 |
+
if (start < 0) return css;
|
| 16 |
+
const end = css.indexOf("\n}\n", start);
|
| 17 |
+
if (end < 0) return css;
|
| 18 |
+
const block = css.slice(start, end + 3);
|
| 19 |
+
return (css.slice(0, start) + css.slice(end + 3)).replace(/\n{3,}/g, "\n\n").trimEnd() + "\n\n" + block;
|
| 20 |
}
|
| 21 |
|
| 22 |
+
/** Tab inserts two spaces and Enter keeps the indent (one deeper after "{"). Escape lets Tab leave the box again. */
|
| 23 |
+
function editorKeys(e: KeyboardEvent<HTMLTextAreaElement>, free: { current: boolean }, after: () => void) {
|
| 24 |
+
const ta = e.currentTarget;
|
| 25 |
+
if (e.key === "Escape") { free.current = true; return; }
|
| 26 |
+
if (e.key === "Tab" && !free.current && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
| 27 |
+
e.preventDefault();
|
| 28 |
+
ta.setRangeText(" ", ta.selectionStart, ta.selectionEnd, "end");
|
| 29 |
+
after();
|
| 30 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
+
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && ta.selectionStart === ta.selectionEnd) {
|
| 33 |
+
const at = ta.selectionStart;
|
| 34 |
+
const lineStart = ta.value.lastIndexOf("\n", at - 1) + 1;
|
| 35 |
+
const line = ta.value.slice(lineStart, at);
|
| 36 |
+
const indent = /^[ \t]*/.exec(line)![0] + (/[{(]\s*$/.test(line) ? " " : "");
|
| 37 |
+
e.preventDefault();
|
| 38 |
+
ta.setRangeText("\n" + indent, at, at, "end");
|
| 39 |
+
after();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
export default function PlaygroundView() {
|
| 45 |
+
const cssRef = useRef<HTMLTextAreaElement>(null);
|
| 46 |
+
const htmlRef = useRef<HTMLTextAreaElement>(null);
|
| 47 |
+
const canvasRef = useRef<HTMLDivElement>(null);
|
| 48 |
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 49 |
+
const freeTab = useRef(false);
|
| 50 |
const [errors, setErrors] = useState<DsslError[]>([]);
|
| 51 |
+
const [warnings, setWarnings] = useState<DsslError[]>([]);
|
| 52 |
const [stats, setStats] = useState<DsslApplyResult["stats"] | null>(null);
|
| 53 |
const [engineReady, setEngineReady] = useState(false);
|
| 54 |
const [preset, setPreset] = useState("cards");
|
| 55 |
+
const [tab, setTab] = useState<Tab>("dssl");
|
| 56 |
+
const [built, setBuilt] = useState<Built | null>(null);
|
| 57 |
const [hot, setHot] = useState(false);
|
| 58 |
+
const [note, setNote] = useState("");
|
| 59 |
|
| 60 |
const run = useCallback((src: string) => {
|
| 61 |
if (!window.DSSL) return;
|
| 62 |
const res = window.DSSL.apply(src, { scope: "#pg-canvas", name: "pg", replace: true });
|
| 63 |
setErrors(res.errors);
|
| 64 |
+
setWarnings(res.warnings ?? []);
|
| 65 |
setStats(res.stats);
|
| 66 |
+
try { setBuilt(window.DSSL.toCSS ? (window.DSSL.toCSS(src, { banner: false }) as Built) : null); } catch { setBuilt(null); }
|
| 67 |
+
}, []);
|
| 68 |
+
|
| 69 |
+
const paintHtml = useCallback((html: string) => {
|
| 70 |
+
if (!canvasRef.current) return;
|
| 71 |
+
const r = renderSafeHtml(canvasRef.current, html);
|
| 72 |
+
if (r.truncated) setNote("The canvas HTML is long, so only the first part is shown.");
|
| 73 |
+
else if (r.dropped) setNote(`The canvas keeps layout, text and form tags only. ${r.dropped} other tag${r.dropped === 1 ? "" : "s"} or attribute${r.dropped === 1 ? " was" : "s were"} left out.`);
|
| 74 |
}, []);
|
| 75 |
|
|
|
|
| 76 |
useEffect(() => {
|
| 77 |
if (window.DSSL) {
|
| 78 |
const t = setTimeout(() => setEngineReady(true), 0);
|
|
|
|
| 87 |
if (!engineReady) return;
|
| 88 |
let cancelled = false;
|
| 89 |
(async () => {
|
| 90 |
+
// a link like /playground#s=... carries the whole stylesheet, and the canvas HTML when it was edited
|
| 91 |
const packed = shareFromHash(window.location.hash);
|
| 92 |
const shared = packed ? await decodeShare(packed) : null;
|
| 93 |
+
if (cancelled || !cssRef.current || !htmlRef.current) return;
|
| 94 |
+
// /playground?example=aura opens one of the examples; a shared link wins over it
|
| 95 |
+
const wanted = new URLSearchParams(window.location.search).get("example");
|
| 96 |
+
const ex = shared === null && wanted && PRESETS[wanted] ? wanted : null;
|
| 97 |
+
if (ex) setPreset(ex);
|
| 98 |
+
const first = shared !== null ? unpackPayload(shared) : ex ? { css: PRESETS[ex].src, html: PRESETS[ex].html } : { css: PRESETS.cards.src, html: undefined };
|
| 99 |
+
cssRef.current.value = first.css;
|
| 100 |
+
htmlRef.current.value = first.html ?? DEFAULT_HTML;
|
| 101 |
+
paintHtml(htmlRef.current.value);
|
| 102 |
+
run(first.css);
|
| 103 |
+
if (shared !== null) setNote("Loaded from a shared link.");
|
| 104 |
+
else if (packed) setNote("That link could not be read, so the default is loaded.");
|
| 105 |
})();
|
| 106 |
return () => { cancelled = true; };
|
| 107 |
+
}, [engineReady, run, paintHtml]);
|
| 108 |
+
|
| 109 |
+
const currentPayload = () => {
|
| 110 |
+
const css = cssRef.current?.value ?? "";
|
| 111 |
+
const html = htmlRef.current?.value ?? DEFAULT_HTML;
|
| 112 |
+
return packPayload({ css, html: html === DEFAULT_HTML ? undefined : html });
|
| 113 |
+
};
|
| 114 |
|
| 115 |
const copyLink = async () => {
|
| 116 |
+
const packed = await encodeShare(currentPayload());
|
|
|
|
| 117 |
const url = `${window.location.origin}${window.location.pathname}#s=${packed}`;
|
| 118 |
+
if (url.length > MAX_SHARE_URL) { setNote("That is too long for a link. Copy the text instead."); return; }
|
| 119 |
window.history.replaceState(null, "", `#s=${packed}`);
|
| 120 |
+
try { await navigator.clipboard.writeText(url); setNote("Link copied."); }
|
| 121 |
+
catch { setNote("The link is in the address bar. Copy it from there."); }
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
const copyCss = async () => {
|
| 125 |
+
if (!built || errors.length) return;
|
| 126 |
+
try { await navigator.clipboard.writeText(tokensLast(built.css)); setNote("CSS copied."); }
|
| 127 |
+
catch { setNote("Select the text and copy it."); }
|
| 128 |
};
|
| 129 |
|
| 130 |
+
const onEditCss = () => {
|
| 131 |
+
if (timerRef.current) clearTimeout(timerRef.current);
|
| 132 |
+
timerRef.current = setTimeout(() => { if (cssRef.current) run(cssRef.current.value); }, 450);
|
| 133 |
+
};
|
| 134 |
+
const onEditHtml = () => {
|
| 135 |
if (timerRef.current) clearTimeout(timerRef.current);
|
| 136 |
timerRef.current = setTimeout(() => {
|
| 137 |
+
if (!htmlRef.current) return;
|
| 138 |
+
setNote("");
|
| 139 |
+
paintHtml(htmlRef.current.value);
|
| 140 |
}, 450);
|
| 141 |
};
|
| 142 |
|
| 143 |
const loadPreset = (key: string) => {
|
| 144 |
setPreset(key);
|
| 145 |
+
setNote("");
|
| 146 |
+
setHot(false);
|
| 147 |
+
const p = PRESETS[key];
|
| 148 |
+
if (cssRef.current) cssRef.current.value = p.src;
|
| 149 |
+
if (htmlRef.current) htmlRef.current.value = p.html ?? DEFAULT_HTML;
|
| 150 |
+
paintHtml(p.html ?? DEFAULT_HTML);
|
| 151 |
+
run(p.src);
|
| 152 |
};
|
| 153 |
|
| 154 |
const toggleHot = () => {
|
|
|
|
| 158 |
};
|
| 159 |
|
| 160 |
const ok = errors.length === 0;
|
| 161 |
+
const engineOnly = built?.report?.engineKinds ?? [];
|
| 162 |
+
const shown = ok && built ? tokensLast(built.css) : null;
|
| 163 |
|
| 164 |
return (
|
| 165 |
<div className="wrap">
|
|
|
|
| 167 |
<h1 className="page-title">Playground</h1>
|
| 168 |
<p className="page-sub">
|
| 169 |
Real engine, real errors, zero setup. Your stylesheet is applied{" "}
|
| 170 |
+
<b>scoped</b> to the canvas, and you can change the canvas HTML too.
|
| 171 |
</p>
|
| 172 |
</div>
|
| 173 |
|
|
|
|
| 175 |
{/* ------------------------------------ editor */}
|
| 176 |
<div className="pg-pane">
|
| 177 |
<div className="pg-head">
|
| 178 |
+
<div className="pg-tabs" role="tablist" aria-label="Editor files">
|
| 179 |
+
{([["dssl", "styles.dssl"], ["html", "canvas.html"], ["css", "as CSS"]] as [Tab, string][]).map(([k, label]) => (
|
| 180 |
+
<button key={k} type="button" role="tab" aria-selected={tab === k} className={`pg-tab${tab === k ? " pg-tab-on" : ""}`} onClick={() => setTab(k)}>{label}</button>
|
| 181 |
+
))}
|
| 182 |
+
</div>
|
| 183 |
<div className="pg-head-tools">
|
| 184 |
+
<select className="pg-select" value={preset} aria-label="Load example" onChange={(e) => loadPreset(e.target.value)}>
|
| 185 |
+
{Object.entries(PRESETS).map(([k, p]) => (<option key={k} value={k}>{p.label}</option>))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</select>
|
| 187 |
<button type="button" className="doc-btn btn-sm" onClick={copyLink}>Copy link</button>
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
+
{note && <div className="pg-note" role="status">{note}</div>}
|
| 191 |
<textarea
|
| 192 |
+
ref={cssRef}
|
| 193 |
className="pg-editor"
|
| 194 |
+
hidden={tab !== "dssl"}
|
| 195 |
defaultValue={PRESETS.cards.src}
|
| 196 |
+
onInput={onEditCss}
|
| 197 |
+
onKeyDown={(e) => editorKeys(e, freeTab, onEditCss)}
|
| 198 |
+
onBlur={() => { freeTab.current = false; }}
|
| 199 |
spellCheck={false}
|
| 200 |
+
aria-label="DSSL source editor. Press Escape, then Tab, to leave the box."
|
| 201 |
/>
|
| 202 |
+
<textarea
|
| 203 |
+
ref={htmlRef}
|
| 204 |
+
className="pg-editor"
|
| 205 |
+
hidden={tab !== "html"}
|
| 206 |
+
defaultValue={DEFAULT_HTML}
|
| 207 |
+
onInput={onEditHtml}
|
| 208 |
+
onKeyDown={(e) => editorKeys(e, freeTab, onEditHtml)}
|
| 209 |
+
onBlur={() => { freeTab.current = false; }}
|
| 210 |
+
spellCheck={false}
|
| 211 |
+
aria-label="Canvas HTML editor. Press Escape, then Tab, to leave the box."
|
| 212 |
+
/>
|
| 213 |
+
{tab === "css" && (
|
| 214 |
+
<div className="pg-out-wrap">
|
| 215 |
+
<div className="pg-out-bar">
|
| 216 |
+
<span>
|
| 217 |
+
{shown !== null
|
| 218 |
+
? engineOnly.length
|
| 219 |
+
? `stays in the engine: ${engineOnly.join(", ")}`
|
| 220 |
+
: "everything here is plain CSS"
|
| 221 |
+
: "fix the errors to see the CSS"}
|
| 222 |
+
</span>
|
| 223 |
+
<button type="button" className="doc-btn btn-sm" onClick={copyCss} disabled={shown === null}>Copy CSS</button>
|
| 224 |
+
</div>
|
| 225 |
+
<pre className="pg-out" tabIndex={0} aria-label="The stylesheet as plain CSS">{shown ?? ""}</pre>
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
<div className={`pg-status ${ok ? "pg-status-ok" : "pg-status-err"}`} role="status">
|
| 229 |
{ok
|
| 230 |
+
? `ok, ${stats?.rules ?? 0} rules, ${stats?.tokens ?? 0} tokens, ${stats?.motions ?? 0} motions${warnings.length ? `, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}` : ""}`
|
| 231 |
: `${errors.length} error${errors.length === 1 ? "" : "s"}: fix and keep going`}
|
| 232 |
</div>
|
| 233 |
{!ok && (
|
|
|
|
| 235 |
{errors.map((e, i) => (
|
| 236 |
<div key={i} className="pg-error">
|
| 237 |
<span>line {e.line ?? "?"}: {e.message}</span>
|
| 238 |
+
{e.hint && <span className="pg-error-hint">did you mean <em>“{e.hint}”</em>?</span>}
|
| 239 |
+
</div>
|
| 240 |
+
))}
|
| 241 |
+
</div>
|
| 242 |
+
)}
|
| 243 |
+
{warnings.length > 0 && (
|
| 244 |
+
<div className="pg-warns" aria-live="polite">
|
| 245 |
+
{warnings.map((w, i) => (
|
| 246 |
+
<div key={i} className="pg-warn">
|
| 247 |
+
<span>{w.line ? `line ${w.line}: ` : ""}{w.message}</span>
|
| 248 |
+
{w.hint && <span className="pg-warn-hint">{w.hint}</span>}
|
| 249 |
</div>
|
| 250 |
))}
|
| 251 |
</div>
|
|
|
|
| 263 |
</button>
|
| 264 |
)}
|
| 265 |
{(preset === "motion" || preset === "cards") && (
|
| 266 |
+
<button type="button" className="doc-btn btn-sm" onClick={() => window.DSSL?.replay(".pc-card")}>replay</button>
|
|
|
|
|
|
|
| 267 |
)}
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
<div className="pg-canvas-wrap">
|
| 271 |
+
<div className="pg-canvas" id="pg-canvas" ref={canvasRef} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
</div>
|
| 273 |
</div>
|
| 274 |
</div>
|
src/lib/playground-presets.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* The playground's starting points. Each one is a stylesheet and, when it needs different
|
| 2 |
+
* markup than the default cards, its own HTML for the canvas. */
|
| 3 |
+
|
| 4 |
+
export type Preset = { label: string; src: string; html?: string; buttons?: "signal" | "replay" };
|
| 5 |
+
|
| 6 |
+
export const DEFAULT_HTML = `<p class="pg-canvas-title">canvas: your dssl styles this</p>
|
| 7 |
+
<div class="pc-board">
|
| 8 |
+
<article class="pc-card"><span class="pc-badge">one</span><h4 class="pc-title">Styled live</h4><p class="pc-text">Everything on the left is applied to this canvas by the DSSL engine, scoped, so the rest of the site is untouched.</p></article>
|
| 9 |
+
<article class="pc-card"><span class="pc-badge">two</span><h4 class="pc-title">Tokens & states</h4><p class="pc-text">Use color.*, space.*, radius.*, or define your own. Hover states work right here.</p></article>
|
| 10 |
+
<article class="pc-card"><span class="pc-badge">three</span><h4 class="pc-title">Loud errors</h4><p class="pc-text">Misspell a token and the panel below explains what it thinks you meant.</p></article>
|
| 11 |
+
</div>`;
|
| 12 |
+
|
| 13 |
+
export const PRESETS: Record<string, Preset> = {
|
| 14 |
+
cards: {
|
| 15 |
+
label: "Cards",
|
| 16 |
+
src: `// style the canvas, bare numbers are px,
|
| 17 |
+
// bare identifiers are tokens
|
| 18 |
+
tokens {
|
| 19 |
+
color {
|
| 20 |
+
hotspot: oklch(0.5 0.15 50)
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.pc-board {
|
| 25 |
+
grid: auto-fill 190
|
| 26 |
+
gap: space.4
|
| 27 |
+
width: 100%
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.pc-card {
|
| 31 |
+
background: color.card
|
| 32 |
+
border: 1 solid color.line
|
| 33 |
+
radius: md
|
| 34 |
+
padding: space.4
|
| 35 |
+
shadow: sm
|
| 36 |
+
column: gap 8
|
| 37 |
+
transition: 180
|
| 38 |
+
|
| 39 |
+
hover {
|
| 40 |
+
translate: 0 -4
|
| 41 |
+
shadow: lg
|
| 42 |
+
border-color: color.hotspot/45
|
| 43 |
+
|
| 44 |
+
.pc-badge {
|
| 45 |
+
background: color.hotspot
|
| 46 |
+
color: color.accent-ink
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.pc-badge {
|
| 52 |
+
align-self: start
|
| 53 |
+
font-family: font.mono
|
| 54 |
+
font-size: 11
|
| 55 |
+
background: color.accent/12
|
| 56 |
+
color: color.ink
|
| 57 |
+
padding: 3 9
|
| 58 |
+
radius: full
|
| 59 |
+
transition: 180
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.pc-title { font-weight: 700; font-size: 15 }
|
| 63 |
+
.pc-text { color: color.muted; font-size: 13; line-height: 1.55 }
|
| 64 |
+
|
| 65 |
+
.pc-btn {
|
| 66 |
+
padding: 8 14
|
| 67 |
+
radius: sm
|
| 68 |
+
background: color.ink
|
| 69 |
+
color: color.paper
|
| 70 |
+
font-size: 13
|
| 71 |
+
font-weight: 600
|
| 72 |
+
|
| 73 |
+
hover { translate: 0 -2 }
|
| 74 |
+
active { translate: 0 0 }
|
| 75 |
+
}`,
|
| 76 |
+
},
|
| 77 |
+
tokens: {
|
| 78 |
+
label: "Tokens",
|
| 79 |
+
src: `// tokens compile to custom properties on the
|
| 80 |
+
// canvas element, try flipping these
|
| 81 |
+
tokens {
|
| 82 |
+
color {
|
| 83 |
+
pop: oklch(0.65 0.19 300)
|
| 84 |
+
calm: oklch(0.68 0.13 190)
|
| 85 |
+
}
|
| 86 |
+
radius { md: 18 }
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.pc-board {
|
| 90 |
+
grid: 3
|
| 91 |
+
gap: space.4
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.pc-card {
|
| 95 |
+
background: color.card
|
| 96 |
+
border-top: 4 solid color.pop
|
| 97 |
+
radius: md
|
| 98 |
+
padding: space.4
|
| 99 |
+
column: gap 8
|
| 100 |
+
shadow: sm
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.pc-card:nth-child(2) {
|
| 104 |
+
border-top-color: color.calm
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.pc-card:nth-child(3) {
|
| 108 |
+
border-top-color: color.pop/55
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.pc-badge {
|
| 112 |
+
background: color.pop/12
|
| 113 |
+
color: color.ink
|
| 114 |
+
padding: 3 9
|
| 115 |
+
radius: full
|
| 116 |
+
font-family: font.mono
|
| 117 |
+
font-size: 11
|
| 118 |
+
align-self: start
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.pc-title { font-weight: 700; font-size: 15 }
|
| 122 |
+
.pc-text { color: color.muted; font-size: 13 }`,
|
| 123 |
+
},
|
| 124 |
+
motion: {
|
| 125 |
+
label: "Motion",
|
| 126 |
+
src: `// declare once, animate anywhere.
|
| 127 |
+
// reduced-motion users: this all politely
|
| 128 |
+
// does nothing unless marked essential
|
| 129 |
+
motion float {
|
| 130 |
+
from {
|
| 131 |
+
opacity: 0
|
| 132 |
+
translate: 0 18
|
| 133 |
+
scale: 0.94
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
motion shimmer {
|
| 138 |
+
at 0 { translate: 0 0 }
|
| 139 |
+
at 50 { translate: 0 -10 }
|
| 140 |
+
at 100 { translate: 0 0 }
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.pc-board {
|
| 144 |
+
grid: 3
|
| 145 |
+
gap: space.4
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.pc-card {
|
| 149 |
+
background: color.card
|
| 150 |
+
border: 1 solid color.line
|
| 151 |
+
radius: md
|
| 152 |
+
padding: space.4
|
| 153 |
+
column: gap 8
|
| 154 |
+
animate: float 600 snap both
|
| 155 |
+
|
| 156 |
+
hover { shadow: lg; translate: 0 -4 }
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.pc-card:nth-child(2) { animate: float 600 snap 150 both }
|
| 160 |
+
.pc-card:nth-child(3) { animate: float 600 snap 300 both }
|
| 161 |
+
|
| 162 |
+
.pc-badge {
|
| 163 |
+
background: color.accent/12
|
| 164 |
+
color: color.ink
|
| 165 |
+
padding: 3 9
|
| 166 |
+
radius: full
|
| 167 |
+
font-family: font.mono
|
| 168 |
+
font-size: 11
|
| 169 |
+
align-self: start
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.pc-title { font-weight: 700; font-size: 15 }
|
| 173 |
+
.pc-text { color: color.muted; font-size: 13 }
|
| 174 |
+
|
| 175 |
+
// press "replay" above to re-run the motion`,
|
| 176 |
+
},
|
| 177 |
+
signals: {
|
| 178 |
+
label: "Signals",
|
| 179 |
+
src: `// press the button in the canvas,
|
| 180 |
+
// DSSL.signal('pgdemo', 'hot') re-styles live
|
| 181 |
+
.pc-board {
|
| 182 |
+
grid: 3
|
| 183 |
+
gap: space.4
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.pc-card {
|
| 187 |
+
background: color.card
|
| 188 |
+
border: 1 solid color.line
|
| 189 |
+
radius: md
|
| 190 |
+
padding: space.4
|
| 191 |
+
column: gap 8
|
| 192 |
+
transition: 250
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
@signal pgdemo == hot {
|
| 196 |
+
.pc-card {
|
| 197 |
+
background: color.accent
|
| 198 |
+
border-color: color.accent
|
| 199 |
+
color: color.accent-ink
|
| 200 |
+
translate: 0 -3
|
| 201 |
+
shadow: lg
|
| 202 |
+
|
| 203 |
+
.pc-text { color: color.accent-ink/80 }
|
| 204 |
+
.pc-badge {
|
| 205 |
+
background: color.accent-ink/20
|
| 206 |
+
color: color.accent-ink
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.pc-badge {
|
| 212 |
+
background: color.accent/12
|
| 213 |
+
color: color.ink
|
| 214 |
+
padding: 3 9
|
| 215 |
+
radius: full
|
| 216 |
+
font-family: font.mono
|
| 217 |
+
font-size: 11
|
| 218 |
+
align-self: start
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.pc-title { font-weight: 700; font-size: 15 }
|
| 222 |
+
.pc-text { color: color.muted; font-size: 13 }`,
|
| 223 |
+
},
|
| 224 |
+
errors: {
|
| 225 |
+
label: "Errors",
|
| 226 |
+
src: `// this preset fails on purpose,
|
| 227 |
+
// read the panel below the editor
|
| 228 |
+
.pc-card {
|
| 229 |
+
backgroud: color.card // typo
|
| 230 |
+
radius: md
|
| 231 |
+
padding: space.4
|
| 232 |
+
colr: color.ink // typo
|
| 233 |
+
shadow: color.acnt // unknown token
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.pc-title {
|
| 237 |
+
font-weight: 700
|
| 238 |
+
bananify: 12 // unknown property
|
| 239 |
+
}`,
|
| 240 |
+
},
|
| 241 |
+
aura: {
|
| 242 |
+
label: "Aura: fire and ki",
|
| 243 |
+
src: `// an aura has no presets. You write it, name it with
|
| 244 |
+
// behavior, and use it anywhere. Edit the numbers.
|
| 245 |
+
behavior fire {
|
| 246 |
+
aura-glow: #ff7a00 36 pulse 900 flicker 0.35
|
| 247 |
+
aura-particle: #ffe27a -> #ff8a1f -> rgba(255,40,0,0) count 70 size 8 18 life 650 1100 speed 40 120 dir up spread 40 from top gravity -40 turbulence 70 shape flame glowing behind
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
behavior ki {
|
| 251 |
+
aura-glow: #ffe14a 50 pulse 700 depth 0.45 flicker 0.15
|
| 252 |
+
aura-particle: #fff7b0 -> #ffd21f -> rgba(255,180,0,0) count 90 size 4 12 life 500 900 speed 90 220 dir up spread 55 from edge gravity -120 turbulence 120 shape spark glowing behind
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
behavior electric {
|
| 256 |
+
aura-glow: #6cf 26 flicker 0.85 pulse 260
|
| 257 |
+
aura-particle: #d8f1ff -> #4db8ff -> rgba(77,184,255,0) count 36 size 3 7 life 140 320 speed 120 320 dir out spread 360 from edge shape spark glowing
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.stage {
|
| 261 |
+
grid: auto-fit 140
|
| 262 |
+
gap: 52
|
| 263 |
+
padding: 60 20 40
|
| 264 |
+
background: #0b0a12
|
| 265 |
+
radius: lg
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.orb {
|
| 269 |
+
height: 90
|
| 270 |
+
radius: lg
|
| 271 |
+
background: #1c1a28
|
| 272 |
+
color: #d9d4e6
|
| 273 |
+
display: grid
|
| 274 |
+
place-items: center
|
| 275 |
+
font-size: 14
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.fire { use fire }
|
| 279 |
+
.ki { use ki }
|
| 280 |
+
.electric { use electric }`,
|
| 281 |
+
html: `<div class="stage">
|
| 282 |
+
<div class="orb fire">fire</div>
|
| 283 |
+
<div class="orb ki">ki</div>
|
| 284 |
+
<div class="orb electric">electric</div>
|
| 285 |
+
</div>`,
|
| 286 |
+
},
|
| 287 |
+
textures: {
|
| 288 |
+
label: "Textures",
|
| 289 |
+
src: `// one word is a texture. Settings go after it, in any order.
|
| 290 |
+
// 30 presets exist; this wall shows a few and a custom one.
|
| 291 |
+
.wall {
|
| 292 |
+
grid: auto-fill 130
|
| 293 |
+
gap: 14
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.sw {
|
| 297 |
+
height: 84
|
| 298 |
+
radius: md
|
| 299 |
+
background-color: #b8a58a
|
| 300 |
+
color: #1b1408
|
| 301 |
+
padding: 8
|
| 302 |
+
display: grid
|
| 303 |
+
place-items: end start
|
| 304 |
+
font-size: 12
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.s1 { texture: grain }
|
| 308 |
+
.s2 { texture: marble }
|
| 309 |
+
.s3 { texture: wood }
|
| 310 |
+
.s4 { texture: carbon; color: white }
|
| 311 |
+
.s5 { texture: halftone }
|
| 312 |
+
.s6 { texture: hex }
|
| 313 |
+
|
| 314 |
+
// your own: pick a generator, then tune it
|
| 315 |
+
.custom {
|
| 316 |
+
background-color: #1b2a3a
|
| 317 |
+
texture: noise scale 3 intensity 0.9 contrast 1.5 color #8fd3ff
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
// stack several with commas
|
| 321 |
+
.stack { texture: grain, scanlines }`,
|
| 322 |
+
html: `<div class="wall">
|
| 323 |
+
<div class="sw s1">grain</div>
|
| 324 |
+
<div class="sw s2">marble</div>
|
| 325 |
+
<div class="sw s3">wood</div>
|
| 326 |
+
<div class="sw s4">carbon</div>
|
| 327 |
+
<div class="sw s5">halftone</div>
|
| 328 |
+
<div class="sw s6">hex</div>
|
| 329 |
+
<div class="sw custom">noise, tuned</div>
|
| 330 |
+
<div class="sw stack">grain, scanlines</div>
|
| 331 |
+
</div>`,
|
| 332 |
+
},
|
| 333 |
+
sliders: {
|
| 334 |
+
label: "Range sliders",
|
| 335 |
+
src: `// slider: styles the WebKit and Firefox parts from one line.
|
| 336 |
+
// graph makes the track a histogram, particles can come off the thumb.
|
| 337 |
+
.panel {
|
| 338 |
+
column: gap 30
|
| 339 |
+
padding: 28 24
|
| 340 |
+
background: #101018
|
| 341 |
+
radius: lg
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.label { color: #a79eba; font-size: 13; margin: 0 0 6 }
|
| 345 |
+
|
| 346 |
+
.warm {
|
| 347 |
+
slider: track 6 #445566 radius full, fill #ff7a00, thumb 22 #ffffff, ring 3 #ff7a00, glow #ff7a00 14, ticks 10 #8890a0
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.histo {
|
| 351 |
+
slider: graph 20 45 70 90 60 35 50 80 95 40 60 75, graph-height 52, graph-gap 3, fill #77ccff, track 3 #34445a, thumb 14 #ffffff, shape bar
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.gem {
|
| 355 |
+
slider: track 10 #2b3040 radius 3, fill #5be2a0, thumb 24 #5be2a0, shape diamond
|
| 356 |
+
aura-particle: #bfffe0 -> rgba(91,226,160,0) count 40 size 5 life 700 speed 30 70 dir up spread 50 from thumb glowing
|
| 357 |
+
}`,
|
| 358 |
+
html: `<div class="panel">
|
| 359 |
+
<div><p class="label">ring, glow and ticks</p><input class="warm" type="range" value="60"></div>
|
| 360 |
+
<div><p class="label">a histogram</p><input class="histo" type="range" value="40"></div>
|
| 361 |
+
<div><p class="label">a diamond thumb that sheds particles</p><input class="gem" type="range" value="70"></div>
|
| 362 |
+
</div>`,
|
| 363 |
+
},
|
| 364 |
+
glass: {
|
| 365 |
+
label: "Glass and tilt",
|
| 366 |
+
src: `// glass: blurs what is behind it. It is plain CSS,
|
| 367 |
+
// so "dssl build" keeps it. tilt: follows the pointer
|
| 368 |
+
// (move your mouse over the panes) and stays in the engine.
|
| 369 |
+
.scene {
|
| 370 |
+
position: relative
|
| 371 |
+
overflow: hidden
|
| 372 |
+
radius: lg
|
| 373 |
+
padding: 36
|
| 374 |
+
min-height: 300
|
| 375 |
+
background: linear-gradient(135deg, #ff7a00, #c2185b 55%, #3949ab)
|
| 376 |
+
grid: auto-fit 180
|
| 377 |
+
gap: 20
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.blob { position: absolute; radius: full; filter: blur(2px) }
|
| 381 |
+
.b1 { width: 170; height: 170; left: 8%; top: -30; background: #ffe14a }
|
| 382 |
+
.b2 { width: 130; height: 130; right: 10%; bottom: -20; background: #40e0d0 }
|
| 383 |
+
|
| 384 |
+
.pane {
|
| 385 |
+
position: relative
|
| 386 |
+
glass: 18 saturate 1.6
|
| 387 |
+
tilt: 10 lift 1.04 glare
|
| 388 |
+
border: 1 solid white/35
|
| 389 |
+
radius: lg
|
| 390 |
+
padding: 22
|
| 391 |
+
color: white
|
| 392 |
+
min-height: 150
|
| 393 |
+
background-image: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), white/28, transparent 55%)
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.pane h4 { margin: 0 0 6; font-size: 17 }
|
| 397 |
+
.pane p { margin: 0; font-size: 13.5; line-height: 1.6; opacity: 0.9 }`,
|
| 398 |
+
html: `<div class="scene">
|
| 399 |
+
<div class="blob b1"></div>
|
| 400 |
+
<div class="blob b2"></div>
|
| 401 |
+
<div class="pane"><h4>Frosted</h4><p>glass: 18 saturate 1.6 blurs and lifts the colours behind this pane.</p></div>
|
| 402 |
+
<div class="pane"><h4>Follows you</h4><p>tilt: 10 lift 1.04 glare turns toward the pointer and lights the spot you are on.</p></div>
|
| 403 |
+
</div>`,
|
| 404 |
+
},
|
| 405 |
+
};
|
| 406 |
+
|
src/lib/sanitize.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* A shared playground link carries HTML as well as styles, and anyone can write a link.
|
| 2 |
+
* So the canvas never receives that text as markup. It is parsed in an inert document and
|
| 3 |
+
* rebuilt element by element from a short allow-list: layout and text tags, a few form
|
| 4 |
+
* controls, class names and data attributes. No scripts, no event handlers, no style
|
| 5 |
+
* attributes, no URLs, no ids (which could collide with the site). */
|
| 6 |
+
|
| 7 |
+
const TAGS = new Set([
|
| 8 |
+
"div", "span", "p", "section", "article", "header", "footer", "nav", "main", "aside", "figure", "figcaption",
|
| 9 |
+
"h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "b", "i", "em", "strong", "small", "code", "pre", "kbd", "mark",
|
| 10 |
+
"blockquote", "hr", "br", "button", "input", "label", "progress", "meter", "table", "thead", "tbody", "tr", "th", "td",
|
| 11 |
+
]);
|
| 12 |
+
const ATTRS = new Set(["class", "title", "value", "min", "max", "step", "checked", "disabled", "placeholder", "aria-label", "aria-hidden", "aria-describedby", "role", "low", "high", "optimum"]);
|
| 13 |
+
const INPUT_TYPES = new Set(["range", "checkbox", "radio", "text", "number", "button"]);
|
| 14 |
+
const MAX_CHARS = 30000;
|
| 15 |
+
const MAX_NODES = 1500;
|
| 16 |
+
|
| 17 |
+
/** parse `html` and append a safe copy to `target` (any existing children are removed first) */
|
| 18 |
+
export function renderSafeHtml(target: HTMLElement, html: string): { nodes: number; dropped: number; truncated: boolean } {
|
| 19 |
+
target.replaceChildren();
|
| 20 |
+
let dropped = 0, nodes = 0, truncated = false;
|
| 21 |
+
const text = html.length > MAX_CHARS ? ((truncated = true), html.slice(0, MAX_CHARS)) : html;
|
| 22 |
+
const doc = new DOMParser().parseFromString(text, "text/html");
|
| 23 |
+
const copy = (src: Node, into: Node) => {
|
| 24 |
+
for (const n of Array.from(src.childNodes)) {
|
| 25 |
+
if (nodes >= MAX_NODES) { truncated = true; return; }
|
| 26 |
+
if (n.nodeType === Node.TEXT_NODE) { into.appendChild(document.createTextNode(n.textContent || "")); continue; }
|
| 27 |
+
if (n.nodeType !== Node.ELEMENT_NODE) continue;
|
| 28 |
+
const el = n as Element;
|
| 29 |
+
const tag = el.tagName.toLowerCase();
|
| 30 |
+
if (!TAGS.has(tag)) { dropped++; continue; }
|
| 31 |
+
nodes++;
|
| 32 |
+
const out = document.createElement(tag);
|
| 33 |
+
for (const a of Array.from(el.attributes)) {
|
| 34 |
+
const name = a.name.toLowerCase();
|
| 35 |
+
if (name === "type" && tag === "input") { if (INPUT_TYPES.has(a.value)) out.setAttribute("type", a.value); else dropped++; continue; }
|
| 36 |
+
if (name.startsWith("data-") && /^data-[a-z0-9-]{1,40}$/.test(name)) { out.setAttribute(name, a.value.slice(0, 200)); continue; }
|
| 37 |
+
if (ATTRS.has(name)) { out.setAttribute(name, a.value.slice(0, 400)); continue; }
|
| 38 |
+
dropped++;
|
| 39 |
+
}
|
| 40 |
+
if (tag === "a") out.setAttribute("tabindex", "-1");
|
| 41 |
+
copy(el, out);
|
| 42 |
+
into.appendChild(out);
|
| 43 |
+
}
|
| 44 |
+
};
|
| 45 |
+
copy(doc.body, target);
|
| 46 |
+
return { nodes, dropped, truncated };
|
| 47 |
+
}
|
src/lib/share.ts
CHANGED
|
@@ -55,3 +55,19 @@ export function shareFromHash(hash: string): string | null {
|
|
| 55 |
|
| 56 |
/** a browser URL length that every chat app and server will accept */
|
| 57 |
export const MAX_SHARE_URL = 8000;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
/** a browser URL length that every chat app and server will accept */
|
| 57 |
export const MAX_SHARE_URL = 8000;
|
| 58 |
+
|
| 59 |
+
/** what a playground link holds: the styles, and the canvas HTML when it is not the default */
|
| 60 |
+
export type SharePayload = { css: string; html?: string };
|
| 61 |
+
|
| 62 |
+
/** plain text stays plain text (so old links keep working); anything with HTML is a small JSON object */
|
| 63 |
+
export const packPayload = (p: SharePayload): string => (p.html === undefined ? p.css : JSON.stringify({ v: 1, css: p.css, html: p.html }));
|
| 64 |
+
|
| 65 |
+
export function unpackPayload(text: string): SharePayload {
|
| 66 |
+
if (text.startsWith('{"v":1,')) {
|
| 67 |
+
try {
|
| 68 |
+
const o = JSON.parse(text);
|
| 69 |
+
if (o && typeof o.css === "string") return { css: o.css, html: typeof o.html === "string" ? o.html : undefined };
|
| 70 |
+
} catch { /* it was just a stylesheet that starts oddly */ }
|
| 71 |
+
}
|
| 72 |
+
return { css: text };
|
| 73 |
+
}
|
src/lib/vocab.json
CHANGED
|
@@ -392,5 +392,49 @@
|
|
| 392 |
"key": "graph-gap",
|
| 393 |
"form": "graph-gap <px>"
|
| 394 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
]
|
| 396 |
}
|
|
|
|
| 392 |
"key": "graph-gap",
|
| 393 |
"form": "graph-gap <px>"
|
| 394 |
}
|
| 395 |
+
],
|
| 396 |
+
"glassWords": [
|
| 397 |
+
{
|
| 398 |
+
"key": "blur",
|
| 399 |
+
"doc": "a number: how many px to blur what is behind the element (default 16)"
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
"key": "saturate",
|
| 403 |
+
"doc": "saturate <n>: colour boost for what shows through, 1 is unchanged (default 1.5)"
|
| 404 |
+
},
|
| 405 |
+
{
|
| 406 |
+
"key": "bright",
|
| 407 |
+
"doc": "bright <n>: brightness of what shows through, 1 is unchanged"
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"key": "tint",
|
| 411 |
+
"doc": "tint <color> | tint none: the milky layer on top (default: a faint wash of the text colour)"
|
| 412 |
+
}
|
| 413 |
+
],
|
| 414 |
+
"tiltWords": [
|
| 415 |
+
{
|
| 416 |
+
"key": "max",
|
| 417 |
+
"doc": "a number: the largest tilt in degrees (default 8)"
|
| 418 |
+
},
|
| 419 |
+
{
|
| 420 |
+
"key": "lift",
|
| 421 |
+
"doc": "lift <n>: scale while the pointer is over it, e.g. 1.04"
|
| 422 |
+
},
|
| 423 |
+
{
|
| 424 |
+
"key": "perspective",
|
| 425 |
+
"doc": "perspective <px>: how strong the 3D depth looks (default 900)"
|
| 426 |
+
},
|
| 427 |
+
{
|
| 428 |
+
"key": "speed",
|
| 429 |
+
"doc": "speed <0.05 to 1>: how quickly it follows (default 0.18)"
|
| 430 |
+
},
|
| 431 |
+
{
|
| 432 |
+
"key": "invert",
|
| 433 |
+
"doc": "invert: tilt away from the pointer instead of toward it"
|
| 434 |
+
},
|
| 435 |
+
{
|
| 436 |
+
"key": "glare",
|
| 437 |
+
"doc": "glare: also keep --dssl-gx and --dssl-gy (0% to 100%) pointing at the pointer, for your own highlight"
|
| 438 |
+
}
|
| 439 |
]
|
| 440 |
}
|
src/types/dssl.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ interface Window {
|
|
| 23 |
unfreeze: () => unknown;
|
| 24 |
explain: (el: Element, opts?: { quiet?: boolean }) => unknown;
|
| 25 |
errors: () => DsslError[];
|
| 26 |
-
toCSS?: (src: string) => { css: string; report: unknown };
|
| 27 |
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 28 |
__debug?: any;
|
| 29 |
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
| 23 |
unfreeze: () => unknown;
|
| 24 |
explain: (el: Element, opts?: { quiet?: boolean }) => unknown;
|
| 25 |
errors: () => DsslError[];
|
| 26 |
+
toCSS?: (src: string, opts?: { banner?: boolean; file?: string }) => { css: string; report: unknown };
|
| 27 |
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 28 |
__debug?: any;
|
| 29 |
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|