EHS-Studio Claude Sonnet 5.5 commited on
Commit
d9e058a
·
1 Parent(s): 0882628

v0.12.0: symmetrical tiling, fonts block, richer gradients, in-page inspector, hot reload and dssl dev

Browse files
GUIDE.md CHANGED
@@ -392,6 +392,16 @@ knots are drawn on top. There are ten presets: `oak`, `walnut`, `cherry`, `maple
392
  | `planks` | board height in px, with a seam and a different board each |
393
  | `seed`, `angle` | another board; 0 or 90 degrees |
394
 
 
 
 
 
 
 
 
 
 
 
395
  The drawing uses only your seed and plain arithmetic, so the same seed gives the same board on every
396
  machine, and the tile repeats without a seam. It does repeat: one tile is 576px along the grain, and
397
  three boards tall with `planks`, so a very large surface shows the pattern twice. Use two textures with
@@ -415,6 +425,43 @@ engine is ready: the engine adds the class `dssl-ready` to `<html>` when it has
415
  <script>setTimeout(() => document.documentElement.classList.add('dssl-ready'), 2500)</script> <!-- failsafe -->
416
  ```
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  ### Counters
419
 
420
  `counter-reset`, `counter-increment`, `counter-set`, and `content: counter(step)` or
@@ -682,7 +729,7 @@ Three ways to get it:
682
 
683
  1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
684
  https://open-vsx.org/extension/ehs-studio/dssl-vscode
685
- 2. VS Code: download `dssl-vscode/dssl-vscode-0.11.0.vsix` from the repo files, then
686
  Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
687
  3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
688
 
 
392
  | `planks` | board height in px, with a seam and a different board each |
393
  | `seed`, `angle` | another board; 0 or 90 degrees |
394
 
395
+ `symmetrical` removes the repeat problem. It mirrors every other tile, so each edge meets its own reflection (seamless by construction) and the pattern repeats twice as far apart:
396
+
397
+ ```dssl
398
+ .floor { background-color: #8a5f33; texture: oak-planks symmetrical } // mirrored both ways
399
+ .bar { texture: walnut symmetrical x } // only left to right
400
+ .post { texture: noise symmetrical y } // only top to bottom
401
+ ```
402
+
403
+ It works on every texture made of an SVG image (noise, bump, rings, waves, hex). The pure CSS patterns (lines, dots, grid, checker, carbon) are already regular and are left alone. The cost is small: the tile is drawn once and flipped, not copied. The look is a mirror line down the middle, which on wood reads as a book-matched board, a real woodworking trick.
404
+
405
  The drawing uses only your seed and plain arithmetic, so the same seed gives the same board on every
406
  machine, and the tile repeats without a seam. It does repeat: one tile is 576px along the grain, and
407
  three boards tall with `planks`, so a very large surface shows the pattern twice. Use two textures with
 
425
  <script>setTimeout(() => document.documentElement.classList.add('dssl-ready'), 2500)</script> <!-- failsafe -->
