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 +48 -1
- PARITY.md +9 -4
- README.md +14 -1
- cli/dssl.mjs +41 -3
- dssl-vscode/CHANGELOG.md +6 -0
- dssl-vscode/lib/engine/fonts.js +112 -0
- dssl-vscode/lib/engine/parser.js +3 -2
- dssl-vscode/lib/engine/resolver.js +100 -12
- dssl-vscode/lib/engine/texture.js +27 -1
- dssl-vscode/lib/props-docs.js +3 -2
- dssl-vscode/package.json +1 -1
- llms.txt +7 -1
- package.json +1 -1
- public/dssl/dev.js +178 -0
- public/dssl/engine.js +25 -0
- public/dssl/fonts.js +112 -0
- public/dssl/index.js +34 -2
- public/dssl/parser.js +3 -2
- public/dssl/resolver.js +100 -12
- public/dssl/static.js +4 -0
- public/dssl/texture.js +27 -1
- public/llms.txt +7 -1
- public/showcase/wood/index.html +1 -4
- public/showcase/wood/static.dssl +12 -5
- public/showcase/wood/wood.css +0 -0
- public/styles/docs.dssl +7 -0
- scripts/test-all.mjs +1 -1
- scripts/test-v0120.mjs +202 -0
- scripts/test-v090.mjs +1 -1
- src/components/dssl/views/ChangelogView.tsx +13 -0
- src/components/dssl/views/DocsView.tsx +85 -1
- src/lib/vocab.json +5 -0
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.
|
| 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 {}` |
|
| 80 |
-
| Gradients only cover simple cases |
|
| 81 |
-
| Dev-tools inspector for which rule produced a style |
|
| 82 |
-
| Hot reload |
|
|
|
|
| 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.
|
| 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 === '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
| 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,
|
| 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
|
|
|
|
|
|
|
| 431 |
if (!m) {
|
| 432 |
ctx.errors.push({
|
| 433 |
file: ctx.file, line: ctx.line,
|
| 434 |
-
message: `gradient needs a kind: "linear", "radial" or "
|
| 435 |
hint: 'try gradient: linear 135, color.accent -> color.ok',
|
| 436 |
});
|
| 437 |
return null;
|
| 438 |
}
|
| 439 |
-
const
|
| 440 |
-
const
|
|
|
|
| 441 |
const DIR_RE = /^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i;
|
| 442 |
-
const isPrefixShaped = (
|
| 443 |
-
const s =
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
let colorPart = s, pos = '';
|
| 518 |
-
|
| 519 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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`.
|
| 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.
|
| 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.
|
|
|
|
| 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.
|
| 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) => ({ '&': '&', '<': '<', '>': '>' }[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.
|
| 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,
|
| 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
|
|
|
|
|
|
|
| 431 |
if (!m) {
|
| 432 |
ctx.errors.push({
|
| 433 |
file: ctx.file, line: ctx.line,
|
| 434 |
-
message: `gradient needs a kind: "linear", "radial" or "
|
| 435 |
hint: 'try gradient: linear 135, color.accent -> color.ok',
|
| 436 |
});
|
| 437 |
return null;
|
| 438 |
}
|
| 439 |
-
const
|
| 440 |
-
const
|
|
|
|
| 441 |
const DIR_RE = /^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i;
|
| 442 |
-
const isPrefixShaped = (
|
| 443 |
-
const s =
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
let colorPart = s, pos = '';
|
| 518 |
-
|
| 519 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
|
|
|
| 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 |
-
<
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
|
|
|
| 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[
|
| 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><link></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's servers see everyone who loads one of their fonts, which some privacy laws treat as sharing a
|
| 1010 |
+
visitor'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><style type="text/dssl"></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",
|