EHS-Studio Claude Sonnet 5.5 commited on
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 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.9.1.vsix` from the repo files, then
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.9.1.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,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.9.1",
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.9.1.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
 
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.9.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.9.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.9.1.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
 
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
- const files = ['parser.js', 'resolver.js', 'utils.js', 'color.js', 'texture.js', 'aura.js', 'slider.js', 'lint.js', 'import.js'];
 
 
 
 
 
 
 
 
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.x', /^0\.9\./.test(VERSION), VERSION);
305
 
306
  console.log(`\n${pass} passed, ${fail} failed`);
307
  process.exit(fail ? 1 : 0);
 
301
 
302
  /* ------------------------------------------------------------------ */
303
  section('release');
304
+ ok('the engine version is 0.9 or later', /^0\.(9|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
- const PRESETS: Record<string, { label: string; src: string }> = {
7
- cards: {
8
- label: "Cards",
9
- src: `// style the canvas, bare numbers are px,
10
- // bare identifiers are tokens
11
- tokens {
12
- color {
13
- hotspot: oklch(0.62 0.17 55)
14
- }
 
 
 
 
 
15
  }
16
 
17
- .pc-board {
18
- grid: auto-fill 190
19
- gap: space.4
20
- width: 100%
21
- }
22
-
23
- .pc-card {
24
- background: color.card
25
- border: 1 solid color.line
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
- .pc-badge {
45
- align-self: start
46
- font-family: font.mono
47
- font-size: 11
48
- background: color.accent/12
49
- color: color.accent
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 editorRef = useRef<HTMLTextAreaElement>(null);
 
 
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 [shareNote, setShareNote] = useState("");
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
- if (shared !== null && editorRef.current) {
279
- editorRef.current.value = shared;
280
- setShareNote("Loaded from a shared link.");
281
- run(shared);
282
- } else {
283
- run(PRESETS.cards.src);
284
- if (packed) setShareNote("That link could not be read, so the default is loaded.");
285
- }
 
 
 
286
  })();
287
  return () => { cancelled = true; };
288
- }, [engineReady, run]);
 
 
 
 
 
 
289
 
290
  const copyLink = async () => {
291
- if (!editorRef.current) return;
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) { setShareNote("That stylesheet is too long for a link. Copy the text instead."); return; }
295
  window.history.replaceState(null, "", `#s=${packed}`);
296
- try {
297
- await navigator.clipboard.writeText(url);
298
- setShareNote("Link copied.");
299
- } catch {
300
- setShareNote("The link is in the address bar. Copy it from there.");
301
- }
 
 
302
  };
303
 
304
- const onEdit = () => {
 
 
 
 
305
  if (timerRef.current) clearTimeout(timerRef.current);
306
  timerRef.current = setTimeout(() => {
307
- if (editorRef.current) run(editorRef.current.value);
 
 
308
  }, 450);
309
  };
310
 
311
  const loadPreset = (key: string) => {
312
  setPreset(key);
313
- if (editorRef.current) {
314
- editorRef.current.value = PRESETS[key].src;
315
- run(PRESETS[key].src);
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, nothing leaks to the rest of the page.
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
- <span>canvas.dssl</span>
 
 
 
 
342
  <div className="pg-head-tools">
343
- <select
344
- className="pg-select"
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
- {shareNote && <div className="pg-note" role="status">{shareNote}</div>}
357
  <textarea
358
- ref={editorRef}
359
  className="pg-editor"
 
360
  defaultValue={PRESETS.cards.src}
361
- onInput={onEdit}
 
 
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>“{e.hint}”</em>?</span>}
 
 
 
 
 
 
 
 
 
 
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>&ldquo;{e.hint}&rdquo;</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 &amp; 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