426
  ```
427
 
428
+ ### Fonts
429
+
430
+ ```dssl
431
+ fonts {
432
+ Fraunces: 500 700 800 // Google Fonts, these weights
433
+ "Hanken Grotesk": 400 700 italic // with italics
434
+ Inter: 100..900 display optional // a variable range; how it swaps in
435
+ Brand: from "/fonts/brand.woff2" 400 // your own file: @font-face
436
+ }
437
+ ```
438
+
439
+ A family line is `Family: weights [italic] [display swap|block|fallback|optional]`, or `Family: from "file" weight` for a file of your own (one weight or one variable range per file, so one line per file). The engine adds the `<link>` and preconnects to `<head>` and removes it again if nothing asks for it; `dssl build` writes `@import` as the first line of the CSS and `@font-face` for your files. Google sees every visitor who loads one of its fonts, which some privacy laws care about: self-host with `from` if that is you.
440
+
441
+ ### Gradients, the new forms
442
+
443
+ ```dssl
444
+ .stripes { gradient: repeating linear 45, #fff 0 10, color.ink 10 20 }
445
+ .blend { gradient: linear 90 in oklch, #ff0055 -> #00d4ff }
446
+ .mid { gradient: linear 90, #f00 -> 30% -> #00f }
447
+ .band { gradient: linear 90, #f00 0 30%, #00f 30% 100% }
448
+ .aurora { gradient: mesh #14102a, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 45%, #3949ab at 50% 95% }
449
+ .sheen { gradient: radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 }
450
+ ```
451
+
452
+ `repeating` turns any kind into its repeating form. `in oklch` (or `oklab`, `hsl`, `lab`, `srgb`, with `longer hue` and friends) picks the colour space the blend happens in. A lone position between two stops is a midpoint. Two positions make a hard band. Bare numbers in a stop position are px (degrees on `conic`). `mesh` takes a base colour and blobs: `color at x y [size n]` (default size 60%). `+` joins layers, first on top. A direction is optional: `linear #f00 -> #00f` works.
453
+
454
+ ### Inspector and hot reload
455
+
456
+ ```js
457
+ DSSL.inspect() // hover an element: the rules that won each property, with file and line
458
+ DSSL.watch() // edit a .dssl file, the open page restyles with no reload
459
+ ```
460
+
461
+ Both are loaded only when called (`dev.js`), or switched on with `data-watch` and `data-inspect` on the script tag, or `?dssl-watch` and `?dssl-inspect` in the URL. The inspector outlines the element under the pointer and lists what applied and every matching rule in cascade order; click to pin, Escape to release, Escape again to close. Hot reload polls the .dssl files loaded by `DSSL.load` (and linked .css files) and applies a changed file in place, so scroll position and form input survive; an error shows as a toast with its line. `DSSL.watch(false)` stops it.
462
+
463
+ `node cli/dssl.mjs dev [dir] [--port 3000] [--inspect]` is a static server that never caches and adds `DSSL.watch()` to every page, with the engine served from the install if the folder has none.
464
+
465
  ### Counters
466
 
467
  `counter-reset`, `counter-increment`, `counter-set`, and `content: counter(step)` or
 
729
 
730
  1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
731
  https://open-vsx.org/extension/ehs-studio/dssl-vscode
732
+ 2. VS Code: download `dssl-vscode/dssl-vscode-0.12.0.vsix` from the repo files, then
733
  Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
734
  3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
735
 
PARITY.md CHANGED
@@ -62,6 +62,10 @@ 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.11.0 (wood): rings, species, planks, baked textures
66
 
67
  Written after another AI built a wood site in DSSL and listed what hurt. What came of it:
@@ -76,10 +80,11 @@ Written after another AI built a wood site in DSSL and listed what hurt. What ca
76
  | Contrast false alarms on translucent backgrounds | judged on the light page, white and black; warns only if it fails on all |
77
  | Flash of unstyled content | `dssl-ready` class on `<html>`, and `dssl build` for the static part (the wood page does this) |
78
  | Textures missing from `dssl build` | baked into the CSS when colours are known; the report names the ones that could not be |
79
- | `@font-face` in `raw {}` | **not done.** A native font loader is still a good idea. |
80
- | Gradients only cover simple cases | **not done.** Plain CSS gradient strings still pass through, which is what the wood page uses. |
81
- | Dev-tools inspector for which rule produced a style | **not done.** `DSSL.explain()` is still a console call. |
82
- | Hot reload | **not done.** |
 
83
 
84
  ## v0.10.0: glass, tilt, and a silent colour bug
85
 
 
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.12.0: the rest of the wood list
66
+
67
+ Fonts, richer gradients, an inspector, hot reload and mirrored tiling, from the same critic's list (rows marked "done in 0.12.0" below).
68
+
69
  ## v0.11.0 (wood): rings, species, planks, baked textures
70
 
71
  Written after another AI built a wood site in DSSL and listed what hurt. What came of it:
 
80
  | Contrast false alarms on translucent backgrounds | judged on the light page, white and black; warns only if it fails on all |
81
  | Flash of unstyled content | `dssl-ready` class on `<html>`, and `dssl build` for the static part (the wood page does this) |
82
  | Textures missing from `dssl build` | baked into the CSS when colours are known; the report names the ones that could not be |
83
+ | `@font-face` in `raw {}` | done in 0.12.0: a `fonts { }` block (Google families, variable ranges, your own files) |
84
+ | Gradients only cover simple cases | done in 0.12.0: repeating, colour spaces, midpoints, hard bands, `mesh`, layers |
85
+ | Dev-tools inspector for which rule produced a style | done in 0.12.0 as an in-page panel, `DSSL.inspect()`. A real browser DevTools panel would need an extension and is still not done. |
86
+ | Hot reload | done in 0.12.0: `DSSL.watch()` and `dssl dev` |
87
+ | The wood tile repeats every 576px | `texture: ... symmetrical` mirrors the tile, so it never shows a seam |
88
 
89
  ## v0.10.0: glass, tilt, and a silent colour bug
90
 
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.11.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,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
  ## Glass and tilt
396
 
397
  ```dssl
 
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.12.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
+ ## Fonts, gradients, dev tools
396
+
397
+ ```dssl
398
+ fonts {
399
+ Fraunces: 500 700 800
400
+ Brand: from "/fonts/brand.woff2" 400
401
+ }
402
+ .hero { gradient: mesh #14102a, #ff7a1a at 15% 20%, #3949ab at 80% 70% }
403
+ .floor { texture: oak-planks symmetrical } // mirrored tiles: no seam, no obvious repeat
404
+ ```
405
+
406
+ `DSSL.inspect()` shows which rule produced each style, in the page. `DSSL.watch()` (or `node cli/dssl.mjs dev .`) restyles the open page when you save a `.dssl` file, with no reload.
407
+
408
  ## Glass and tilt
409
 
410
  ```dssl
cli/dssl.mjs CHANGED
@@ -12,6 +12,11 @@
12
  * Convert CSS to DSSL (semicolons, px, transition order, font:, @keyframes,
13
  * !important, raw blocks) and report what could not be carried over.
14
  *
 
 
 
 
 
15
  * dssl build styles.dssl [-o out.css] [--json] [--strict] [--no-banner]
16
  * The exit lane: compile the static subset to a real .css file.
17
  * Tokens become custom properties, states become :hover, layers become
@@ -53,6 +58,9 @@ const USAGE = `dssl v${VERSION} - the DSSL style language
53
  -o <out.dssl> output path (default: <file>.dssl)
54
  --stdout print the DSSL instead of writing a file
55
  --json machine-readable report
 
 
 
56
  dssl --version print the version`;
57
 
58
  const readArg = (args, flag) => {
@@ -152,7 +160,35 @@ if (cmd === 'check') {
152
  process.exit(bad ? 1 : 0);
153
  }
154
 
155
- if (cmd === 'build') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  const json = args.includes('--json');
157
  const banner = !args.includes('--no-banner');
158
  const strict = args.includes('--strict');
@@ -217,5 +253,7 @@ if (cmd === 'import') {
217
  process.exit(report.compile.ok ? 0 : 1);
218
  }
219
 
220
- console.error(`dssl: unknown command "${cmd}"\n\n${USAGE}`);
221
- process.exit(2);
 
 
 
12
  * Convert CSS to DSSL (semicolons, px, transition order, font:, @keyframes,
13
  * !important, raw blocks) and report what could not be carried over.
14
  *
15
+ * dssl dev [dir] [--port 3000] [--inspect]
16
+ * A tiny static server that never caches and adds DSSL.watch() to every page it
17
+ * serves, so saving a .dssl file restyles the open page in place. Pages that load
18
+ * /dssl/boot.js get it from the engine folder if the served folder has none.
19
+ *
20
  * dssl build styles.dssl [-o out.css] [--json] [--strict] [--no-banner]
21
  * The exit lane: compile the static subset to a real .css file.
22
  * Tokens become custom properties, states become :hover, layers become
 
58
  -o <out.dssl> output path (default: <file>.dssl)
59
  --stdout print the DSSL instead of writing a file
60
  --json machine-readable report
61
+ dssl dev [dir] serve a folder with hot reload: edit a .dssl file and the page restyles with no reload
62
+ --port <n> port (default 3000)
63
+ --inspect also open the in-page inspector (hover an element to see its rules)
64
  dssl --version print the version`;
65
 
66
  const readArg = (args, flag) => {
 
160
  process.exit(bad ? 1 : 0);
161
  }
162
 
163
+ if (cmd === 'dev') {
164
+ const { createServer } = await import('node:http');
165
+ const { extname } = await import('node:path');
166
+ const { readFile } = await import('node:fs/promises');
167
+ const port = Number(readArg(args, '--port') || 3000);
168
+ const inspectOn = args.includes('--inspect'); if (inspectOn) args.splice(args.indexOf('--inspect'), 1);
169
+ const dir = pathResolve(args[0] || '.');
170
+ if (!existsSync(dir)) { console.error(`dssl dev: ${dir} does not exist`); process.exit(2); }
171
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.dssl': 'text/plain; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain' };
172
+ const inject = `<script>addEventListener('dssl-ready',function(){if(window.DSSL){DSSL.watch();${inspectOn ? 'DSSL.inspect();' : ''}}});</script>`;
173
+ createServer(async (req, res) => {
174
+ let url = decodeURIComponent((req.url || '/').split('?')[0]);
175
+ if (url.endsWith('/')) url += 'index.html';
176
+ const inside = (p) => p.startsWith(dir);
177
+ let file = pathResolve(join(dir, url));
178
+ if (!inside(file)) { res.writeHead(403); return res.end('no'); }
179
+ if (!existsSync(file) && url.startsWith('/dssl/')) file = pathResolve(join(engineDir, url.slice(6)));
180
+ try {
181
+ if (statSync(file).isDirectory()) file = join(file, 'index.html');
182
+ let body = await readFile(file);
183
+ if (extname(file) === '.html') body = Buffer.from(String(body).includes('</body>') ? String(body).replace('</body>', inject + '</body>') : String(body) + inject);
184
+ res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream', 'cache-control': 'no-store' });
185
+ res.end(body);
186
+ } catch { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('not found: ' + url); }
187
+ }).listen(port, () => {
188
+ console.log(`dssl dev: serving ${dir}`);
189
+ console.log(` http://localhost:${port}/ (hot reload on${inspectOn ? ', inspector on' : ''}; Ctrl+C to stop)`);
190
+ });
191
+ } else if (cmd === 'build') {
192
  const json = args.includes('--json');
193
  const banner = !args.includes('--no-banner');
194
  const strict = args.includes('--strict');
 
253
  process.exit(report.compile.ok ? 0 : 1);
254
  }
255
 
256
+ if (cmd !== 'dev') { // dev keeps the process alive
257
+ console.error(`dssl: unknown command "${cmd}"\n\n${USAGE}`);
258
+ process.exit(2);
259
+ }
dssl-vscode/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
  # Changelog
2
 
 
 
 
 
 
 
3
  ## 0.11.0
4
 
5
  - Bundled engine updated to 0.11.0: ten wood species and the `rings` texture generator (figure, warp,
 
1
  # Changelog
2
 
3
+ ## 0.12.0
4
+
5
+ - Bundled engine updated to 0.12.0: `texture: ... symmetrical`, a `fonts { }` block, richer `gradient:` sentences
6
+ (repeating, colour spaces, midpoints, hard bands, mesh, layers), `DSSL.inspect()`, `DSSL.watch()` and `dssl dev`.
7
+ - `fonts` appears in block completions with hover docs, and the `gradient` hover lists the new forms.
8
+
9
  ## 0.11.0
10
 
11
  - Bundled engine updated to 0.11.0: ten wood species and the `rings` texture generator (figure, warp,
dssl-vscode/lib/engine/fonts.js ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: fonts.js (0.12.0)
3
+ *
4
+ * fonts {
5
+ * Fraunces: 500 700 800 // from Google Fonts, these weights
6
+ * "Hanken Grotesk": 400 700 italic // with the italics too
7
+ * Inter: 100..900 display optional // a variable range, and how it swaps in
8
+ * Brand: from "/fonts/brand.woff2" 400 // your own file: becomes @font-face
9
+ * }
10
+ *
11
+ * Fonts used to need a raw { } block of plain CSS. A font is a stylesheet link
12
+ * (Google) or an @font-face (your file), and neither belongs in a rule, so this
13
+ * is its own block. At run time the engine adds the <link> to <head>; `dssl build`
14
+ * writes an @import at the top of the CSS.
15
+ *
16
+ * Google's servers see every visitor who loads a Google font. If that matters
17
+ * to you (it does under some privacy laws), self-host with `from`.
18
+ * ========================================================================= */
19
+
20
+ export const FONT_WORDS = {
21
+ weights: 'a weight (400), several (400 700), or a variable range (100..900)',
22
+ italic: 'also load the italic of each weight',
23
+ display: 'display <swap|block|fallback|optional>: how the text behaves while the font loads (default swap)',
24
+ from: 'from "/path/to/file.woff2": use your own file instead of Google Fonts',
25
+ };
26
+ const DISPLAYS = ['swap', 'block', 'fallback', 'optional', 'auto'];
27
+ const FORMATS = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' };
28
+
29
+ const words = (s) => {
30
+ const out = []; let i = 0;
31
+ while (i < s.length) {
32
+ if (/\s/.test(s[i])) { i++; continue; }
33
+ if (s[i] === '"' || s[i] === "'") { const q = s[i]; let j = i + 1; while (j < s.length && s[j] !== q) j++; out.push(s.slice(i, j + 1)); i = j + 1; continue; }
34
+ let j = i; while (j < s.length && !/\s/.test(s[j])) j++; out.push(s.slice(i, j)); i = j;
35
+ }
36
+ return out;
37
+ };
38
+ const unq = (w) => (/^(["']).*\1$/.test(w) ? w.slice(1, -1) : w);
39
+
40
+ /**
41
+ * Parse the text inside `fonts { ... }`.
42
+ * hooks.error(line, message, hint) reports a problem, hooks.suggest(word, list) gives a did-you-mean.
43
+ * Returns { links: [{ url, line }], faces: [css], families: [name] }.
44
+ */
45
+ export function parseFonts(body, firstLine, hooks) {
46
+ const google = new Map(); // family -> { weights:Set<string>, italic:bool }
47
+ let display = 'swap';
48
+ const faces = [], families = [];
49
+ const lines = String(body).replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).split('\n');
50
+ lines.forEach((raw, idx) => {
51
+ const line = firstLine + idx;
52
+ let text = raw.replace(/(^|\s)\/\/.*$/, '').trim().replace(/;$/, '');
53
+ if (!text) return;
54
+ // the family may be quoted, or bare words ("Hanken Grotesk"): everything before the first colon outside quotes
55
+ let colon = -1, q = null;
56
+ for (let i = 0; i < text.length; i++) { const c = text[i]; if (q) { if (c === q) q = null; continue; } if (c === '"' || c === "'") { q = c; continue; } if (c === ':') { colon = i; break; } }
57
+ if (colon === -1) { hooks.error(line, `expected "Family: weights" in "${text.slice(0, 40)}"`, 'e.g. Fraunces: 500 700 800'); return; }
58
+ const family = unq(text.slice(0, colon).trim()).replace(/\s+/g, ' ');
59
+ if (!family) { hooks.error(line, 'a font line needs a family name before the colon'); return; }
60
+ const w = words(text.slice(colon + 1));
61
+ const weights = []; let italic = false, from = null, disp = null;
62
+ for (let i = 0; i < w.length; i++) {
63
+ const x = w[i];
64
+ if (/^\d{1,4}$/.test(x) || /^\d{1,4}\.\.\d{1,4}$/.test(x)) {
65
+ const [a, b] = x.split('..').map(Number);
66
+ if (a < 1 || a > 1000 || (b !== undefined && (b < 1 || b > 1000 || b <= a))) { hooks.error(line, `"${x}" is not a font weight`, FONT_WORDS.weights); return; }
67
+ weights.push(x);
68
+ } else if (x === 'italic') italic = true;
69
+ else if (x === 'display') {
70
+ if (!DISPLAYS.includes(w[i + 1])) { hooks.error(line, `display needs one of: ${DISPLAYS.join(', ')}`, FONT_WORDS.display); return; }
71
+ disp = w[++i];
72
+ } else if (x === 'from') {
73
+ if (!w[i + 1]) { hooks.error(line, 'from needs a file, e.g. from "/fonts/brand.woff2"', FONT_WORDS.from); return; }
74
+ from = unq(w[++i]);
75
+ } else {
76
+ hooks.error(line, `unknown font setting "${x}"`, (hooks.suggest && hooks.suggest(x, ['italic', 'display', 'from'])) || 'a line is: Family: weights italic display swap, or Family: from "file" weight');
77
+ return;
78
+ }
79
+ }
80
+ if (!families.includes(family)) families.push(family);
81
+ if (from) {
82
+ if (weights.length > 1) { hooks.error(line, 'one font file holds one weight (or one variable range), so give from a single weight', 'add one line per file'); return; }
83
+ const ext = (from.split('?')[0].match(/\.([a-z0-9]+)$/i) || [])[1];
84
+ const fmt = ext && FORMATS[ext.toLowerCase()];
85
+ const weight = weights[0] ? weights[0].replace('..', ' ') : '400';
86
+ faces.push(`@font-face {\n font-family: "${family}";\n src: url("${from}")${fmt ? ` format("${fmt}")` : ''};\n font-weight: ${weight};\n font-style: ${italic ? 'italic' : 'normal'};\n font-display: ${disp || 'swap'};\n}`);
87
+ return;
88
+ }
89
+ if (disp) display = disp;
90
+ const g = google.get(family) || { weights: new Set(), italic: false };
91
+ for (const x of weights) g.weights.add(x);
92
+ if (italic) g.italic = true;
93
+ google.set(family, g);
94
+ });
95
+
96
+ const links = [];
97
+ if (google.size) {
98
+ const parts = [];
99
+ for (const [fam, g] of google) {
100
+ const name = fam.replace(/ /g, '+');
101
+ const ws = [...g.weights].sort((a, b) => parseInt(a) - parseInt(b));
102
+ if (!ws.length) { parts.push(g.italic ? `${name}:ital@0;1` : name); continue; }
103
+ if (g.italic) {
104
+ const axis = [];
105
+ for (const it of [0, 1]) for (const x of ws) axis.push(`${it},${x}`);
106
+ parts.push(`${name}:ital,wght@${axis.join(';')}`);
107
+ } else parts.push(`${name}:wght@${ws.join(';')}`);
108
+ }
109
+ links.push({ url: `https://fonts.googleapis.com/css2?family=${parts.join('&family=')}&display=${display}`, line: firstLine });
110
+ }
111
+ return { links, faces, families };
112
+ }
dssl-vscode/lib/engine/parser.js CHANGED
@@ -149,6 +149,7 @@ export function parse(source, file = 'inline.dssl') {
149
  if (h === 'tokens') return { kind: 'tokens' };
150
  if (h === 'pin') return { kind: 'pin' };
151
  if (h === 'raw' || h === '@css') return { kind: 'raw' };
 
152
  if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
153
  if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
154
  if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
@@ -244,9 +245,9 @@ export function parse(source, file = 'inline.dssl') {
244
  * parsing normally. */
245
  let rawCapture = null; // { node, depth } while a multi-line raw { } is open
246
  const openBlock = (meta, node, rest, open, line) => {
247
- if (meta.kind === 'raw') {
248
  if (stack.length > 1) {
249
- err(line, 'raw { } must be top-level - it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
250
  const close = matchBrace(rest, open + 1);
251
  return close === -1 ? '' : rest.slice(close + 1);
252
  }
 
149
  if (h === 'tokens') return { kind: 'tokens' };
150
  if (h === 'pin') return { kind: 'pin' };
151
  if (h === 'raw' || h === '@css') return { kind: 'raw' };
152
+ if (h === 'fonts') return { kind: 'fonts' };
153
  if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
154
  if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
155
  if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
 
245
  * parsing normally. */
246
  let rawCapture = null; // { node, depth } while a multi-line raw { } is open
247
  const openBlock = (meta, node, rest, open, line) => {
248
+ if (meta.kind === 'raw' || meta.kind === 'fonts') {
249
  if (stack.length > 1) {
250
+ err(line, `${meta.kind} { } must be top-level` + (meta.kind === 'raw' ? ' - it drops verbatim CSS into the page stylesheet' : ' - fonts load for the whole page'), 'move it out of the enclosing block');
251
  const close = matchBrace(rest, open + 1);
252
  return close === -1 ? '' : rest.slice(close + 1);
253
  }
dssl-vscode/lib/engine/resolver.js CHANGED
@@ -24,6 +24,7 @@ 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
  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
@@ -425,22 +426,79 @@ const COLOR_WORDS = new Set(('black,silver,gray,white,maroon,red,purple,fuchsia,
425
  'canvas,canvastext,linktext,visitedtext,activetext,buttonface,buttontext,buttonborder,field,fieldtext,highlight,highlighttext,' +
426
  'currentcolor,transparent').split(','));
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  function buildGradient(v, ctx) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  const t = String(v).trim();
430
- const m = t.match(/^(linear|radial|conic)\b([\s\S]*)$/i);
 
 
431
  if (!m) {
432
  ctx.errors.push({
433
  file: ctx.file, line: ctx.line,
434
- message: `gradient needs a kind: "linear", "radial" or "conic"`,
435
  hint: 'try gradient: linear 135, color.accent -> color.ok',
436
  });
437
  return null;
438
  }
439
- const kind = m[1].toLowerCase();
440
- const rest = m[2].trim();
 
441
  const DIR_RE = /^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i;
442
- const isPrefixShaped = (seg) => {
443
- const s = seg.toLowerCase();
 
444
  if (kind === 'linear') return /^-?\d+(\.\d+)?(deg)?$/.test(s) || DIR_RE.test(s);
445
  if (kind === 'conic') return /^from\s+-?\d+(\.\d+)?(deg)?(\s+at\s+.+)?$/.test(s) || /^at\s+.+$/.test(s);
446
  // radial: every atom is shape/size/position vocabulary (possibly around `at`)
@@ -456,6 +514,8 @@ function buildGradient(v, ctx) {
456
  // mixed styles like `linear 135, a -> b`: the comma belongs to the stop list
457
  const hc = topSplit(head, ',').map(s => s.trim()).filter(Boolean);
458
  if (hc.length > 1) { head = hc[0]; stopStrs = [...hc.slice(1), ...stopStrs]; }
 
 
459
  } else {
460
  const segs = topSplit(rest, ',').map(s => s.trim()).filter(Boolean);
461
  while (segs.length > 1 && isPrefixShaped(segs[0])) head = head ? head + ' ' + segs.shift() : segs.shift();
@@ -469,6 +529,9 @@ function buildGradient(v, ctx) {
469
  });
470
  return null;
471
  }
 
 
 
472
  let lead = '';
473
  const POS_ATOM = /^(center|top|bottom|left|right|closest-side|farthest-side|closest-corner|farthest-corner|-?\d+(?:\.\d+)?(%|px|em|rem)?)$/i;
474
  if (kind === 'linear') {
@@ -513,10 +576,21 @@ function buildGradient(v, ctx) {
513
  }
514
  }
515
  const stops = [];
516
- for (const s of stopStrs) {
 
 
 
 
 
 
 
 
 
 
517
  let colorPart = s, pos = '';
518
- const pm = s.match(/\s+(\d+(?:\.\d+)?)%$/); // trailing per-stop position
519
- if (pm) { pos = pm[1] + '%'; colorPart = s.slice(0, pm.index).trim(); }
 
520
  if (!colorPart) {
521
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `a gradient stop is missing its color (got "${s}")` });
522
  return null;
@@ -532,8 +606,9 @@ function buildGradient(v, ctx) {
532
  }
533
  stops.push(pos ? `${val} ${pos}` : val);
534
  }
535
- const fn = kind === 'linear' ? 'linear-gradient' : kind === 'radial' ? 'radial-gradient' : 'conic-gradient';
536
- return `${fn}(${[lead, ...stops].filter(Boolean).join(', ')})`;
 
537
  }
538
 
539
  function parseFlexWords(value, ctx, dir) {
@@ -1204,6 +1279,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1204
  const errors = [];
1205
  const rules = [];
1206
  const shadowRules = [];
 
1207
  const raws = []; // v0.8.0 - raw { } / @css { } verbatim passthrough blocks
1208
  const motions = {};
1209
  const behaviors = {};
@@ -1497,6 +1573,18 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1497
  if (tr) rules.push(tr);
1498
  break;
1499
  }
 
 
 
 
 
 
 
 
 
 
 
 
1500
  case 'raw': {
1501
  // v0.8.0 - verbatim CSS escape hatch (@font-face, @keyframes,
1502
  // @supports, @import, @property…). The parser captured the body
@@ -1702,7 +1790,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1702
  const mediaQueries = new Set();
1703
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1704
 
1705
- return { rules, shadowRules, raws, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1706
  }
1707
 
1708
  /** naive `&` substitution + descendant join */
 
24
  import { parseAura } from './aura.js';
25
  import { parseSlider, sliderCss } from './slider.js';
26
  import { lintRules } from './lint.js';
27
+ import { parseFonts } from './fonts.js';
28
  import { NAMED as NAMED_COLORS } from './color.js';
29
  import { glassDecls, parseTilt } from './effects.js';
30
  // v0.5.0 - the resolver validates the `::name` half of full selectors so a
 
426
  'canvas,canvastext,linktext,visitedtext,activetext,buttonface,buttontext,buttonborder,field,fieldtext,highlight,highlighttext,' +
427
  'currentcolor,transparent').split(','));
428
 
429
+ /** split on a top-level " + " (layers), never inside parentheses or quotes */
430
+ function splitLayers(s) {
431
+ const out = []; let depth = 0, q = null, cur = '';
432
+ for (let i = 0; i < s.length; i++) {
433
+ const c = s[i];
434
+ if (q) { cur += c; if (c === q) q = null; continue; }
435
+ if (c === '"' || c === "'") { q = c; cur += c; continue; }
436
+ if (c === '(' || c === '[') depth++;
437
+ else if (c === ')' || c === ']') depth = Math.max(0, depth - 1);
438
+ if (depth === 0 && c === '+' && /\s/.test(s[i - 1] || '') && /\s/.test(s[i + 1] || '')) { out.push(cur.trim()); cur = ''; continue; }
439
+ cur += c;
440
+ }
441
+ if (cur.trim()) out.push(cur.trim());
442
+ return out;
443
+ }
444
+
445
+ const INTERP_RE = /\s*\bin\s+(srgb-linear|srgb|display-p3|lab|oklab|xyz-d50|xyz-d65|xyz|hsl|hwb|lch|oklch)(\s+(?:longer|shorter|increasing|decreasing)\s+hue)?\s*$/i;
446
+
447
+ /**
448
+ * gradient: one or several layers.
449
+ * linear 135, a -> b the original sentence
450
+ * repeating linear 45, red 0 10, blue 10 20 stripes: positions take %, px, em, rem (deg on conic)
451
+ * linear 90 in oklch, #f00 -> #00f pick the colour space the blend happens in
452
+ * linear 90, a -> 30% -> b a lone position between two stops moves the midpoint
453
+ * mesh #fff4e6, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 60% soft blobs of colour on a base
454
+ * radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 layers, first on top
455
+ */
456
  function buildGradient(v, ctx) {
457
+ const layers = splitLayers(String(v).trim());
458
+ if (layers.length > 1) {
459
+ const out = [];
460
+ for (const l of layers) { const g = buildGradientLayer(l, ctx); if (g === null) return null; out.push(g); }
461
+ return out.join(', ');
462
+ }
463
+ return buildGradientLayer(String(v).trim(), ctx);
464
+ }
465
+
466
+ function buildMesh(rest, ctx) {
467
+ const items = topSplit(rest, ',').map((x) => x.trim()).filter(Boolean);
468
+ const bad = (message, hint) => { ctx.errors.push({ file: ctx.file, line: ctx.line, message, hint }); return null; };
469
+ if (items.length < 2) return bad('mesh needs a base colour and at least one blob', 'try gradient: mesh #fff4e6, #ff7a1a at 15% 20%, #3949ab at 80% 70%');
470
+ let base = null; const blobs = [];
471
+ for (const it of items) {
472
+ const m = it.match(/^(.*?)\s+at\s+(\S+)\s+(\S+)(?:\s+size\s+(\S+))?$/i);
473
+ if (!m) { if (base !== null) return bad(`mesh item "${it}" needs a position`, 'write color at 20% 30% (and optionally size 60%)'); base = resolveValue(it, { ...ctx, unitless: false, bareIdents: false }); continue; }
474
+ const pos = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + 'px' : x);
475
+ const size = m[4] ? pos(m[4]) : '60%';
476
+ blobs.push(`radial-gradient(circle farthest-side at ${pos(m[2])} ${pos(m[3])}, ${resolveValue(m[1].trim(), { ...ctx, unitless: false, bareIdents: false })}, transparent ${size})`);
477
+ }
478
+ if (base === null) return bad('mesh needs a base colour first (an item without "at")', 'gradient: mesh #fff4e6, #ff7a1a at 15% 20%');
479
+ return [...blobs, `linear-gradient(${base}, ${base})`].join(', ');
480
+ }
481
+
482
+ function buildGradientLayer(v, ctx) {
483
  const t = String(v).trim();
484
+ const mm = t.match(/^mesh\b([\s\S]*)$/i);
485
+ if (mm) return buildMesh(mm[1], ctx);
486
+ const m = t.match(/^(repeating\s+)?(linear|radial|conic)\b([\s\S]*)$/i);
487
  if (!m) {
488
  ctx.errors.push({
489
  file: ctx.file, line: ctx.line,
490
+ message: `gradient needs a kind: "linear", "radial", "conic" or "mesh" (any of them can be "repeating")`,
491
  hint: 'try gradient: linear 135, color.accent -> color.ok',
492
  });
493
  return null;
494
  }
495
+ const repeating = !!m[1];
496
+ const kind = m[2].toLowerCase();
497
+ const rest = m[3].trim().replace(/^,\s*/, '');
498
  const DIR_RE = /^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i;
499
+ const isPrefixShaped = (seg0) => {
500
+ const s = seg0.toLowerCase().replace(INTERP_RE, '').trim();
501
+ if (s === '' && INTERP_RE.test(seg0)) return true;
502
  if (kind === 'linear') return /^-?\d+(\.\d+)?(deg)?$/.test(s) || DIR_RE.test(s);
503
  if (kind === 'conic') return /^from\s+-?\d+(\.\d+)?(deg)?(\s+at\s+.+)?$/.test(s) || /^at\s+.+$/.test(s);
504
  // radial: every atom is shape/size/position vocabulary (possibly around `at`)
 
514
  // mixed styles like `linear 135, a -> b`: the comma belongs to the stop list
515
  const hc = topSplit(head, ',').map(s => s.trim()).filter(Boolean);
516
  if (hc.length > 1) { head = hc[0]; stopStrs = [...hc.slice(1), ...stopStrs]; }
517
+ // no direction at all: `linear #f00 -> #00f`, the first part is a stop
518
+ if (head && !isPrefixShaped(head) && /^(#|[a-z-]+\(|[a-z]+\.[a-z0-9-]+)/i.test(head) || (head && COLOR_WORDS.has(head.split(/\s+/)[0].toLowerCase()) && !isPrefixShaped(head))) { stopStrs.unshift(head); head = ''; }
519
  } else {
520
  const segs = topSplit(rest, ',').map(s => s.trim()).filter(Boolean);
521
  while (segs.length > 1 && isPrefixShaped(segs[0])) head = head ? head + ' ' + segs.shift() : segs.shift();
 
529
  });
530
  return null;
531
  }
532
+ // the colour space of the blend, if one was written: `in oklch`, `in hsl longer hue`
533
+ let interp = '';
534
+ { const im = head.match(INTERP_RE); if (im) { interp = im[0].trim().toLowerCase(); head = head.slice(0, im.index).trim(); } }
535
  let lead = '';
536
  const POS_ATOM = /^(center|top|bottom|left|right|closest-side|farthest-side|closest-corner|farthest-corner|-?\d+(?:\.\d+)?(%|px|em|rem)?)$/i;
537
  if (kind === 'linear') {
 
576
  }
577
  }
578
  const stops = [];
579
+ const posUnit = kind === 'conic' ? 'deg' : 'px';
580
+ const asPos = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + (x === '0' ? '' : posUnit) : x);
581
+ const POS = String.raw`-?\d+(?:\.\d+)?(?:%|px|em|rem|deg|turn)?`;
582
+ for (let si = 0; si < stopStrs.length; si++) {
583
+ const s = stopStrs[si];
584
+ // a lone position between two colours is a midpoint hint
585
+ if (new RegExp('^' + POS + '$').test(s)) {
586
+ if (si === 0 || si === stopStrs.length - 1) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `"${s}" is a midpoint, so it has to sit between two colours`, hint: 'try gradient: linear 90, #f00 -> 30% -> #00f' }); return null; }
587
+ stops.push(asPos(s));
588
+ continue;
589
+ }
590
  let colorPart = s, pos = '';
591
+ // trailing position, or two of them (a hard-edged band: red 0 30%)
592
+ const pm = s.match(new RegExp('\\s+(' + POS + ')(?:\\s+(' + POS + '))?$'));
593
+ if (pm) { pos = pm[2] ? asPos(pm[1]) + ' ' + asPos(pm[2]) : asPos(pm[1]); colorPart = s.slice(0, pm.index).trim(); }
594
  if (!colorPart) {
595
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `a gradient stop is missing its color (got "${s}")` });
596
  return null;
 
606
  }
607
  stops.push(pos ? `${val} ${pos}` : val);
608
  }
609
+ const fn = (repeating ? 'repeating-' : '') + (kind === 'linear' ? 'linear-gradient' : kind === 'radial' ? 'radial-gradient' : 'conic-gradient');
610
+ const first = [lead, interp].filter(Boolean).join(' ');
611
+ return `${fn}(${[first, ...stops].filter(Boolean).join(', ')})`;
612
  }
613
 
614
  function parseFlexWords(value, ctx, dir) {
 
1279
  const errors = [];
1280
  const rules = [];
1281
  const shadowRules = [];
1282
+ const fontLinks = []; // 0.12.0 - stylesheet links from fonts { }
1283
  const raws = []; // v0.8.0 - raw { } / @css { } verbatim passthrough blocks
1284
  const motions = {};
1285
  const behaviors = {};
 
1573
  if (tr) rules.push(tr);
1574
  break;
1575
  }
1576
+ case 'fonts': {
1577
+ // 0.12.0 - a font is a stylesheet link or an @font-face, not a rule. Links ride on the document
1578
+ // (the engine adds them to <head>, `dssl build` writes @import); faces are plain CSS and ride with raw.
1579
+ if (ctx.sel) break;
1580
+ const pf = parseFonts(node.css || '', node.line, {
1581
+ error: (line, message, hint) => errors.push({ file, line, message, hint }),
1582
+ suggest: (w, list) => { const h = didYouMean(w, list); return h ? `did you mean "${h}"?` : null; },
1583
+ });
1584
+ for (const l of pf.links) fontLinks.push({ ...l, file });
1585
+ for (const css of pf.faces) raws.push({ css, file, line: node.line, order: order++ });
1586
+ break;
1587
+ }
1588
  case 'raw': {
1589
  // v0.8.0 - verbatim CSS escape hatch (@font-face, @keyframes,
1590
  // @supports, @import, @property…). The parser captured the body
 
1790
  const mediaQueries = new Set();
1791
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1792
 
1793
+ return { rules, shadowRules, raws, fontLinks, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1794
  }
1795
 
1796
  /** naive `&` substitution + descendant join */
dssl-vscode/lib/engine/texture.js CHANGED
@@ -56,6 +56,7 @@ export const TEXTURE_KEYS = {
56
  pores: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: fine fibre and pore streaks between the rings' },
57
  knots: { words: 1, kind: 'number', min: 0, max: 8, int: true, doc: 'rings generator: how many knots in one tile (the pattern repeats, so keep it small)' },
58
  planks: { words: 1, kind: 'number', min: 24, max: 1200, doc: 'rings generator: lay it out as planks this many px tall, with a seam and a slightly different board each' },
 
59
  invert: { words: 0, kind: 'flag', doc: 'swap the two colours' },
60
  };
61
 
@@ -253,6 +254,8 @@ export function parseTexture(value, hooks) {
253
  return null;
254
  }
255
  if (def.kind === 'flag') { over[key] = true; continue; }
 
 
256
  const vals = words.slice(i + 1, i + 1 + def.words);
257
  if (vals.length < def.words || vals.some((v) => TEXTURE_KEYS[v])) {
258
  hooks.error(`texture setting "${key}" needs ${def.words === 1 ? 'a value' : def.words + ' values'}`, def.doc);
@@ -612,12 +615,35 @@ const BUILDERS = { rings: buildRings, noise: buildNoise, bump: buildBump, lines:
612
  * -position and -blend-mode, top layer first.
613
  * rc(cssColorWord) -> {r,g,b,a} | null
614
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  export function buildTexture(layers, rc) {
616
  const parts = [];
617
  for (const spec of layers) {
618
  const builder = BUILDERS[spec.gen];
619
  if (!builder) continue;
620
- for (const piece of builder(spec, rc)) parts.push({ ...piece, animate: spec.animate || 0 });
621
  }
622
  return {
623
  image: parts.map((p) => p.image).join(', '),
 
56
  pores: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: fine fibre and pore streaks between the rings' },
57
  knots: { words: 1, kind: 'number', min: 0, max: 8, int: true, doc: 'rings generator: how many knots in one tile (the pattern repeats, so keep it small)' },
58
  planks: { words: 1, kind: 'number', min: 24, max: 1200, doc: 'rings generator: lay it out as planks this many px tall, with a seam and a slightly different board each' },
59
+ symmetrical: { words: 1, kind: 'word', values: ['both', 'x', 'y'], optional: true, doc: 'mirror every other tile, so each edge meets its own reflection: no seam, and the repeat is twice as far apart. x mirrors left to right, y top to bottom, both (the default) does both. Works on the image-based textures (noise, bump, rings, waves, hex).' },
60
  invert: { words: 0, kind: 'flag', doc: 'swap the two colours' },
61
  };
62
 
 
254
  return null;
255
  }
256
  if (def.kind === 'flag') { over[key] = true; continue; }
257
+ // a setting whose value is optional (symmetrical alone means both)
258
+ if (def.optional && (words[i + 1] === undefined || TEXTURE_KEYS[words[i + 1]] || !def.values.includes(words[i + 1]))) { over[key] = def.values[0]; continue; }
259
  const vals = words.slice(i + 1, i + 1 + def.words);
260
  if (vals.length < def.words || vals.some((v) => TEXTURE_KEYS[v])) {
261
  hooks.error(`texture setting "${key}" needs ${def.words === 1 ? 'a value' : def.words + ' values'}`, def.doc);
 
615
  * -position and -blend-mode, top layer first.
616
  * rc(cssColorWord) -> {r,g,b,a} | null
617
  */
618
+ /**
619
+ * symmetrical: lay the tile out twice over, the second copy flipped, so every edge meets its own mirror image.
620
+ * That is seamless for any image at all, and the pattern now repeats at double the distance. Only the
621
+ * SVG-image textures can be flipped this way; the pure CSS ones (lines, dots, grid, checker, carbon) are
622
+ * already regular and are left alone.
623
+ */
624
+ function mirrorPiece(piece, mode) {
625
+ const m = /^url\("(data:image\/svg\+xml,[^"]*)"\)$/.exec(piece.image);
626
+ const sz = /^([\d.]+)px ([\d.]+)px$/.exec(piece.size || '');
627
+ if (!m || !sz) return piece;
628
+ const w = Number(sz[1]), h = Number(sz[2]);
629
+ const fx = mode === 'x' || mode === 'both', fy = mode === 'y' || mode === 'both';
630
+ const W = fx ? w * 2 : w, H = fy ? h * 2 : h;
631
+ const use = (tx, ty, sx, sy) => `<use href='#t' transform='translate(${tx} ${ty}) scale(${sx} ${sy})'/>`;
632
+ let body = use(0, 0, 1, 1);
633
+ if (fx) body += use(2 * w, 0, -1, 1);
634
+ if (fy) body += use(0, 2 * h, 1, -1);
635
+ if (fx && fy) body += use(2 * w, 2 * h, -1, -1);
636
+ const inner = m[1].replace(/'/g, '%27');
637
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='${W}' height='${H}'><defs><image id='t' width='${w}' height='${h}' href='${inner}'/></defs>${body}</svg>`;
638
+ return { ...piece, image: smallUrl(svg), size: `${W}px ${H}px`, tile: Math.max(piece.tile || 0, W, H) };
639
+ }
640
+
641
  export function buildTexture(layers, rc) {
642
  const parts = [];
643
  for (const spec of layers) {
644
  const builder = BUILDERS[spec.gen];
645
  if (!builder) continue;
646
+ for (const piece of builder(spec, rc)) parts.push({ ...(spec.symmetrical ? mirrorPiece(piece, spec.symmetrical) : piece), animate: spec.animate || 0 });
647
  }
648
  return {
649
  image: parts.map((p) => p.image).join(', '),
dssl-vscode/lib/props-docs.js CHANGED
@@ -12,7 +12,7 @@ export const SEMANTIC = {
12
  size: 'width and height in one line: `size: 40 20` = 40 wide, 20 tall. `size: 40` = both.',
13
  shadow: 'box shadow from the shadow token scale: `shadow: sm | md | lg | xl`. Custom: `shadow: 0 2 4 ink/10, 0 4 12 ink/8`.',
14
  radius: 'border-radius, bare number or token: `radius: md`, `radius: full`, `radius: 14`.',
15
- gradient: 'gradient as a sentence: `gradient: linear 135, color.accent -> color.ok`. Stops can be hex, name, token, alpha mix, or percent-positioned. A stop that is not a color is a compile error.',
16
  'gradient-text': 'clips the gradient grammar into the glyphs: `gradient-text: linear to right, color.accent, color.ink`.',
17
  'gradient-flow': 'the gradient drifts forever through WAAPI: `gradient-flow: 6s alternate`. Holds still under reduced-motion.',
18
  bend: 'bows the element in 3D. Positive tips the top away, negative leans toward you. Bare numbers are degrees. Composes into the one transform channel.',
@@ -31,7 +31,7 @@ export const SEMANTIC = {
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.',
37
  'aura-particle': 'just the particles slot: `aura-particle: #7cf -> transparent 40 up size 6 life 800 speed 30 from bottom`. Settings: count, size, life, speed, dir, spread, gravity, drag, turbulence, from, shape, grow, glowing, behind, blend, texture(...), seed, essential.',
@@ -123,6 +123,7 @@ export const BLOCKS = {
123
  tokens: 'define design tokens: `tokens { color { ink: #16161d } radius { md: 14 } space { 9: 96 } }`. Numeric keys are legal. Referenced as color.ink, radius.md, space.9. Trailing /n adds alpha.',
124
  behavior: 'a named rule group you can `use` anywhere: `behavior focus-ring { focus-visible { ... } }`.',
125
  motion: 'a named keyframe animation, run with `animate:`: `motion pulse { from { } to { } }`.',
 
126
  pin: 'must-win declarations. The cascade ranks tokens < base < components < utilities < pin. This is the ONLY way to force a win \u2014 there is no !important.',
127
  };
128
 
 
12
  size: 'width and height in one line: `size: 40 20` = 40 wide, 20 tall. `size: 40` = both.',
13
  shadow: 'box shadow from the shadow token scale: `shadow: sm | md | lg | xl`. Custom: `shadow: 0 2 4 ink/10, 0 4 12 ink/8`.',
14
  radius: 'border-radius, bare number or token: `radius: md`, `radius: full`, `radius: 14`.',
15
+ gradient: 'gradient as a sentence: `gradient: linear 135, color.accent -> color.ok`. Also `repeating linear 45, #fff 0 10, #000 10 20` (stripes), `linear 90 in oklch, a -> b` (blend colour space), `a -> 30% -> b` (midpoint), `a 0 30%, b 30% 100%` (hard bands), `mesh #fff4e6, #ff7a1a at 15% 20%, #3949ab at 80% 70%` (soft blobs on a base), and layers joined with ` + ` (first on top). A stop that is not a color is a compile error.',
16
  'gradient-text': 'clips the gradient grammar into the glyphs: `gradient-text: linear to right, color.accent, color.ink`.',
17
  'gradient-flow': 'the gradient drifts forever through WAAPI: `gradient-flow: 6s alternate`. Holds still under reduced-motion.',
18
  bend: 'bows the element in 3D. Positive tips the top away, negative leans toward you. Bare numbers are degrees. Composes into the one transform channel.',
 
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, ...). Wood: `texture: oak figure 0.8 knots 2 planks 120` (rings generator, ten species). `symmetrical` mirrors the tile so it never shows a seam. Open the designer to see them all.',
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.',
37
  'aura-particle': 'just the particles slot: `aura-particle: #7cf -> transparent 40 up size 6 life 800 speed 30 from bottom`. Settings: count, size, life, speed, dir, spread, gravity, drag, turbulence, from, shape, grow, glowing, behind, blend, texture(...), seed, essential.',
 
123
  tokens: 'define design tokens: `tokens { color { ink: #16161d } radius { md: 14 } space { 9: 96 } }`. Numeric keys are legal. Referenced as color.ink, radius.md, space.9. Trailing /n adds alpha.',
124
  behavior: 'a named rule group you can `use` anywhere: `behavior focus-ring { focus-visible { ... } }`.',
125
  motion: 'a named keyframe animation, run with `animate:`: `motion pulse { from { } to { } }`.',
126
+ fonts: 'load fonts without raw CSS: `fonts { Fraunces: 500 700 800 "Hanken Grotesk": 400 700 italic Brand: from "/fonts/brand.woff2" 400 }`. Google families become a <link> (and an @import in `dssl build`); `from` writes @font-face. Settings: weights or a range (100..900), italic, display swap|block|fallback|optional.',
127
  pin: 'must-win declarations. The cascade ranks tokens < base < components < utilities < pin. This is the ONLY way to force a win \u2014 there is no !important.',
128
  };
129
 
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.11.0",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
 
2
  "name": "dssl-vscode",
3
  "displayName": "DSSL Language Support",
4
  "description": "The DSSL style language in your editor: syntax highlighting, real-compiler diagnostics (the same errors the browser console shows), completions from the live token registry and for textures, auras and sliders, hover docs, an interactive designer with sliders and live previews for aura, texture and range sliders, and a CSS to DSSL converter. Works in VS Code and VSCodium.",
5
+ "version": "0.12.0",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
llms.txt CHANGED
@@ -63,6 +63,12 @@ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
63
  - Alpha on a named colour works: `white/12`, `currentColor/20`. It used to compile to `white/12px`.
64
  - The playground has a canvas.html tab, `?example=` links, lint warnings and an "as CSS" tab. The canvas keeps a short allow-list of tags and drops scripts, handlers, styles and ids.
65
 
 
 
 
 
 
 
66
  ## 0.11.0: wood and the build
67
  - Ten wood species presets: oak, walnut, cherry, maple, pine, mahogany, ebony, birch, oak-planks, walnut-planks. The `rings` generator draws growth rings as vector curves (not stretched noise): `texture: oak figure 0.8 knots 2 planks 120`. Settings: scale (ring spacing), color (latewood), color2 (earlywood), contrast, figure (cathedral arching), warp, pores, knots, planks (board height px), seed, angle 0 or 90. Same seed, same board everywhere; the tile repeats without a seam.
68
  - `dssl build` now BAKES textures into the CSS as background images when every colour is known at build time. Animated textures and unknown colours stay in the runtime, and the report says why.
@@ -84,4 +90,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
84
 
85
  ## Editor support
86
 
87
- 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.11.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
 
63
  - Alpha on a named colour works: `white/12`, `currentColor/20`. It used to compile to `white/12px`.
64
  - The playground has a canvas.html tab, `?example=` links, lint warnings and an "as CSS" tab. The canvas keeps a short allow-list of tags and drops scripts, handlers, styles and ids.
65
 
66
+ ## 0.12.0: fonts, gradients, symmetrical, dev tools
67
+ - `texture: oak symmetrical [x|y|both]` mirrors every other tile: no seam, the repeat is twice as far apart. Works on image textures (noise, bump, rings, waves, hex); pure-CSS patterns are left alone.
68
+ - `fonts { Fraunces: 500 700 800 "Hanken Grotesk": 400 700 italic Inter: 100..900 display optional Brand: from "/f.woff2" 400 }`. Google families become a <link> at run time and an @import in `dssl build`; `from` becomes @font-face.
69
+ - gradient: `repeating linear 45, #fff 0 10, #000 10 20`; `linear 90 in oklch, a -> b`; `a -> 30% -> b` midpoint; `a 0 30%, b 30% 100%` hard bands; `mesh base, color at x y [size n], ...`; layers joined with ` + `; the direction is optional.
70
+ - `DSSL.inspect()` (which rule produced each style, in the page), `DSSL.watch()` (hot reload of loaded .dssl and linked .css, no page reload), `node cli/dssl.mjs dev [dir]`. `data-watch`/`data-inspect` on the script tag, or ?dssl-watch / ?dssl-inspect.
71
+
72
  ## 0.11.0: wood and the build
73
  - Ten wood species presets: oak, walnut, cherry, maple, pine, mahogany, ebony, birch, oak-planks, walnut-planks. The `rings` generator draws growth rings as vector curves (not stretched noise): `texture: oak figure 0.8 knots 2 planks 120`. Settings: scale (ring spacing), color (latewood), color2 (earlywood), contrast, figure (cathedral arching), warp, pores, knots, planks (board height px), seed, angle 0 or 90. Same seed, same board everywhere; the tile repeats without a seam.
74
  - `dssl build` now BAKES textures into the CSS as background images when every colour is known at build time. Animated textures and unknown colours stay in the runtime, and the report says why.
 
90
 
91
  ## Editor support
92
 
93
+ 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.12.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.11.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.12.0",
4
  "private": true,
5
  "description": "The DSSL style language and the website that is written in it.",
6
  "scripts": {
public/dssl/dev.js ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: dev.js (0.12.0) - the in-page inspector and hot reload
3
+ *
4
+ * DSSL.inspect() hover any element to see which DSSL rule produced each style, and where
5
+ * DSSL.watch() re-apply a .dssl file the moment it changes on disk, with no page reload
6
+ *
7
+ * Neither is loaded for visitors: this file is only imported when you call them
8
+ * (or when the script tag carries data-inspect or data-watch).
9
+ * ========================================================================= */
10
+
11
+ const HOST_ID = 'dssl-dev-host';
12
+
13
+ function describe(el) {
14
+ const tag = el.tagName.toLowerCase();
15
+ const id = el.id ? '#' + el.id : '';
16
+ const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 3).join('.') : '';
17
+ return tag + id + cls;
18
+ }
19
+
20
+ /* ------------------------------------------------------------------ inspector */
21
+
22
+ export function createInspector({ explain }) {
23
+ let state = null;
24
+
25
+ const start = () => {
26
+ if (state) return state.api;
27
+ const host = document.createElement('div');
28
+ host.id = HOST_ID;
29
+ host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483646;pointer-events:none;';
30
+ const root = host.attachShadow({ mode: 'open' });
31
+ root.innerHTML = `
32
+ <style>
33
+ :host { all: initial }
34
+ .box { position: fixed; border: 2px solid #ff3d8b; background: rgba(255,61,139,.12); pointer-events: none; border-radius: 2px; display: none }
35
+ .panel { position: fixed; right: 12px; bottom: 12px; width: min(440px, calc(100vw - 24px)); max-height: 58vh; overflow: auto; pointer-events: auto;
36
+ background: #17111f; color: #ece7f5; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; border-radius: 10px;
37
+ box-shadow: 0 10px 40px rgba(0,0,0,.45); border: 1px solid #3a2f4f }
38
+ header { position: sticky; top: 0; display: flex; gap: 8px; align-items: center; justify-content: space-between; padding: 8px 12px; background: #231a33; border-bottom: 1px solid #3a2f4f }
39
+ header b { color: #ff8ac4 }
40
+ button { all: unset; cursor: pointer; padding: 2px 8px; border: 1px solid #5a4a78; border-radius: 6px; color: #ece7f5 }
41
+ button:focus-visible { outline: 2px solid #ffd98a }
42
+ .sec { padding: 8px 12px; border-bottom: 1px solid #2a2140 }
43
+ .sec h4 { margin: 0 0 4px; color: #a9c6ff; font-weight: 700 }
44
+ .row { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; padding: 1px 0 }
45
+ .row span:first-child { overflow-wrap: anywhere }
46
+ .src { color: #9ce3c0; white-space: nowrap }
47
+ .val { color: #ffd98a }
48
+ .muted { color: #9a90ad }
49
+ .none { padding: 12px; color: #9a90ad }
50
+ </style>
51
+ <div class="box" part="box"></div>
52
+ <div class="panel" role="dialog" aria-label="DSSL inspector"><header><span><b>DSSL</b> inspector</span><span><button data-act="pin" title="Click the page to pin an element">hover</button> <button data-act="close" aria-label="Close inspector">Esc</button></span></header><div class="body"><div class="none">Hover an element. Click it to keep its rules on screen.</div></div></div>`;
53
+ document.documentElement.appendChild(host);
54
+ const box = root.querySelector('.box'), body = root.querySelector('.body'), pinBtn = root.querySelector('[data-act=pin]');
55
+ let target = null, pinned = false;
56
+
57
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
58
+ const place = (el) => {
59
+ const r = el.getBoundingClientRect();
60
+ Object.assign(box.style, { display: 'block', left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' });
61
+ };
62
+ const show = (el) => {
63
+ target = el; place(el);
64
+ let rep = null;
65
+ try { rep = explain(el, { quiet: true }); } catch { rep = null; }
66
+ if (!rep) { body.innerHTML = `<div class="sec"><h4>${esc(describe(el))}</h4><span class="muted">no DSSL engine covers this element</span></div>`; return; }
67
+ const wins = rep.wins || [];
68
+ const matched = rep.matched || [];
69
+ body.innerHTML =
70
+ `<div class="sec"><h4>${esc(describe(el))}</h4><span class="muted">${matched.length} rule${matched.length === 1 ? '' : 's'} matched, ${wins.length} declaration${wins.length === 1 ? '' : 's'} won</span></div>` +
71
+ `<div class="sec"><h4>what applied</h4>${wins.length ? wins.map((w) => `<div class="row"><span>${esc(w.prop)}: <span class="val">${esc(String(w.value).slice(0, 90))}${String(w.value).length > 90 ? '...' : ''}</span>${w.imp ? ' !important' : ''}</span><span class="src">${esc(w.by.file || 'inline')}:${w.by.line} ${esc(w.by.sel)}</span></div>`).join('') : '<span class="muted">nothing</span>'}</div>` +
72
+ `<div class="sec"><h4>rules, in cascade order</h4>${matched.length ? matched.map((m) => `<div class="row"><span>${esc(m.sel)}${m.states && m.states.length ? ' <span class="muted">[' + esc(m.states.join(',')) + ']</span>' : ''}</span><span class="src">${esc(m.layer)} ${esc(m.file || 'inline')}:${m.line}</span></div>`).join('') : '<span class="muted">none</span>'}</div>` +
73
+ (rep.pseudo && rep.pseudo.length ? `<div class="sec"><h4>pseudo-elements</h4>${rep.pseudo.slice(0, 4).map((p) => `<div class="row"><span>::${esc(p.pseudo)} ${esc(p.sel)}</span><span class="src">${esc(p.file || 'inline')}:${p.line}</span></div>`).join('')}${rep.pseudo.length > 4 ? `<span class="muted">and ${rep.pseudo.length - 4} more, mostly global ones like scrollbars</span>` : ''}</div>` : '');
74
+ };
75
+ const inside = (el) => el === host || (el && el.closest && el.closest('#' + HOST_ID));
76
+ const onMove = (e) => {
77
+ if (pinned) return;
78
+ const el = document.elementFromPoint(e.clientX, e.clientY);
79
+ if (!el || inside(el) || el === document.documentElement || el === document.body) return;
80
+ if (el !== target) show(el);
81
+ };
82
+ const onClick = (e) => {
83
+ if (inside(e.target)) return;
84
+ e.preventDefault(); e.stopPropagation();
85
+ const el = document.elementFromPoint(e.clientX, e.clientY);
86
+ if (!el || inside(el)) return;
87
+ pinned = true; pinBtn.textContent = 'pinned (click to release)'; show(el);
88
+ };
89
+ const onKey = (e) => { if (e.key === 'Escape') { if (pinned) release(); else stop(); } };
90
+ const release = () => { pinned = false; pinBtn.textContent = 'hover'; };
91
+ const onScroll = () => { if (target && target.isConnected) place(target); };
92
+ pinBtn.addEventListener('click', release);
93
+ root.querySelector('[data-act=close]').addEventListener('click', () => stop());
94
+ document.addEventListener('mousemove', onMove, true);
95
+ document.addEventListener('click', onClick, true);
96
+ document.addEventListener('keydown', onKey, true);
97
+ window.addEventListener('scroll', onScroll, true);
98
+ window.addEventListener('resize', onScroll);
99
+ const stop = () => {
100
+ document.removeEventListener('mousemove', onMove, true);
101
+ document.removeEventListener('click', onClick, true);
102
+ document.removeEventListener('keydown', onKey, true);
103
+ window.removeEventListener('scroll', onScroll, true);
104
+ window.removeEventListener('resize', onScroll);
105
+ host.remove(); state = null;
106
+ };
107
+ state = { api: { stop, show, get target() { return target; }, get pinned() { return pinned; } } };
108
+ return state.api;
109
+ };
110
+
111
+ return { start, stop: () => { if (state) state.api.stop(); }, get active() { return !!state; } };
112
+ }
113
+
114
+ /* ------------------------------------------------------------------ hot reload */
115
+
116
+ export function createWatcher({ apply, sources }) {
117
+ let timer = null, opts = null;
118
+ const seen = new Map(); // url -> last text
119
+ let toast = null, toastTimer = null;
120
+
121
+ const say = (text, bad) => {
122
+ if (!opts || opts.toast === false) return;
123
+ if (!toast) {
124
+ toast = document.createElement('div');
125
+ toast.setAttribute('role', 'status');
126
+ toast.style.cssText = 'all:initial;position:fixed;left:12px;bottom:12px;z-index:2147483646;font:12px/1.4 ui-monospace,Menlo,monospace;padding:6px 10px;border-radius:8px;color:#fff;pointer-events:none;box-shadow:0 4px 16px rgba(0,0,0,.35)';
127
+ document.documentElement.appendChild(toast);
128
+ }
129
+ toast.textContent = text;
130
+ toast.style.background = bad ? '#b3372e' : '#1c6b45';
131
+ toast.style.display = 'block';
132
+ clearTimeout(toastTimer);
133
+ toastTimer = setTimeout(() => { if (toast) toast.style.display = 'none'; }, bad ? 6000 : 1600);
134
+ };
135
+
136
+ const check = async () => {
137
+ for (const url of sources()) {
138
+ let text;
139
+ try {
140
+ const res = await fetch(url, { cache: 'no-store' });
141
+ if (!res.ok) continue;
142
+ text = await res.text();
143
+ } catch { continue; }
144
+ if (!seen.has(url)) { seen.set(url, text); continue; }
145
+ if (seen.get(url) === text) continue;
146
+ seen.set(url, text);
147
+ const r = apply(text, { name: url, replace: true });
148
+ const short = url.split('/').pop();
149
+ if (r.errors.length) say(`${short}: ${r.errors.length} error${r.errors.length === 1 ? '' : 's'}, line ${r.errors[0].line ?? '?'}: ${r.errors[0].message}`.slice(0, 160), true);
150
+ else say(`reloaded ${short}`);
151
+ }
152
+ // plain CSS links (a built stylesheet): swap the href so the browser fetches it again
153
+ if (opts && opts.css !== false) {
154
+ for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
155
+ let u; try { u = new URL(link.href); } catch { continue; }
156
+ if (u.origin !== location.origin) continue;
157
+ const base = u.origin + u.pathname;
158
+ let text;
159
+ try { text = await (await fetch(base, { cache: 'no-store' })).text(); } catch { continue; }
160
+ if (!seen.has(base)) { seen.set(base, text); continue; }
161
+ if (seen.get(base) === text) continue;
162
+ seen.set(base, text);
163
+ link.href = base + '?dssl-reload=' + Date.now();
164
+ say(`reloaded ${u.pathname.split('/').pop()}`);
165
+ }
166
+ }
167
+ };
168
+
169
+ const start = (o = {}) => {
170
+ stop();
171
+ opts = { interval: 800, css: true, toast: true, ...o };
172
+ // remember what is on the page now, so only later edits count
173
+ (async () => { await check(); timer = setInterval(() => { check().catch(() => {}); }, Math.max(250, opts.interval)); })();
174
+ return true;
175
+ };
176
+ const stop = () => { if (timer) clearInterval(timer); timer = null; opts = null; if (toast) { toast.remove(); toast = null; } };
177
+ return { start, stop, get active() { return !!timer || !!opts; }, check };
178
+ }
public/dssl/engine.js CHANGED
@@ -393,6 +393,7 @@ export class DsslEngine {
393
  this.rules = rules;
394
  this.shadowRules = shadowRules;
395
  this.raws = raws;
 
396
 
397
  // unique media queries → MediaQueryList cache
398
  for (const r of rules) for (const q of r.media) this.mql(q);
@@ -403,6 +404,30 @@ export class DsslEngine {
403
  this.computeAll();
404
  }
405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  mql(query) {
407
  let m = this.mqlCache.get(query);
408
  if (!m) {
 
393
  this.rules = rules;
394
  this.shadowRules = shadowRules;
395
  this.raws = raws;
396
+ this.syncFontLinks(bundles);
397
 
398
  // unique media queries → MediaQueryList cache
399
  for (const r of rules) for (const q of r.media) this.mql(q);
 
404
  this.computeAll();
405
  }
406
 
407
+ /** 0.12.0: fonts { } becomes <link> tags in <head>, one per URL, removed again when no sheet asks for it */
408
+ syncFontLinks(bundles) {
409
+ if (typeof document === 'undefined' || !document.head) return;
410
+ const want = new Set();
411
+ for (const b of bundles) for (const l of b.fontLinks || []) want.add(l.url);
412
+ const have = new Map();
413
+ for (const el of document.head.querySelectorAll('link[data-dssl-font]')) have.set(el.getAttribute('href'), el);
414
+ for (const [url, el] of have) if (!want.has(url)) el.remove();
415
+ for (const url of want) {
416
+ if (have.has(url)) continue;
417
+ if (!document.head.querySelector('link[data-dssl-font-preconnect]') && /^https:\/\/fonts\.googleapis\.com\//.test(url)) {
418
+ for (const origin of ['https://fonts.googleapis.com', 'https://fonts.gstatic.com']) {
419
+ const pc = document.createElement('link');
420
+ pc.rel = 'preconnect'; pc.href = origin; if (origin.includes('gstatic')) pc.crossOrigin = '';
421
+ pc.setAttribute('data-dssl-font-preconnect', '');
422
+ document.head.appendChild(pc);
423
+ }
424
+ }
425
+ const el = document.createElement('link');
426
+ el.rel = 'stylesheet'; el.href = url; el.setAttribute('data-dssl-font', '');
427
+ document.head.appendChild(el);
428
+ }
429
+ }
430
+
431
  mql(query) {
432
  let m = this.mqlCache.get(query);
433
  if (!m) {
public/dssl/fonts.js ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: fonts.js (0.12.0)
3
+ *
4
+ * fonts {
5
+ * Fraunces: 500 700 800 // from Google Fonts, these weights
6
+ * "Hanken Grotesk": 400 700 italic // with the italics too
7
+ * Inter: 100..900 display optional // a variable range, and how it swaps in
8
+ * Brand: from "/fonts/brand.woff2" 400 // your own file: becomes @font-face
9
+ * }
10
+ *
11
+ * Fonts used to need a raw { } block of plain CSS. A font is a stylesheet link
12
+ * (Google) or an @font-face (your file), and neither belongs in a rule, so this
13
+ * is its own block. At run time the engine adds the <link> to <head>; `dssl build`
14
+ * writes an @import at the top of the CSS.
15
+ *
16
+ * Google's servers see every visitor who loads a Google font. If that matters
17
+ * to you (it does under some privacy laws), self-host with `from`.
18
+ * ========================================================================= */
19
+
20
+ export const FONT_WORDS = {
21
+ weights: 'a weight (400), several (400 700), or a variable range (100..900)',
22
+ italic: 'also load the italic of each weight',
23
+ display: 'display <swap|block|fallback|optional>: how the text behaves while the font loads (default swap)',
24
+ from: 'from "/path/to/file.woff2": use your own file instead of Google Fonts',
25
+ };
26
+ const DISPLAYS = ['swap', 'block', 'fallback', 'optional', 'auto'];
27
+ const FORMATS = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' };
28
+
29
+ const words = (s) => {
30
+ const out = []; let i = 0;
31
+ while (i < s.length) {
32
+ if (/\s/.test(s[i])) { i++; continue; }
33
+ if (s[i] === '"' || s[i] === "'") { const q = s[i]; let j = i + 1; while (j < s.length && s[j] !== q) j++; out.push(s.slice(i, j + 1)); i = j + 1; continue; }
34
+ let j = i; while (j < s.length && !/\s/.test(s[j])) j++; out.push(s.slice(i, j)); i = j;
35
+ }
36
+ return out;
37
+ };
38
+ const unq = (w) => (/^(["']).*\1$/.test(w) ? w.slice(1, -1) : w);
39
+
40
+ /**
41
+ * Parse the text inside `fonts { ... }`.
42
+ * hooks.error(line, message, hint) reports a problem, hooks.suggest(word, list) gives a did-you-mean.
43
+ * Returns { links: [{ url, line }], faces: [css], families: [name] }.
44
+ */
45
+ export function parseFonts(body, firstLine, hooks) {
46
+ const google = new Map(); // family -> { weights:Set<string>, italic:bool }
47
+ let display = 'swap';
48
+ const faces = [], families = [];
49
+ const lines = String(body).replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).split('\n');
50
+ lines.forEach((raw, idx) => {
51
+ const line = firstLine + idx;
52
+ let text = raw.replace(/(^|\s)\/\/.*$/, '').trim().replace(/;$/, '');
53
+ if (!text) return;
54
+ // the family may be quoted, or bare words ("Hanken Grotesk"): everything before the first colon outside quotes
55
+ let colon = -1, q = null;
56
+ for (let i = 0; i < text.length; i++) { const c = text[i]; if (q) { if (c === q) q = null; continue; } if (c === '"' || c === "'") { q = c; continue; } if (c === ':') { colon = i; break; } }
57
+ if (colon === -1) { hooks.error(line, `expected "Family: weights" in "${text.slice(0, 40)}"`, 'e.g. Fraunces: 500 700 800'); return; }
58
+ const family = unq(text.slice(0, colon).trim()).replace(/\s+/g, ' ');
59
+ if (!family) { hooks.error(line, 'a font line needs a family name before the colon'); return; }
60
+ const w = words(text.slice(colon + 1));
61
+ const weights = []; let italic = false, from = null, disp = null;
62
+ for (let i = 0; i < w.length; i++) {
63
+ const x = w[i];
64
+ if (/^\d{1,4}$/.test(x) || /^\d{1,4}\.\.\d{1,4}$/.test(x)) {
65
+ const [a, b] = x.split('..').map(Number);
66
+ if (a < 1 || a > 1000 || (b !== undefined && (b < 1 || b > 1000 || b <= a))) { hooks.error(line, `"${x}" is not a font weight`, FONT_WORDS.weights); return; }
67
+ weights.push(x);
68
+ } else if (x === 'italic') italic = true;
69
+ else if (x === 'display') {
70
+ if (!DISPLAYS.includes(w[i + 1])) { hooks.error(line, `display needs one of: ${DISPLAYS.join(', ')}`, FONT_WORDS.display); return; }
71
+ disp = w[++i];
72
+ } else if (x === 'from') {
73
+ if (!w[i + 1]) { hooks.error(line, 'from needs a file, e.g. from "/fonts/brand.woff2"', FONT_WORDS.from); return; }
74
+ from = unq(w[++i]);
75
+ } else {
76
+ hooks.error(line, `unknown font setting "${x}"`, (hooks.suggest && hooks.suggest(x, ['italic', 'display', 'from'])) || 'a line is: Family: weights italic display swap, or Family: from "file" weight');
77
+ return;
78
+ }
79
+ }
80
+ if (!families.includes(family)) families.push(family);
81
+ if (from) {
82
+ if (weights.length > 1) { hooks.error(line, 'one font file holds one weight (or one variable range), so give from a single weight', 'add one line per file'); return; }
83
+ const ext = (from.split('?')[0].match(/\.([a-z0-9]+)$/i) || [])[1];
84
+ const fmt = ext && FORMATS[ext.toLowerCase()];
85
+ const weight = weights[0] ? weights[0].replace('..', ' ') : '400';
86
+ faces.push(`@font-face {\n font-family: "${family}";\n src: url("${from}")${fmt ? ` format("${fmt}")` : ''};\n font-weight: ${weight};\n font-style: ${italic ? 'italic' : 'normal'};\n font-display: ${disp || 'swap'};\n}`);
87
+ return;
88
+ }
89
+ if (disp) display = disp;
90
+ const g = google.get(family) || { weights: new Set(), italic: false };
91
+ for (const x of weights) g.weights.add(x);
92
+ if (italic) g.italic = true;
93
+ google.set(family, g);
94
+ });
95
+
96
+ const links = [];
97
+ if (google.size) {
98
+ const parts = [];
99
+ for (const [fam, g] of google) {
100
+ const name = fam.replace(/ /g, '+');
101
+ const ws = [...g.weights].sort((a, b) => parseInt(a) - parseInt(b));
102
+ if (!ws.length) { parts.push(g.italic ? `${name}:ital@0;1` : name); continue; }
103
+ if (g.italic) {
104
+ const axis = [];
105
+ for (const it of [0, 1]) for (const x of ws) axis.push(`${it},${x}`);
106
+ parts.push(`${name}:ital,wght@${axis.join(';')}`);
107
+ } else parts.push(`${name}:wght@${ws.join(';')}`);
108
+ }
109
+ links.push({ url: `https://fonts.googleapis.com/css2?family=${parts.join('&family=')}&display=${display}`, line: firstLine });
110
+ }
111
+ return { links, faces, families };
112
+ }
public/dssl/index.js CHANGED
@@ -19,9 +19,10 @@ 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.11.0';
23
 
24
  let counter = 0;
 
25
  const engines = new Map();
26
  let lastErrors = [];
27
 
@@ -139,6 +140,7 @@ export async function load(urls, opts = {}) {
139
  results.push({ ok: false, errors });
140
  continue;
141
  }
 
142
  results.push(apply(r.text, { ...opts, name: opts.name || r.url }));
143
  }
144
  return results;
@@ -205,9 +207,32 @@ export function explain(el, opts = {}) {
205
  return rep;
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  export const DSSL = {
209
  version: VERSION, compile, apply, load, toCSS, signal, getSignal, refresh, replay, errors,
210
- freeze, unfreeze, explain,
211
  __debug: { engines },
212
  };
213
 
@@ -239,6 +264,13 @@ if (typeof window !== 'undefined') {
239
  }
240
  } finally {
241
  // always announce readiness - even when there was nothing to auto-load
 
 
 
 
 
 
 
242
  window.dispatchEvent(new CustomEvent('dssl-ready', { detail: { version: VERSION } }));
243
  // 0.10.0: a page that hides itself until styled (see the guide, "No flash") shows itself here
244
  try { document.documentElement.classList.add('dssl-ready'); } catch { /* no document element yet */ }
 
19
  import { toCSS as docToCSS } from './static.js';
20
  import { DsslEngine } from './engine.js';
21
 
22
+ export const VERSION = '0.12.0';
23
 
24
  let counter = 0;
25
+ const loadedUrls = new Set(); // every .dssl file fetched by load(), so DSSL.watch() knows what to follow
26
  const engines = new Map();
27
  let lastErrors = [];
28
 
 
140
  results.push({ ok: false, errors });
141
  continue;
142
  }
143
+ loadedUrls.add(opts.name || r.url);
144
  results.push(apply(r.text, { ...opts, name: opts.name || r.url }));
145
  }
146
  return results;
 
207
  return rep;
208
  }
209
 
210
+ // ---- 0.12.0: dev tools, loaded only when asked for --------------------------
211
+ let devMod = null, inspector = null, watcher = null;
212
+ async function dev() {
213
+ if (!devMod) devMod = await import('./dev.js');
214
+ if (!inspector) inspector = devMod.createInspector({ explain });
215
+ if (!watcher) watcher = devMod.createWatcher({ apply, sources: () => [...loadedUrls] });
216
+ return { inspector, watcher };
217
+ }
218
+ /** DSSL.inspect() toggles the in-page inspector; DSSL.inspect(false) closes it */
219
+ export async function inspect(on = true) {
220
+ const d = await dev();
221
+ if (on === false) { d.inspector.stop(); return false; }
222
+ d.inspector.start();
223
+ return true;
224
+ }
225
+ /** DSSL.watch({ interval, css, toast }) re-applies a .dssl file when it changes on disk; DSSL.watch(false) stops */
226
+ export async function watch(o = {}) {
227
+ const d = await dev();
228
+ if (o === false) { d.watcher.stop(); return false; }
229
+ d.watcher.start(typeof o === 'object' ? o : {});
230
+ return true;
231
+ }
232
+
233
  export const DSSL = {
234
  version: VERSION, compile, apply, load, toCSS, signal, getSignal, refresh, replay, errors,
235
+ freeze, unfreeze, explain, inspect, watch,
236
  __debug: { engines },
237
  };
238
 
 
264
  }
265
  } finally {
266
  // always announce readiness - even when there was nothing to auto-load
267
+ // 0.12.0: <script src="boot.js" data-watch data-inspect> turns the dev tools on, and so does ?dssl-watch in the address
268
+ try {
269
+ const tag = document.querySelector('script[src$="boot.js"], script[src$="dssl/index.js"]');
270
+ const q = new URLSearchParams(location.search);
271
+ if ((tag && tag.hasAttribute('data-watch')) || q.has('dssl-watch')) watch();
272
+ if ((tag && tag.hasAttribute('data-inspect')) || q.has('dssl-inspect')) inspect();
273
+ } catch { /* dev tools are optional */ }
274
  window.dispatchEvent(new CustomEvent('dssl-ready', { detail: { version: VERSION } }));
275
  // 0.10.0: a page that hides itself until styled (see the guide, "No flash") shows itself here
276
  try { document.documentElement.classList.add('dssl-ready'); } catch { /* no document element yet */ }
public/dssl/parser.js CHANGED
@@ -149,6 +149,7 @@ export function parse(source, file = 'inline.dssl') {
149
  if (h === 'tokens') return { kind: 'tokens' };
150
  if (h === 'pin') return { kind: 'pin' };
151
  if (h === 'raw' || h === '@css') return { kind: 'raw' };
 
152
  if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
153
  if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
154
  if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
@@ -244,9 +245,9 @@ export function parse(source, file = 'inline.dssl') {
244
  * parsing normally. */
245
  let rawCapture = null; // { node, depth } while a multi-line raw { } is open
246
  const openBlock = (meta, node, rest, open, line) => {
247
- if (meta.kind === 'raw') {
248
  if (stack.length > 1) {
249
- err(line, 'raw { } must be top-level - it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
250
  const close = matchBrace(rest, open + 1);
251
  return close === -1 ? '' : rest.slice(close + 1);
252
  }
 
149
  if (h === 'tokens') return { kind: 'tokens' };
150
  if (h === 'pin') return { kind: 'pin' };
151
  if (h === 'raw' || h === '@css') return { kind: 'raw' };
152
+ if (h === 'fonts') return { kind: 'fonts' };
153
  if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
154
  if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
155
  if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
 
245
  * parsing normally. */
246
  let rawCapture = null; // { node, depth } while a multi-line raw { } is open
247
  const openBlock = (meta, node, rest, open, line) => {
248
+ if (meta.kind === 'raw' || meta.kind === 'fonts') {
249
  if (stack.length > 1) {
250
+ err(line, `${meta.kind} { } must be top-level` + (meta.kind === 'raw' ? ' - it drops verbatim CSS into the page stylesheet' : ' - fonts load for the whole page'), 'move it out of the enclosing block');
251
  const close = matchBrace(rest, open + 1);
252
  return close === -1 ? '' : rest.slice(close + 1);
253
  }
public/dssl/resolver.js CHANGED
@@ -24,6 +24,7 @@ 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
  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
@@ -425,22 +426,79 @@ const COLOR_WORDS = new Set(('black,silver,gray,white,maroon,red,purple,fuchsia,
425
  'canvas,canvastext,linktext,visitedtext,activetext,buttonface,buttontext,buttonborder,field,fieldtext,highlight,highlighttext,' +
426
  'currentcolor,transparent').split(','));
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  function buildGradient(v, ctx) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  const t = String(v).trim();
430
- const m = t.match(/^(linear|radial|conic)\b([\s\S]*)$/i);
 
 
431
  if (!m) {
432
  ctx.errors.push({
433
  file: ctx.file, line: ctx.line,
434
- message: `gradient needs a kind: "linear", "radial" or "conic"`,
435
  hint: 'try gradient: linear 135, color.accent -> color.ok',
436
  });
437
  return null;
438
  }
439
- const kind = m[1].toLowerCase();
440
- const rest = m[2].trim();
 
441
  const DIR_RE = /^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i;
442
- const isPrefixShaped = (seg) => {
443
- const s = seg.toLowerCase();
 
444
  if (kind === 'linear') return /^-?\d+(\.\d+)?(deg)?$/.test(s) || DIR_RE.test(s);
445
  if (kind === 'conic') return /^from\s+-?\d+(\.\d+)?(deg)?(\s+at\s+.+)?$/.test(s) || /^at\s+.+$/.test(s);
446
  // radial: every atom is shape/size/position vocabulary (possibly around `at`)
@@ -456,6 +514,8 @@ function buildGradient(v, ctx) {
456
  // mixed styles like `linear 135, a -> b`: the comma belongs to the stop list
457
  const hc = topSplit(head, ',').map(s => s.trim()).filter(Boolean);
458
  if (hc.length > 1) { head = hc[0]; stopStrs = [...hc.slice(1), ...stopStrs]; }
 
 
459
  } else {
460
  const segs = topSplit(rest, ',').map(s => s.trim()).filter(Boolean);
461
  while (segs.length > 1 && isPrefixShaped(segs[0])) head = head ? head + ' ' + segs.shift() : segs.shift();
@@ -469,6 +529,9 @@ function buildGradient(v, ctx) {
469
  });
470
  return null;
471
  }
 
 
 
472
  let lead = '';
473
  const POS_ATOM = /^(center|top|bottom|left|right|closest-side|farthest-side|closest-corner|farthest-corner|-?\d+(?:\.\d+)?(%|px|em|rem)?)$/i;
474
  if (kind === 'linear') {
@@ -513,10 +576,21 @@ function buildGradient(v, ctx) {
513
  }
514
  }
515
  const stops = [];
516
- for (const s of stopStrs) {
 
 
 
 
 
 
 
 
 
 
517
  let colorPart = s, pos = '';
518
- const pm = s.match(/\s+(\d+(?:\.\d+)?)%$/); // trailing per-stop position
519
- if (pm) { pos = pm[1] + '%'; colorPart = s.slice(0, pm.index).trim(); }
 
520
  if (!colorPart) {
521
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `a gradient stop is missing its color (got "${s}")` });
522
  return null;
@@ -532,8 +606,9 @@ function buildGradient(v, ctx) {
532
  }
533
  stops.push(pos ? `${val} ${pos}` : val);
534
  }
535
- const fn = kind === 'linear' ? 'linear-gradient' : kind === 'radial' ? 'radial-gradient' : 'conic-gradient';
536
- return `${fn}(${[lead, ...stops].filter(Boolean).join(', ')})`;
 
537
  }
538
 
539
  function parseFlexWords(value, ctx, dir) {
@@ -1204,6 +1279,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1204
  const errors = [];
1205
  const rules = [];
1206
  const shadowRules = [];
 
1207
  const raws = []; // v0.8.0 - raw { } / @css { } verbatim passthrough blocks
1208
  const motions = {};
1209
  const behaviors = {};
@@ -1497,6 +1573,18 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1497
  if (tr) rules.push(tr);
1498
  break;
1499
  }
 
 
 
 
 
 
 
 
 
 
 
 
1500
  case 'raw': {
1501
  // v0.8.0 - verbatim CSS escape hatch (@font-face, @keyframes,
1502
  // @supports, @import, @property…). The parser captured the body
@@ -1702,7 +1790,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1702
  const mediaQueries = new Set();
1703
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1704
 
1705
- return { rules, shadowRules, raws, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1706
  }
1707
 
1708
  /** naive `&` substitution + descendant join */
 
24
  import { parseAura } from './aura.js';
25
  import { parseSlider, sliderCss } from './slider.js';
26
  import { lintRules } from './lint.js';
27
+ import { parseFonts } from './fonts.js';
28
  import { NAMED as NAMED_COLORS } from './color.js';
29
  import { glassDecls, parseTilt } from './effects.js';
30
  // v0.5.0 - the resolver validates the `::name` half of full selectors so a
 
426
  'canvas,canvastext,linktext,visitedtext,activetext,buttonface,buttontext,buttonborder,field,fieldtext,highlight,highlighttext,' +
427
  'currentcolor,transparent').split(','));
428
 
429
+ /** split on a top-level " + " (layers), never inside parentheses or quotes */
430
+ function splitLayers(s) {
431
+ const out = []; let depth = 0, q = null, cur = '';
432
+ for (let i = 0; i < s.length; i++) {
433
+ const c = s[i];
434
+ if (q) { cur += c; if (c === q) q = null; continue; }
435
+ if (c === '"' || c === "'") { q = c; cur += c; continue; }
436
+ if (c === '(' || c === '[') depth++;
437
+ else if (c === ')' || c === ']') depth = Math.max(0, depth - 1);
438
+ if (depth === 0 && c === '+' && /\s/.test(s[i - 1] || '') && /\s/.test(s[i + 1] || '')) { out.push(cur.trim()); cur = ''; continue; }
439
+ cur += c;
440
+ }
441
+ if (cur.trim()) out.push(cur.trim());
442
+ return out;
443
+ }
444
+
445
+ const INTERP_RE = /\s*\bin\s+(srgb-linear|srgb|display-p3|lab|oklab|xyz-d50|xyz-d65|xyz|hsl|hwb|lch|oklch)(\s+(?:longer|shorter|increasing|decreasing)\s+hue)?\s*$/i;
446
+
447
+ /**
448
+ * gradient: one or several layers.
449
+ * linear 135, a -> b the original sentence
450
+ * repeating linear 45, red 0 10, blue 10 20 stripes: positions take %, px, em, rem (deg on conic)
451
+ * linear 90 in oklch, #f00 -> #00f pick the colour space the blend happens in
452
+ * linear 90, a -> 30% -> b a lone position between two stops moves the midpoint
453
+ * mesh #fff4e6, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 60% soft blobs of colour on a base
454
+ * radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 layers, first on top
455
+ */
456
  function buildGradient(v, ctx) {
457
+ const layers = splitLayers(String(v).trim());
458
+ if (layers.length > 1) {
459
+ const out = [];
460
+ for (const l of layers) { const g = buildGradientLayer(l, ctx); if (g === null) return null; out.push(g); }
461
+ return out.join(', ');
462
+ }
463
+ return buildGradientLayer(String(v).trim(), ctx);
464
+ }
465
+
466
+ function buildMesh(rest, ctx) {
467
+ const items = topSplit(rest, ',').map((x) => x.trim()).filter(Boolean);
468
+ const bad = (message, hint) => { ctx.errors.push({ file: ctx.file, line: ctx.line, message, hint }); return null; };
469
+ if (items.length < 2) return bad('mesh needs a base colour and at least one blob', 'try gradient: mesh #fff4e6, #ff7a1a at 15% 20%, #3949ab at 80% 70%');
470
+ let base = null; const blobs = [];
471
+ for (const it of items) {
472
+ const m = it.match(/^(.*?)\s+at\s+(\S+)\s+(\S+)(?:\s+size\s+(\S+))?$/i);
473
+ if (!m) { if (base !== null) return bad(`mesh item "${it}" needs a position`, 'write color at 20% 30% (and optionally size 60%)'); base = resolveValue(it, { ...ctx, unitless: false, bareIdents: false }); continue; }
474
+ const pos = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + 'px' : x);
475
+ const size = m[4] ? pos(m[4]) : '60%';
476
+ blobs.push(`radial-gradient(circle farthest-side at ${pos(m[2])} ${pos(m[3])}, ${resolveValue(m[1].trim(), { ...ctx, unitless: false, bareIdents: false })}, transparent ${size})`);
477
+ }
478
+ if (base === null) return bad('mesh needs a base colour first (an item without "at")', 'gradient: mesh #fff4e6, #ff7a1a at 15% 20%');
479
+ return [...blobs, `linear-gradient(${base}, ${base})`].join(', ');
480
+ }
481
+
482
+ function buildGradientLayer(v, ctx) {
483
  const t = String(v).trim();
484
+ const mm = t.match(/^mesh\b([\s\S]*)$/i);
485
+ if (mm) return buildMesh(mm[1], ctx);
486
+ const m = t.match(/^(repeating\s+)?(linear|radial|conic)\b([\s\S]*)$/i);
487
  if (!m) {
488
  ctx.errors.push({
489
  file: ctx.file, line: ctx.line,
490
+ message: `gradient needs a kind: "linear", "radial", "conic" or "mesh" (any of them can be "repeating")`,
491
  hint: 'try gradient: linear 135, color.accent -> color.ok',
492
  });
493
  return null;
494
  }
495
+ const repeating = !!m[1];
496
+ const kind = m[2].toLowerCase();
497
+ const rest = m[3].trim().replace(/^,\s*/, '');
498
  const DIR_RE = /^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i;
499
+ const isPrefixShaped = (seg0) => {
500
+ const s = seg0.toLowerCase().replace(INTERP_RE, '').trim();
501
+ if (s === '' && INTERP_RE.test(seg0)) return true;
502
  if (kind === 'linear') return /^-?\d+(\.\d+)?(deg)?$/.test(s) || DIR_RE.test(s);
503
  if (kind === 'conic') return /^from\s+-?\d+(\.\d+)?(deg)?(\s+at\s+.+)?$/.test(s) || /^at\s+.+$/.test(s);
504
  // radial: every atom is shape/size/position vocabulary (possibly around `at`)
 
514
  // mixed styles like `linear 135, a -> b`: the comma belongs to the stop list
515
  const hc = topSplit(head, ',').map(s => s.trim()).filter(Boolean);
516
  if (hc.length > 1) { head = hc[0]; stopStrs = [...hc.slice(1), ...stopStrs]; }
517
+ // no direction at all: `linear #f00 -> #00f`, the first part is a stop
518
+ if (head && !isPrefixShaped(head) && /^(#|[a-z-]+\(|[a-z]+\.[a-z0-9-]+)/i.test(head) || (head && COLOR_WORDS.has(head.split(/\s+/)[0].toLowerCase()) && !isPrefixShaped(head))) { stopStrs.unshift(head); head = ''; }
519
  } else {
520
  const segs = topSplit(rest, ',').map(s => s.trim()).filter(Boolean);
521
  while (segs.length > 1 && isPrefixShaped(segs[0])) head = head ? head + ' ' + segs.shift() : segs.shift();
 
529
  });
530
  return null;
531
  }
532
+ // the colour space of the blend, if one was written: `in oklch`, `in hsl longer hue`
533
+ let interp = '';
534
+ { const im = head.match(INTERP_RE); if (im) { interp = im[0].trim().toLowerCase(); head = head.slice(0, im.index).trim(); } }
535
  let lead = '';
536
  const POS_ATOM = /^(center|top|bottom|left|right|closest-side|farthest-side|closest-corner|farthest-corner|-?\d+(?:\.\d+)?(%|px|em|rem)?)$/i;
537
  if (kind === 'linear') {
 
576
  }
577
  }
578
  const stops = [];
579
+ const posUnit = kind === 'conic' ? 'deg' : 'px';
580
+ const asPos = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + (x === '0' ? '' : posUnit) : x);
581
+ const POS = String.raw`-?\d+(?:\.\d+)?(?:%|px|em|rem|deg|turn)?`;
582
+ for (let si = 0; si < stopStrs.length; si++) {
583
+ const s = stopStrs[si];
584
+ // a lone position between two colours is a midpoint hint
585
+ if (new RegExp('^' + POS + '$').test(s)) {
586
+ if (si === 0 || si === stopStrs.length - 1) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `"${s}" is a midpoint, so it has to sit between two colours`, hint: 'try gradient: linear 90, #f00 -> 30% -> #00f' }); return null; }
587
+ stops.push(asPos(s));
588
+ continue;
589
+ }
590
  let colorPart = s, pos = '';
591
+ // trailing position, or two of them (a hard-edged band: red 0 30%)
592
+ const pm = s.match(new RegExp('\\s+(' + POS + ')(?:\\s+(' + POS + '))?$'));
593
+ if (pm) { pos = pm[2] ? asPos(pm[1]) + ' ' + asPos(pm[2]) : asPos(pm[1]); colorPart = s.slice(0, pm.index).trim(); }
594
  if (!colorPart) {
595
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `a gradient stop is missing its color (got "${s}")` });
596
  return null;
 
606
  }
607
  stops.push(pos ? `${val} ${pos}` : val);
608
  }
609
+ const fn = (repeating ? 'repeating-' : '') + (kind === 'linear' ? 'linear-gradient' : kind === 'radial' ? 'radial-gradient' : 'conic-gradient');
610
+ const first = [lead, interp].filter(Boolean).join(' ');
611
+ return `${fn}(${[first, ...stops].filter(Boolean).join(', ')})`;
612
  }
613
 
614
  function parseFlexWords(value, ctx, dir) {
 
1279
  const errors = [];
1280
  const rules = [];
1281
  const shadowRules = [];
1282
+ const fontLinks = []; // 0.12.0 - stylesheet links from fonts { }
1283
  const raws = []; // v0.8.0 - raw { } / @css { } verbatim passthrough blocks
1284
  const motions = {};
1285
  const behaviors = {};
 
1573
  if (tr) rules.push(tr);
1574
  break;
1575
  }
1576
+ case 'fonts': {
1577
+ // 0.12.0 - a font is a stylesheet link or an @font-face, not a rule. Links ride on the document
1578
+ // (the engine adds them to <head>, `dssl build` writes @import); faces are plain CSS and ride with raw.
1579
+ if (ctx.sel) break;
1580
+ const pf = parseFonts(node.css || '', node.line, {
1581
+ error: (line, message, hint) => errors.push({ file, line, message, hint }),
1582
+ suggest: (w, list) => { const h = didYouMean(w, list); return h ? `did you mean "${h}"?` : null; },
1583
+ });
1584
+ for (const l of pf.links) fontLinks.push({ ...l, file });
1585
+ for (const css of pf.faces) raws.push({ css, file, line: node.line, order: order++ });
1586
+ break;
1587
+ }
1588
  case 'raw': {
1589
  // v0.8.0 - verbatim CSS escape hatch (@font-face, @keyframes,
1590
  // @supports, @import, @property…). The parser captured the body
 
1790
  const mediaQueries = new Set();
1791
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1792
 
1793
+ return { rules, shadowRules, raws, fontLinks, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1794
  }
1795
 
1796
  /** naive `&` substitution + descendant join */
public/dssl/static.js CHANGED
@@ -104,7 +104,11 @@ export function toCSS(doc, opts = {}) {
104
  ? `/* generated by dssl build from ${file} - the static subset of DSSL${opts.version ? ' v' + opts.version : ''}.\n @signal rules and engine-native properties (animate, aura, tilt, ...) stay in the runtime.
105
  Textures are baked in as background images when their colours are known at build time. */\n`
106
  : '';
 
107
  let out = header;
 
 
 
108
 
109
  const allRules = [...(doc.registryRule ? [doc.registryRule] : []), ...(doc.rules || [])];
110
 
 
104
  ? `/* generated by dssl build from ${file} - the static subset of DSSL${opts.version ? ' v' + opts.version : ''}.\n @signal rules and engine-native properties (animate, aura, tilt, ...) stay in the runtime.
105
  Textures are baked in as background images when their colours are known at build time. */\n`
106
  : '';
107
+ // 0.12.0: fonts { } as @import. An @import has to come before every rule except @layer statements, so it goes right after the banner.
108
  let out = header;
109
+ const fontUrls = [...new Set((doc.fontLinks || []).map((l) => l.url))];
110
+ for (const u of fontUrls) out += `@import url("${u}");\n`;
111
+ if (fontUrls.length) out += '\n';
112
 
113
  const allRules = [...(doc.registryRule ? [doc.registryRule] : []), ...(doc.rules || [])];
114
 
public/dssl/texture.js CHANGED
@@ -56,6 +56,7 @@ export const TEXTURE_KEYS = {
56
  pores: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: fine fibre and pore streaks between the rings' },
57
  knots: { words: 1, kind: 'number', min: 0, max: 8, int: true, doc: 'rings generator: how many knots in one tile (the pattern repeats, so keep it small)' },
58
  planks: { words: 1, kind: 'number', min: 24, max: 1200, doc: 'rings generator: lay it out as planks this many px tall, with a seam and a slightly different board each' },
 
59
  invert: { words: 0, kind: 'flag', doc: 'swap the two colours' },
60
  };
61
 
@@ -253,6 +254,8 @@ export function parseTexture(value, hooks) {
253
  return null;
254
  }
255
  if (def.kind === 'flag') { over[key] = true; continue; }
 
 
256
  const vals = words.slice(i + 1, i + 1 + def.words);
257
  if (vals.length < def.words || vals.some((v) => TEXTURE_KEYS[v])) {
258
  hooks.error(`texture setting "${key}" needs ${def.words === 1 ? 'a value' : def.words + ' values'}`, def.doc);
@@ -612,12 +615,35 @@ const BUILDERS = { rings: buildRings, noise: buildNoise, bump: buildBump, lines:
612
  * -position and -blend-mode, top layer first.
613
  * rc(cssColorWord) -> {r,g,b,a} | null
614
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  export function buildTexture(layers, rc) {
616
  const parts = [];
617
  for (const spec of layers) {
618
  const builder = BUILDERS[spec.gen];
619
  if (!builder) continue;
620
- for (const piece of builder(spec, rc)) parts.push({ ...piece, animate: spec.animate || 0 });
621
  }
622
  return {
623
  image: parts.map((p) => p.image).join(', '),
 
56
  pores: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: fine fibre and pore streaks between the rings' },
57
  knots: { words: 1, kind: 'number', min: 0, max: 8, int: true, doc: 'rings generator: how many knots in one tile (the pattern repeats, so keep it small)' },
58
  planks: { words: 1, kind: 'number', min: 24, max: 1200, doc: 'rings generator: lay it out as planks this many px tall, with a seam and a slightly different board each' },
59
+ symmetrical: { words: 1, kind: 'word', values: ['both', 'x', 'y'], optional: true, doc: 'mirror every other tile, so each edge meets its own reflection: no seam, and the repeat is twice as far apart. x mirrors left to right, y top to bottom, both (the default) does both. Works on the image-based textures (noise, bump, rings, waves, hex).' },
60
  invert: { words: 0, kind: 'flag', doc: 'swap the two colours' },
61
  };
62
 
 
254
  return null;
255
  }
256
  if (def.kind === 'flag') { over[key] = true; continue; }
257
+ // a setting whose value is optional (symmetrical alone means both)
258
+ if (def.optional && (words[i + 1] === undefined || TEXTURE_KEYS[words[i + 1]] || !def.values.includes(words[i + 1]))) { over[key] = def.values[0]; continue; }
259
  const vals = words.slice(i + 1, i + 1 + def.words);
260
  if (vals.length < def.words || vals.some((v) => TEXTURE_KEYS[v])) {
261
  hooks.error(`texture setting "${key}" needs ${def.words === 1 ? 'a value' : def.words + ' values'}`, def.doc);
 
615
  * -position and -blend-mode, top layer first.
616
  * rc(cssColorWord) -> {r,g,b,a} | null
617
  */
618
+ /**
619
+ * symmetrical: lay the tile out twice over, the second copy flipped, so every edge meets its own mirror image.
620
+ * That is seamless for any image at all, and the pattern now repeats at double the distance. Only the
621
+ * SVG-image textures can be flipped this way; the pure CSS ones (lines, dots, grid, checker, carbon) are
622
+ * already regular and are left alone.
623
+ */
624
+ function mirrorPiece(piece, mode) {
625
+ const m = /^url\("(data:image\/svg\+xml,[^"]*)"\)$/.exec(piece.image);
626
+ const sz = /^([\d.]+)px ([\d.]+)px$/.exec(piece.size || '');
627
+ if (!m || !sz) return piece;
628
+ const w = Number(sz[1]), h = Number(sz[2]);
629
+ const fx = mode === 'x' || mode === 'both', fy = mode === 'y' || mode === 'both';
630
+ const W = fx ? w * 2 : w, H = fy ? h * 2 : h;
631
+ const use = (tx, ty, sx, sy) => `<use href='#t' transform='translate(${tx} ${ty}) scale(${sx} ${sy})'/>`;
632
+ let body = use(0, 0, 1, 1);
633
+ if (fx) body += use(2 * w, 0, -1, 1);
634
+ if (fy) body += use(0, 2 * h, 1, -1);
635
+ if (fx && fy) body += use(2 * w, 2 * h, -1, -1);
636
+ const inner = m[1].replace(/'/g, '%27');
637
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='${W}' height='${H}'><defs><image id='t' width='${w}' height='${h}' href='${inner}'/></defs>${body}</svg>`;
638
+ return { ...piece, image: smallUrl(svg), size: `${W}px ${H}px`, tile: Math.max(piece.tile || 0, W, H) };
639
+ }
640
+
641
  export function buildTexture(layers, rc) {
642
  const parts = [];
643
  for (const spec of layers) {
644
  const builder = BUILDERS[spec.gen];
645
  if (!builder) continue;
646
+ for (const piece of builder(spec, rc)) parts.push({ ...(spec.symmetrical ? mirrorPiece(piece, spec.symmetrical) : piece), animate: spec.animate || 0 });
647
  }
648
  return {
649
  image: parts.map((p) => p.image).join(', '),
public/llms.txt CHANGED
@@ -63,6 +63,12 @@ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
63
  - Alpha on a named colour works: `white/12`, `currentColor/20`. It used to compile to `white/12px`.
64
  - The playground has a canvas.html tab, `?example=` links, lint warnings and an "as CSS" tab. The canvas keeps a short allow-list of tags and drops scripts, handlers, styles and ids.
65
 
 
 
 
 
 
 
66
  ## 0.11.0: wood and the build
67
  - Ten wood species presets: oak, walnut, cherry, maple, pine, mahogany, ebony, birch, oak-planks, walnut-planks. The `rings` generator draws growth rings as vector curves (not stretched noise): `texture: oak figure 0.8 knots 2 planks 120`. Settings: scale (ring spacing), color (latewood), color2 (earlywood), contrast, figure (cathedral arching), warp, pores, knots, planks (board height px), seed, angle 0 or 90. Same seed, same board everywhere; the tile repeats without a seam.
68
  - `dssl build` now BAKES textures into the CSS as background images when every colour is known at build time. Animated textures and unknown colours stay in the runtime, and the report says why.
@@ -84,4 +90,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
84
 
85
  ## Editor support
86
 
87
- 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.11.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
 
63
  - Alpha on a named colour works: `white/12`, `currentColor/20`. It used to compile to `white/12px`.
64
  - The playground has a canvas.html tab, `?example=` links, lint warnings and an "as CSS" tab. The canvas keeps a short allow-list of tags and drops scripts, handlers, styles and ids.
65
 
66
+ ## 0.12.0: fonts, gradients, symmetrical, dev tools
67
+ - `texture: oak symmetrical [x|y|both]` mirrors every other tile: no seam, the repeat is twice as far apart. Works on image textures (noise, bump, rings, waves, hex); pure-CSS patterns are left alone.
68
+ - `fonts { Fraunces: 500 700 800 "Hanken Grotesk": 400 700 italic Inter: 100..900 display optional Brand: from "/f.woff2" 400 }`. Google families become a <link> at run time and an @import in `dssl build`; `from` becomes @font-face.
69
+ - gradient: `repeating linear 45, #fff 0 10, #000 10 20`; `linear 90 in oklch, a -> b`; `a -> 30% -> b` midpoint; `a 0 30%, b 30% 100%` hard bands; `mesh base, color at x y [size n], ...`; layers joined with ` + `; the direction is optional.
70
+ - `DSSL.inspect()` (which rule produced each style, in the page), `DSSL.watch()` (hot reload of loaded .dssl and linked .css, no page reload), `node cli/dssl.mjs dev [dir]`. `data-watch`/`data-inspect` on the script tag, or ?dssl-watch / ?dssl-inspect.
71
+
72
  ## 0.11.0: wood and the build
73
  - Ten wood species presets: oak, walnut, cherry, maple, pine, mahogany, ebony, birch, oak-planks, walnut-planks. The `rings` generator draws growth rings as vector curves (not stretched noise): `texture: oak figure 0.8 knots 2 planks 120`. Settings: scale (ring spacing), color (latewood), color2 (earlywood), contrast, figure (cathedral arching), warp, pores, knots, planks (board height px), seed, angle 0 or 90. Same seed, same board everywhere; the tile repeats without a seam.
74
  - `dssl build` now BAKES textures into the CSS as background images when every colour is known at build time. Animated textures and unknown colours stay in the runtime, and the report says why.
 
90
 
91
  ## Editor support
92
 
93
+ 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.12.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
public/showcase/wood/index.html CHANGED
@@ -5,10 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>Heartwood: a field guide to wood</title>
7
  <meta name="description" content="How wood is built: growth rings, figure, knots, ten species, and how much a board moves when the air dries out. Every texture on the page is generated by DSSL.">
8
- <link rel="preconnect" href="https://fonts.googleapis.com">
9
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700;9..144,800&family=Hanken+Grotesk:wght@400;500;700&display=swap">
11
- <!-- absolute paths, so the page works at /showcase/wood and /showcase/wood/index.html alike. wood.css is real CSS built from static.dssl (textures baked in), so the page is styled before any script runs -->
12
  <link rel="stylesheet" href="/showcase/wood/wood.css">
13
  </head>
14
  <body>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>Heartwood: a field guide to wood</title>
7
  <meta name="description" content="How wood is built: growth rings, figure, knots, ten species, and how much a board moves when the air dries out. Every texture on the page is generated by DSSL.">
8
+ <!-- absolute paths, so the page works at /showcase/wood and /showcase/wood/index.html alike. wood.css is real CSS built from static.dssl (textures baked in, fonts as an @import from its fonts block), so the page is styled before any script runs -->
 
 
 
9
  <link rel="stylesheet" href="/showcase/wood/wood.css">
10
  </head>
11
  <body>
public/showcase/wood/static.dssl CHANGED
@@ -5,6 +5,12 @@
5
  // The tilt, aura and slider fills live in live.dssl, because they need the engine.
6
  // =========================================================================
7
 
 
 
 
 
 
 
8
  * { box-sizing: border-box }
9
  *::before { box-sizing: border-box }
10
  *::after { box-sizing: border-box }
@@ -76,7 +82,7 @@ img, svg { max-width: 100% }
76
  align-items: center
77
  padding: 120 0 80
78
  background-color: color.walnut
79
- texture: oak-planks scale 14 planks 150
80
  color: color.cream
81
  overflow: hidden
82
  }
@@ -85,7 +91,7 @@ img, svg { max-width: 100% }
85
  content: ""
86
  position: absolute
87
  inset: 0
88
- background: radial-gradient(ellipse at 30% 40%, transparent 30%, color.char/55 100%)
89
  pointer-events: none
90
  }
91
 
@@ -195,7 +201,7 @@ img, svg { max-width: 100% }
195
  inset: 0
196
  radius: lg
197
  pointer-events: none
198
- background: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), color.cream/20, transparent 55%)
199
  }
200
 
201
  .note { position: relative; z: 1 }
@@ -379,7 +385,7 @@ img, svg { max-width: 100% }
379
  padding: 28
380
  radius: lg
381
  background-color: color.oak
382
- texture: oak-planks scale 12 planks 60 intensity 1
383
  color: color.char
384
  position: relative
385
  overflow: hidden
@@ -424,7 +430,8 @@ img, svg { max-width: 100% }
424
  min-height: 250
425
  radius: lg
426
  background-color: #2b2723
427
- texture: stone scale 26 intensity 0.55
 
428
  display: grid
429
  place-items: center
430
  }
 
5
  // The tilt, aura and slider fills live in live.dssl, because they need the engine.
6
  // =========================================================================
7
 
8
+ // fonts are their own block: the build writes the @import, the engine adds the <link>
9
+ fonts {
10
+ Fraunces: 500 700 800
11
+ "Hanken Grotesk": 400 500 700
12
+ }
13
+
14
  * { box-sizing: border-box }
15
  *::before { box-sizing: border-box }
16
  *::after { box-sizing: border-box }
 
82
  align-items: center
83
  padding: 120 0 80
84
  background-color: color.walnut
85
+ texture: oak-planks scale 14 planks 150 symmetrical
86
  color: color.cream
87
  overflow: hidden
88
  }
 
91
  content: ""
92
  position: absolute
93
  inset: 0
94
+ gradient: radial ellipse at 30% 40%, transparent 30%, color.char/55 100%
95
  pointer-events: none
96
  }
97
 
 
201
  inset: 0
202
  radius: lg
203
  pointer-events: none
204
+ gradient: radial circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), color.cream/20, transparent 55%
205
  }
206
 
207
  .note { position: relative; z: 1 }
 
385
  padding: 28
386
  radius: lg
387
  background-color: color.oak
388
+ texture: oak-planks scale 12 planks 60 symmetrical
389
  color: color.char
390
  position: relative
391
  overflow: hidden
 
430
  min-height: 250
431
  radius: lg
432
  background-color: #2b2723
433
+ gradient: mesh #1a1512, #ff7a1a at 50% 92% size 60%, #b9380f at 18% 100% size 40%, #b9380f at 82% 100% size 40%
434
+ texture: stone scale 26 intensity 0.5 blend multiply
435
  display: grid
436
  place-items: center
437
  }
public/showcase/wood/wood.css CHANGED
The diff for this file is too large to render. See raw diff
 
public/styles/docs.dssl CHANGED
@@ -906,3 +906,10 @@ behavior snow {
906
  }
907
 
908
  .fx-pane b { font-size: 17 }
 
 
 
 
 
 
 
 
906
  }
907
 
908
  .fx-pane b { font-size: 17 }
909
+
910
+ // ---- 0.12.0: more gradient forms
911
+ .gd-stripes { gradient: repeating linear 45, #fff 0 10, color.ink 10 20 }
912
+ .gd-srgb { gradient: linear 90, #ff0055 -> #00d4ff }
913
+ .gd-oklch { gradient: linear 90 in oklch, #ff0055 -> #00d4ff }
914
+ .gd-band { gradient: linear 90, #ff7a1a 0 30%, #3949ab 30% 100% }
915
+ .gd-mesh { gradient: mesh #14102a, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 45%, #3949ab at 50% 95% }
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], ['glass, tilt, wood, baked textures, counters 0.10-0.11', 'test-v0100.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, wood, baked textures, counters 0.10-0.11', 'test-v0100.mjs', true], ['symmetrical, fonts, gradients, inspector, hot reload 0.12', 'test-v0120.mjs', true],
20
  ];
21
 
22
  if (!skipBrowser && !findChrome()) {
scripts/test-v0120.mjs ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* test-v0120.mjs: what 0.12.0 added.
3
+ * texture: symmetrical mirrored tiling, so the repeat hides and every edge continues
4
+ * fonts { } Google fonts and your own files, no raw CSS
5
+ * gradient: repeating, colour spaces, midpoints, hard bands, mesh, layers
6
+ * DSSL.inspect() which rule produced which style, in the page
7
+ * DSSL.watch(), dssl dev edit a .dssl file and the open page restyles in place
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { mkdtempSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { compile, toCSS, VERSION } from '../public/dssl/index.js';
14
+ import { parseTexture, buildTexture } from '../public/dssl/texture.js';
15
+ import { withBrowser } from './lib/browser-harness.mjs';
16
+ import { decodePng, px, dist } from './lib/pixels.mjs';
17
+
18
+ let pass = 0, fail = 0;
19
+ const ok = (name, cond, extra) => {
20
+ if (cond) { pass++; console.log(` ok ${name}`); }
21
+ else { fail++; console.log(`FAIL ${name}${extra !== undefined ? '; ' + extra : ''}`); }
22
+ };
23
+ const section = (t) => console.log(`\n== ${t}`);
24
+ const errs = (src) => compile(src, 't.dssl').errors;
25
+ const hooks = (list) => ({ error: (m) => list && list.push(m), color: (w) => w, suggest: () => null });
26
+ const rcHex = (c) => { const m = /^#([0-9a-f]{6})$/i.exec(String(c)); return m ? { r: parseInt(m[1].slice(0, 2), 16), g: parseInt(m[1].slice(2, 4), 16), b: parseInt(m[1].slice(4, 6), 16), a: 1 } : null; };
27
+ const make = (t, list) => { const L = parseTexture(t, hooks(list)); return L && buildTexture(L, rcHex); };
28
+
29
+ section('symmetrical: parsing');
30
+ {
31
+ const bare = parseTexture('oak symmetrical', hooks())[0], x = parseTexture('oak symmetrical x', hooks())[0], y = parseTexture('oak symmetrical y', hooks())[0], b = parseTexture('oak symmetrical both', hooks())[0];
32
+ ok('bare symmetrical means both ways', bare.symmetrical === 'both' && b.symmetrical === 'both');
33
+ ok('x and y are read', x.symmetrical === 'x' && y.symmetrical === 'y');
34
+ ok('it can sit in the middle of the other settings', parseTexture('oak symmetrical scale 8', hooks())[0].scale === 8 && parseTexture('oak symmetrical scale 8', hooks())[0].symmetrical === 'both');
35
+ const e = []; parseTexture('oak symmetrical z', hooks(e));
36
+ ok('a typo after it is still an error, not a silent "both"', e.length === 1 && /unknown texture setting "z"/.test(e[0]), e.join());
37
+ }
38
+
39
+ section('symmetrical: the tile');
40
+ {
41
+ const plain = make('oak'), both = make('oak symmetrical'), xo = make('oak symmetrical x'), yo = make('oak symmetrical y');
42
+ ok('both ways doubles the tile in both directions', plain.size === '576px 264px' && both.size === '1152px 528px', both.size);
43
+ ok('x only doubles the width, y only the height', xo.size === '1152px 264px' && yo.size === '576px 528px', `${xo.size} / ${yo.size}`);
44
+ const svg = (t) => decodeURIComponent(t.image.slice(t.image.indexOf(',') + 1, -2));
45
+ ok('the mirrored copies are drawn by flipping one copy, not by repeating the data four times', (svg(both).match(/<use /g) || []).length === 4 && (svg(both).match(/<image /g) || []).length === 1);
46
+ ok('same seed, same bytes', make('oak symmetrical seed 4').image === make('oak symmetrical seed 4').image);
47
+ ok('textures that are plain CSS patterns are left alone', make('lines symmetrical').image === make('lines').image);
48
+ ok('it works on noise, planks and several layers', make('noise symmetrical').count === 1 && make('oak-planks symmetrical').count === 1 && make('grain symmetrical, oak symmetrical').count === 2);
49
+ ok('stays small: one copy of the image, however many flips', make('oak symmetrical').image.length < make('oak').image.length * 1.15 + 1500, `${make('oak').image.length} -> ${make('oak symmetrical').image.length}`);
50
+ }
51
+
52
+ section('symmetrical: pixels');
53
+ const tilePage = (image, w, h, size) => `<!doctype html><style>body{margin:0}div{width:${w}px;height:${h}px;background:${image} 0 0 / ${size} repeat}</style><div></div>`;
54
+ await withBrowser(async ({ open, errors }) => {
55
+ const t = make('noise symmetrical scale 4 seed 3');
56
+ const p = await open('/m'); await p.waitForTimeout(500);
57
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 600, height: 300 } }));
58
+ const [W, H] = t.size.split(' ').map((v) => parseInt(v));
59
+ // noise is per-pixel random, so allow the mirror to land within a pixel either side of where it should
60
+ const near = (a, b) => dist(a, b) < 14;
61
+ const any = (p, xs, ys) => { for (const dx of [0, -1, 1]) for (const dy of [0, -1, 1]) if (near(p, px(png, xs + dx, ys + dy))) return true; return false; };
62
+ let mx = 0, my = 0, both = 0, n = 0;
63
+ for (let y = 5; y < Math.min(H - 5, 290); y += 17) for (let x = 5; x < Math.min(W - 5, 590); x += 13) {
64
+ n++;
65
+ if (any(px(png, x, y), W - 1 - x, y)) mx++;
66
+ if (any(px(png, x, y), x, H - 1 - y)) my++;
67
+ if (any(px(png, x, y), W - 1 - x, H - 1 - y)) both++;
68
+ }
69
+ ok('the left half is the right half flipped', mx / n > 0.95, `${mx}/${n}`);
70
+ ok('the top half is the bottom half flipped', my / n > 0.95, `${my}/${n}`);
71
+ ok('and the opposite corner is both', both / n > 0.95, `${both}/${n}`);
72
+ ok('no page errors', errors.length === 0, errors.join(' | '));
73
+ }, { pages: { '/m': tilePage(make('noise symmetrical scale 4 seed 3').image, 600, 300, make('noise symmetrical scale 4 seed 3').size) }, viewport: { width: 600, height: 300 } });
74
+ await withBrowser(async ({ open }) => {
75
+ const t = make('oak symmetrical');
76
+ const p = await open('/o'); await p.waitForTimeout(600);
77
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 1400, height: 700 } }));
78
+ const colDiff = (a, b) => { let s = 0, c = 0; for (let y = 0; y < 520; y += 2) { const u = px(png, a, y), v = px(png, b, y); s += Math.abs(u[0] - v[0]) + Math.abs(u[1] - v[1]) + Math.abs(u[2] - v[2]); c++; } return s / c; };
79
+ const typical = [100, 300, 700, 900].map((x) => colDiff(x, x + 1)).reduce((a, b) => a + b) / 4;
80
+ ok('across the mirror line (x = 576, the old tile edge) the grain continues', colDiff(575, 576) <= typical * 2 + 3, `${colDiff(575, 576).toFixed(1)} vs ${typical.toFixed(1)}`);
81
+ ok('and across the period (x = 1152)', colDiff(1151, 1152) <= typical * 2 + 3, `${colDiff(1151, 1152).toFixed(1)} vs ${typical.toFixed(1)}`);
82
+ }, { pages: { '/o': tilePage(make('oak symmetrical').image, 1400, 700, make('oak symmetrical').size) }, viewport: { width: 1400, height: 700 } });
83
+
84
+ section('fonts');
85
+ {
86
+ const r = toCSS('fonts {\n Fraunces: 500 700 800\n "Hanken Grotesk": 400 700 italic\n Inter: 100..900 display optional\n Brand: from "/fonts/brand.woff2" 400\n Brand: from "/fonts/brand-bold.woff" 700 italic\n}\n.a { font-family: "Fraunces", serif }');
87
+ ok('compiles with no errors', r.errors.length === 0, JSON.stringify(r.errors));
88
+ ok('Google families become one @import, first in the CSS (after the banner)', /^\/\*[\s\S]*?\*\/\n@import url\("https:\/\/fonts\.googleapis\.com\/css2\?family=Fraunces:wght@500;700;800&family=Hanken\+Grotesk:ital,wght@0,400;0,700;1,400;1,700&family=Inter:wght@100\.\.900&display=optional"\);\n/.test(r.css), r.css.slice(0, 500));
89
+ ok('the @import comes before the @layer statement and every rule', r.css.indexOf('@import') < r.css.indexOf('@layer'));
90
+ ok('your own files become @font-face, with the format guessed from the extension', /@font-face \{\s+font-family: "Brand";\s+src: url\("\/fonts\/brand\.woff2"\) format\("woff2"\);\s+font-weight: 400;\s+font-style: normal;/.test(r.css) && /format\("woff"\);\s+font-weight: 700;\s+font-style: italic/.test(r.css));
91
+ ok('the report counts the faces as raw CSS', r.report.raws === 2, String(r.report.raws));
92
+ ok('a variable range is kept as a range', /Inter:wght@100\.\.900/.test(r.css));
93
+ ok('with no weights the family is requested whole, or with its italic', /family=Lora&display=swap/.test(toCSS('fonts { Lora: display swap }').css) && /family=Lora:ital@0;1&display=swap/.test(toCSS('fonts { Lora: italic }').css));
94
+ ok('display defaults to swap', /&display=swap"/.test(toCSS('fonts { Lora: 400 }').css));
95
+ const bad = (src) => errs(src).map((e) => e.message).join(' | ');
96
+ ok('an unknown word is an error with a hint', /unknown font setting "bold"/.test(bad('fonts {\n Lora: 400 bold\n}')));
97
+ ok('a missing colon is an error', /expected "Family: weights"/.test(bad('fonts {\n Lora 400\n}')));
98
+ ok('one file with two weights is refused', /one font file holds one weight/.test(bad('fonts {\n X: from "a.woff2" 400 700\n}')));
99
+ ok('a bad display value is refused', /display needs one of/.test(bad('fonts {\n X: 400 display fast\n}')));
100
+ ok('a weight out of range is refused', /is not a font weight/.test(bad('fonts {\n X: 4000\n}')));
101
+ ok('inside a rule it is refused', /must be top-level/.test(bad('.a { fonts { X: 400 } }')));
102
+ ok('error lines point at the right line', errs('.a { color: red }\n\nfonts {\n Lora 400\n}')[0].line === 4, String(errs('.a { color: red }\n\nfonts {\n Lora 400\n}')[0].line));
103
+ ok('comments inside the block are ignored', errs('fonts {\n // the display face\n Lora: 400 // regular\n}').length === 0);
104
+ }
105
+
106
+ section('gradients');
107
+ {
108
+ const g = (v) => { const r = compile(`.a { gradient: ${v} }`); const d = r.doc.rules[0] && r.doc.rules[0].decls.find((x) => x.prop === 'background-image'); return { v: d && d.value, e: r.errors.map((e) => e.message) }; };
109
+ ok('the original sentence still works', g('linear 135, #ff0055 -> #00d4ff').v === 'linear-gradient(135deg, #ff0055, #00d4ff)');
110
+ ok('repeating stripes with plain numbers as px', g('repeating linear 45, #fff 0 10, #000 10 20').v === 'repeating-linear-gradient(45deg, #fff 0 10px, #000 10px 20px)');
111
+ ok('repeating radial and conic', /^repeating-radial-gradient\(circle, #fff 0 8px/.test(g('repeating radial circle, #fff 0 8, #000 8 16').v) && /^repeating-conic-gradient\(/.test(g('repeating conic, #f00 0 30, #00f 30 60').v));
112
+ ok('conic positions are degrees', /#0f0 120deg/.test(g('conic from 45, #f00 0, #0f0 120, #00f 240').v));
113
+ ok('the colour space of the blend', g('linear 90 in oklch, #f00 -> #00f').v === 'linear-gradient(90deg in oklch, #f00, #00f)' && g('linear 90 in hsl longer hue, #f00, #00f').v === 'linear-gradient(90deg in hsl longer hue, #f00, #00f)');
114
+ ok('a colour space alone, with no angle', g('linear in oklab, #f00 -> #00f').v === 'linear-gradient(180deg in oklab, #f00, #00f)');
115
+ ok('a lone position is a midpoint', g('linear 90, #f00 -> 30% -> #00f').v === 'linear-gradient(90deg, #f00, 30%, #00f)');
116
+ ok('a midpoint at the edge is an error', g('linear 90, 30% -> #f00 -> #00f').e.length === 1);
117
+ ok('hard bands with two positions', g('linear 90, #f00 0 30%, #00f 30% 100%').v === 'linear-gradient(90deg, #f00 0 30%, #00f 30% 100%)');
118
+ ok('layers with +, first on top', /^radial-gradient\(at top left, color-mix\([^)]*\), transparent\), linear-gradient\(135deg, #123, #456\)$/.test(g('radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456').v));
119
+ const mesh = g('mesh #fff4e6, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 40%').v;
120
+ ok('mesh: soft blobs over a base colour', /^radial-gradient\(circle farthest-side at 15% 20%, #ff7a1a, transparent 60%\), radial-gradient\(circle farthest-side at 80% 30%, #c2185b, transparent 40%\), linear-gradient\(#fff4e6, #fff4e6\)$/.test(mesh), mesh);
121
+ ok('mesh needs a base and a blob, and a position per blob', g('mesh #fff').e.length === 1 && g('mesh #fff, #f00 at 10%').e.length === 1);
122
+ ok('a direction is optional: stops can follow the kind straight away', g('conic, #f00 -> #00f').v === 'conic-gradient(#f00, #00f)' && g('linear #f00 -> #00f').v === 'linear-gradient(180deg, #f00, #00f)' && g('radial #fff -> #000').v === 'radial-gradient(#fff, #000)');
123
+ ok('bad colours are still caught inside the new forms', g('repeating linear 45, banana 0 10, #000 10 20').e.length >= 1);
124
+ ok('gradient-text takes the layered form too', compile('.a { gradient-text: linear 90, #f00 -> #00f }').errors.length === 0);
125
+ }
126
+
127
+ section('inspector and hot reload (real browser)');
128
+ const page = (extra = '') => `<!doctype html><meta charset=utf-8><body style="margin:0"><style type="text/dssl" data-dssl-name="inline.dssl">.box { position: absolute; left: 40px; top: 40px; width: 200px; height: 120px; background: #345; radius: 12 }\n.box { color: #fff }</style><div class="box">hello</div>${extra}<script type="module" src="/dssl/boot.js"></script>`;
129
+ const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global')`);
130
+ await withBrowser(async ({ open, errors }) => {
131
+ const p = await open('/i'); await ready(p); await p.waitForTimeout(300);
132
+ ok('the dev tools are not loaded for a normal visitor', await p.evaluate(() => !document.getElementById('dssl-dev-host')));
133
+ await p.evaluate(() => DSSL.inspect()); await p.waitForTimeout(200);
134
+ ok('DSSL.inspect() opens the panel', await p.evaluate(() => !!document.getElementById('dssl-dev-host')));
135
+ await p.mouse.move(100, 80); await p.waitForTimeout(300);
136
+ const text = await p.evaluate(() => document.getElementById('dssl-dev-host').shadowRoot.querySelector('.body').innerText);
137
+ ok('hovering shows the element, the winning declarations and where they came from', /div\.box/.test(text) && /background: #345/.test(text) && /inline\.dssl:1/.test(text) && /color: #fff/.test(text), text.slice(0, 300));
138
+ ok('and the rules in cascade order', /\.box[\s\S]*\.box/.test(text.split('rules, in cascade order')[1] || ''));
139
+ const box = await p.evaluate(() => { const b = document.getElementById('dssl-dev-host').shadowRoot.querySelector('.box'); return { w: parseFloat(b.style.width), h: parseFloat(b.style.height) }; });
140
+ ok('the element under the pointer is outlined', Math.abs(box.w - 200) < 2 && Math.abs(box.h - 120) < 2, JSON.stringify(box));
141
+ await p.mouse.click(100, 80); await p.mouse.move(600, 500); await p.waitForTimeout(200);
142
+ ok('clicking pins it, so moving away keeps its rules on screen', await p.evaluate(() => /div\.box/.test(document.getElementById('dssl-dev-host').shadowRoot.querySelector('.body').innerText)));
143
+ await p.keyboard.press('Escape'); await p.waitForTimeout(100);
144
+ ok('Escape releases the pin, then closes', await p.evaluate(() => !!document.getElementById('dssl-dev-host'))); // first Esc only released
145
+ await p.keyboard.press('Escape'); await p.waitForTimeout(100);
146
+ ok('the second Escape closes it and leaves nothing behind', await p.evaluate(() => !document.getElementById('dssl-dev-host')));
147
+ ok('no page errors', errors.length === 0, errors.join(' | '));
148
+ }, { pages: { '/i': page() }, viewport: { width: 800, height: 600 } });
149
+
150
+ {
151
+ const files = { '/s.dssl': '.w { background: #f00; width: 80px; height: 40px }' };
152
+ const pages = { '/w': `<!doctype html><meta charset=utf-8><body><div class="w">x</div><script type="module" src="/dssl/boot.js" data-watch></script><link rel="stylesheet/dssl" href="/s.dssl">`, '/s.dssl': files['/s.dssl'] };
153
+ await withBrowser(async ({ open, errors }) => {
154
+ const p = await open('/w'); await ready(p); await p.waitForTimeout(500);
155
+ await p.evaluate(() => { window.__marker = 'same page'; });
156
+ const bg = () => p.evaluate(() => getComputedStyle(document.querySelector('.w')).backgroundColor);
157
+ ok('data-watch turns hot reload on from the script tag', (await bg()) === 'rgb(255, 0, 0)');
158
+ pages['/s.dssl'] = '.w { background: #0a0; width: 80px; height: 40px }';
159
+ await p.waitForFunction(`getComputedStyle(document.querySelector('.w')).backgroundColor === 'rgb(0, 170, 0)'`, null, { timeout: 5000 });
160
+ ok('editing the file restyles the page', (await bg()) === 'rgb(0, 170, 0)');
161
+ ok('without reloading it (state survives)', (await p.evaluate(() => window.__marker)) === 'same page');
162
+ ok('and says so', /reloaded s\.dssl/.test(await p.evaluate(() => document.querySelector('div[role=status]') && document.querySelector('div[role=status]').textContent) || ''));
163
+ pages['/s.dssl'] = '.w { backgrond: #00f; width: 80px }';
164
+ await p.waitForFunction(`document.querySelector('div[role=status]') && /error/.test(document.querySelector('div[role=status]').textContent)`, null, { timeout: 5000 });
165
+ const toast = await p.evaluate(() => document.querySelector('div[role=status]').textContent);
166
+ ok('a mistake in the file shows the error and line, on the page', /1 error, line 1: unknown property "backgrond"/.test(toast), toast);
167
+ pages['/s.dssl'] = '.w { background: #00f; width: 80px; height: 40px }';
168
+ await p.waitForFunction(`getComputedStyle(document.querySelector('.w')).backgroundColor === 'rgb(0, 0, 255)'`, null, { timeout: 5000 });
169
+ ok('fixing it recovers', true);
170
+ await p.evaluate(() => DSSL.watch(false)); const before = await bg();
171
+ pages['/s.dssl'] = '.w { background: #fff; width: 80px; height: 40px }'; await p.waitForTimeout(1800);
172
+ ok('DSSL.watch(false) stops following', (await bg()) === before);
173
+ ok('no page errors', errors.length === 0, errors.join(' | '));
174
+ }, { pages, viewport: { width: 400, height: 300 } });
175
+ }
176
+
177
+ section('dssl dev');
178
+ {
179
+ const dir = mkdtempSync(join(tmpdir(), 'dssl-dev-'));
180
+ writeFileSync(join(dir, 'index.html'), '<!doctype html><body><div class="x">hi</div><script type="module" src="/dssl/boot.js"></script><link rel="stylesheet/dssl" href="s.dssl"></body>');
181
+ writeFileSync(join(dir, 's.dssl'), '.x { color: red }');
182
+ const port = 4200 + Math.floor(Math.random() * 500);
183
+ const child = spawn(process.execPath, ['--no-warnings', new URL('../cli/dssl.mjs', import.meta.url).pathname, 'dev', dir, '--port', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] });
184
+ let out = ''; child.stdout.on('data', (d) => { out += d; });
185
+ for (let i = 0; i < 40 && !/localhost/.test(out); i++) await new Promise((r) => setTimeout(r, 100));
186
+ try {
187
+ const res = await fetch(`http://localhost:${port}/`);
188
+ const html = await res.text();
189
+ ok('serves the page and adds DSSL.watch() to it', res.status === 200 && /DSSL\.watch\(\)/.test(html) && /<\/script><\/body>$/.test(html.trim()));
190
+ ok('never caches, so a reload always sees the latest file', res.headers.get('cache-control') === 'no-store');
191
+ ok('serves the engine from the install when the folder has none', (await fetch(`http://localhost:${port}/dssl/boot.js`)).status === 200);
192
+ ok('serves your .dssl files untouched', (await (await fetch(`http://localhost:${port}/s.dssl`)).text()) === '.x { color: red }');
193
+ ok('refuses to leave the folder', (await fetch(`http://localhost:${port}/..%2f..%2fetc%2fpasswd`)).status !== 200);
194
+ ok('404 for a missing file', (await fetch(`http://localhost:${port}/nope.css`)).status === 404);
195
+ } finally { child.kill(); }
196
+ }
197
+
198
+ section('versions');
199
+ ok('the engine says 0.12.x or later', /^0\.(1[2-9]|[2-9]\d)\./.test(VERSION), VERSION);
200
+
201
+ console.log(`\n${pass} passed, ${fail} failed`);
202
+ process.exit(fail ? 1 : 0);
scripts/test-v090.mjs CHANGED
@@ -301,7 +301,7 @@ section('dssl build: what stays in the runtime');
301
 
302
  /* ------------------------------------------------------------------ */
303
  section('release');
304
- ok('the engine version is 0.9 or later', /^0\.(9|1[01])\./.test(VERSION), VERSION);
305
 
306
  console.log(`\n${pass} passed, ${fail} failed`);
307
  process.exit(fail ? 1 : 0);
 
301
 
302
  /* ------------------------------------------------------------------ */
303
  section('release');
304
+ ok('the engine version is 0.9 or later', /^0\.(9|1[0-2])\./.test(VERSION), VERSION);
305
 
306
  console.log(`\n${pass} passed, ${fail} failed`);
307
  process.exit(fail ? 1 : 0);
src/components/dssl/views/ChangelogView.tsx CHANGED
@@ -11,6 +11,19 @@ const ENTRIES: {
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  {
15
  version: "v0.11.0",
16
  date: "growth phase \u00b7 the wood drop",
 
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
14
+ {
15
+ version: "v0.12.0",
16
+ date: "growth phase \u00b7 the rest of the list",
17
+ title: "Fonts, gradients, mirrored tiles, an inspector and hot reload.",
18
+ why: "the wood critic's list had four things we said we had not done, and one limit. Now it is the whole list, except a real browser DevTools panel, which needs an extension.",
19
+ kind: "add",
20
+ body:
21
+ "texture: symmetrical mirrors every other tile, so the edges always meet and the repeat is twice as far apart (on wood it reads as a book-matched board). It is drawn by flipping one copy, so the file barely grows. A fonts { } block replaces raw CSS: Google families with weights, ranges and italics become a link at run time and an @import in dssl build, and your own files become @font-face. gradient grew repeating forms, colour spaces (in oklch), midpoints, hard bands, mesh (soft blobs over a base) and layers joined with +. DSSL.inspect() shows which rule produced each style, in the page, with file and line. DSSL.watch() and the new dssl dev command restyle the open page when you save a .dssl file, with no reload, and show errors as a toast. The Heartwood page now uses the font block, mirrored planks, a gradient mesh for the hearth glow, and the layered gradient sentence.",
22
+ code: `fonts { Fraunces: 500 700 800 }
23
+ .floor { texture: oak-planks symmetrical }
24
+ .hearth { gradient: mesh #1a1512, #ff7a1a at 50% 92% size 60%, #b9380f at 20% 100% size 40% }
25
+ // in the console while you work: DSSL.inspect(), DSSL.watch()`,
26
+ },
27
  {
28
  version: "v0.11.0",
29
  date: "growth phase \u00b7 the wood drop",
src/components/dssl/views/DocsView.tsx CHANGED
@@ -27,6 +27,8 @@ const SIDE = [
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" },
32
  { head: null, id: "performance", label: "Performance: freeze & explain" },
@@ -739,6 +741,30 @@ motion wobble {
739
  automatically, a gradient at 100% has nowhere to slide.
740
  </p>
741
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  </section>
743
 
744
  <section className="doc-block" id="texture">
@@ -771,7 +797,7 @@ behavior old-leather {
771
  </div>
772
  <p className="doc-lead fine-print">
773
  Every swatch above is a plain box with one rule, like <code>.tx-marble {"{"} texture: marble {"}"}</code>,
774
- over the same tan colour. Hover a swatch for what it is meant to look like. The last ten are wood: drawn as
775
  growth rings, not stretched noise, so they take <code>figure</code>, <code>warp</code>, <code>pores</code>,{" "}
776
  <code>knots</code> and <code>planks</code>. <a href="/showcase/wood/">See a whole page built from them</a>, with
777
  the textures baked into real CSS by <code>dssl build</code>.
@@ -958,6 +984,64 @@ input.mood { // particles leave the thumb, wherever the va
958
  </p>
959
  </section>
960
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
961
  <section className="doc-block" id="designer">
962
  <h2 className="section-title">Designer, import and lint</h2>
963
  <p className="doc-lead">
 
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: "fonts", label: "Fonts" },
31
+ { head: null, id: "devtools", label: "Inspector and hot reload" },
32
  { head: null, id: "errors", label: "Errors" },
33
  { head: null, id: "api", label: "JS API" },
34
  { head: null, id: "performance", label: "Performance: freeze & explain" },
 
741
  automatically, a gradient at 100% has nowhere to slide.
742
  </p>
743
  </div>
744
+ <h3 className="doc-h3">More forms</h3>
745
+ <p className="doc-lead">
746
+ The sentence grew up in 0.12.0. Any kind can be <code>repeating</code>, the blend can happen in another colour
747
+ space, a lone position between two stops moves the midpoint, two positions make a hard band, <code>mesh</code>{" "}
748
+ lays soft blobs of colour over a base, and layers are joined with <code>+</code>, first on top.
749
+ </p>
750
+ <CodeBlock file="gradient-forms.dssl" code={`.stripes { gradient: repeating linear 45, #fff 0 10, color.ink 10 20 } // bare numbers are px (deg on conic)
751
+ .blend { gradient: linear 90 in oklch, #ff0055 -> #00d4ff } // no muddy middle
752
+ .mid { gradient: linear 90, #f00 -> 30% -> #00f } // the midpoint sits at 30%
753
+ .band { gradient: linear 90, #f00 0 30%, #00f 30% 100% } // a hard edge
754
+ .aurora { gradient: mesh #14102a, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 45%, #3949ab at 50% 95% }
755
+ .sheen { gradient: radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 }`} />
756
+ <div className="demo-box wide">
757
+ <div className="gd-sw-row">
758
+ <div className="gd-sw gd-stripes" />
759
+ <div className="gd-sw gd-srgb" />
760
+ <div className="gd-sw gd-oklch" />
761
+ <div className="gd-sw gd-band" />
762
+ <div className="gd-sw gd-mesh" />
763
+ </div>
764
+ <p className="doc-lead fine-print">
765
+ Left to right: repeating stripes, the same red to cyan blended in sRGB and in oklch, a hard band, and a mesh.
766
+ </p>
767
+ </div>
768
  </section>
769
 
770
  <section className="doc-block" id="texture">
 
797
  </div>
798
  <p className="doc-lead fine-print">
799
  Every swatch above is a plain box with one rule, like <code>.tx-marble {"{"} texture: marble {"}"}</code>,
800
+ over the same tan colour. Hover a swatch for what it is meant to look like. Add <code>symmetrical</code> to any image texture and every other tile is mirrored, so the edges always meet and the repeat is twice as far apart. The last ten are wood: drawn as
801
  growth rings, not stretched noise, so they take <code>figure</code>, <code>warp</code>, <code>pores</code>,{" "}
802
  <code>knots</code> and <code>planks</code>. <a href="/showcase/wood/">See a whole page built from them</a>, with
803
  the textures baked into real CSS by <code>dssl build</code>.
 
984
  </p>
985
  </section>
986
 
987
+ <section className="doc-block" id="fonts">
988
+ <h2 className="section-title">Fonts</h2>
989
+ <p className="doc-lead">
990
+ Loading a font used to mean a <code>raw {"{"} {"}"}</code> block of plain CSS. Now it is its own block, one
991
+ line per family.
992
+ </p>
993
+ <CodeBlock file="fonts.dssl" code={`fonts {
994
+ Fraunces: 500 700 800 // from Google Fonts, these weights
995
+ "Hanken Grotesk": 400 700 italic // and the italics
996
+ Inter: 100..900 display optional // a variable range, and how it swaps in
997
+ Brand: from "/fonts/brand.woff2" 400 // your own file: becomes @font-face
998
+ Brand: from "/fonts/brand-bold.woff2" 700
999
+ }
1000
+
1001
+ tokens { font { display: "Fraunces", Georgia, serif } }`} />
1002
+ <p className="doc-lead">
1003
+ At run time the engine adds the <code>&lt;link&gt;</code> to the page head (with preconnects), and removes it again if
1004
+ no stylesheet asks for it any more. <code>dssl build</code> writes an <code>@import</code> as the first line of the
1005
+ CSS. Your own files become <code>@font-face</code> with the format guessed from the extension. One file holds one
1006
+ weight, or one variable range, so use a line per file.
1007
+ </p>
1008
+ <p className="tbl-note">
1009
+ Google&apos;s servers see everyone who loads one of their fonts, which some privacy laws treat as sharing a
1010
+ visitor&apos;s address. If that matters to you, download the files and use <code>from</code>.
1011
+ </p>
1012
+ </section>
1013
+
1014
+ <section className="doc-block" id="devtools">
1015
+ <h2 className="section-title">Inspector and hot reload</h2>
1016
+ <p className="doc-lead">
1017
+ Two tools for the person writing the styles. Neither is loaded for visitors: they are imported the first
1018
+ time you ask.
1019
+ </p>
1020
+ <CodeBlock file="console" code={`DSSL.inspect() // hover any element: which rule won each property, and in which file and line
1021
+ DSSL.watch() // edit a .dssl file and the open page restyles, with no reload
1022
+
1023
+ // or switch them on from the page, for a dev build only
1024
+ <script type="module" src="/dssl/boot.js" data-watch data-inspect></script>
1025
+ // or from the address bar: ?dssl-watch ?dssl-inspect`} />
1026
+ <p className="doc-lead">
1027
+ <b>The inspector</b> outlines the element under the pointer and lists what applied (each declaration with its
1028
+ file, line and selector), then every matching rule in cascade order, with its layer and any state. Click to pin an
1029
+ element so you can read the panel; <code>Esc</code> releases it, and a second <code>Esc</code> closes the panel.
1030
+ It is the same answer as <code>DSSL.explain()</code>, without opening the console.
1031
+ </p>
1032
+ <p className="doc-lead">
1033
+ <b>Hot reload</b> polls the .dssl files you loaded (and any built .css the page links) and applies a file again the
1034
+ moment its text changes. The page does not reload, so scroll position, open menus and form input survive. A mistake
1035
+ shows as a toast with the line and message, and fixing it recovers. <code>DSSL.watch(false)</code> stops it.
1036
+ </p>
1037
+ <CodeBlock file="terminal" code={`node cli/dssl.mjs dev . # serve this folder, never cached, with hot reload added to every page
1038
+ node cli/dssl.mjs dev site --port 4000 --inspect`} />
1039
+ <p className="tbl-note">
1040
+ Hot reload needs a server that lets the browser re-fetch files. <code>dssl dev</code> is one; most dev servers are too.
1041
+ It does not follow inline <code>&lt;style type=&quot;text/dssl&quot;&gt;</code> blocks, because there is no file to watch.
1042
+ </p>
1043
+ </section>
1044
+
1045
  <section className="doc-block" id="designer">
1046
  <h2 className="section-title">Designer, import and lint</h2>
1047
  <p className="doc-lead">
src/lib/vocab.json CHANGED
@@ -295,6 +295,11 @@
295
  "doc": "rings generator: lay it out as planks this many px tall, with a seam and a slightly different board each",
296
  "words": 1
297
  },
 
 
 
 
 
298
  {
299
  "key": "invert",
300
  "doc": "swap the two colours",
 
295
  "doc": "rings generator: lay it out as planks this many px tall, with a seam and a slightly different board each",
296
  "words": 1
297
  },
298
+ {
299
+ "key": "symmetrical",
300
+ "doc": "mirror every other tile, so each edge meets its own reflection: no seam, and the repeat is twice as far apart. x mirrors left to right, y top to bottom, both (the default) does both. Works on the image-based textures (noise, bump, rings, waves, hex).",
301
+ "words": 1
302
+ },
303
  {
304
  "key": "invert",
305
  "doc": "swap the two colours",