EHS-Studio Claude Sonnet 5.5 commited on
Commit
08132d7
·
1 Parent(s): 794f4bd

v0.11.0: real wood textures, textures baked into dssl build, CSS counters, calmer contrast lint, and the Heartwood showcase page

Browse files
GUIDE.md CHANGED
@@ -281,7 +281,7 @@ replaced, and taking the texture away puts it back exactly as it was.
281
  .poster { background: #e9e2d0; texture: paper, scanlines intensity 0.1 } // a comma stacks layers
282
  ```
283
 
284
- There are 30 presets. They are not magic: each one is a stored combination of a few
285
  generators (`noise`, `bump`, `lines`, `dots`, `grid`, `checker`, `weave`, `carbon`, `waves`, `hex`) and the settings below, so anything a preset does you
286
  can write yourself.
287
 
@@ -362,9 +362,64 @@ A few things worth knowing:
362
  - `animate 90` makes film-grain shimmer by stepping the texture every 90 ms. It stands still
363
  for visitors who ask for reduced motion, and `DSSL.freeze()` stops it.
364
  - Textures are SVG filters and gradients, drawn once and cached. A page full of them is cheap.
365
- - `texture` is an engine feature, so `dssl build` leaves it in the runtime and says so in its report.
366
  - Inside an aura, `texture(grain)` makes the light and the particles grainy. See below.
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  ### Aura
369
 
370
  An aura is light and particles around an element. **There are no presets.** Fire, an
@@ -627,14 +682,14 @@ Three ways to get it:
627
 
628
  1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
629
  https://open-vsx.org/extension/ehs-studio/dssl-vscode
630
- 2. VS Code: download `dssl-vscode/dssl-vscode-0.10.0.vsix` from the repo files, then
631
  Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
632
  3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
633
 
634
  What it does: real-compiler diagnostics (same errors the browser prints,
635
  did-you-mean included), completions folded from your workspace token registry,
636
  hover docs, and a TextMate grammar for `.dssl`. Since 0.9.0 it also completes and
637
- explains the new vocabulary: after `texture:` it offers the 30 presets and every
638
  setting, after `aura-glow:` only glow settings, after `aura-particle:` only particle
639
  ones, after `slider:` the clauses.
640
 
@@ -647,7 +702,7 @@ from the command palette (or the paint-can button on a `.dssl` tab):
647
  and remove, a live preview drawn by the same renderer the engine uses (on dark, light or slate),
648
  a graph of one particle's opacity and size over its life, and a live graph of particles alive and
649
  frame time.
650
- - **Texture tab.** All 30 presets and 10 generators as thumbnails, up to four stacked layers, every
651
  setting as a slider, colour pickers, a preview over the colour you choose.
652
  - **Slider tab.** A live range input styled with the generated rules, with a bar editor for histogram sliders.
653
 
 
281
  .poster { background: #e9e2d0; texture: paper, scanlines intensity 0.1 } // a comma stacks layers
282
  ```
283
 
284
+ There are 40 presets: 30 general ones and ten wood species (see "Wood" below). They are not magic: each one is a stored combination of a few
285
  generators (`noise`, `bump`, `lines`, `dots`, `grid`, `checker`, `weave`, `carbon`, `waves`, `hex`) and the settings below, so anything a preset does you
286
  can write yourself.
287
 
 
362
  - `animate 90` makes film-grain shimmer by stepping the texture every 90 ms. It stands still
363
  for visitors who ask for reduced motion, and `DSSL.freeze()` stops it.
364
  - Textures are SVG filters and gradients, drawn once and cached. A page full of them is cheap.
365
+ - `texture` is built by the engine at run time. `dssl build` bakes it into the CSS when it can, and says what it could not.
366
  - Inside an aura, `texture(grain)` makes the light and the particles grainy. See below.
367
 
368
+ ### Wood
369
+
370
+ `wood` was one generic grain. The `rings` generator draws real structure: each growth ring is a curve,
371
+ the spacing wobbles, the rings arch into cathedral figure, pores run between them, and planks and
372
+ knots are drawn on top. There are ten presets: `oak`, `walnut`, `cherry`, `maple`, `pine`, `mahogany`,
373
+ `ebony`, `birch`, and two laid as boards, `oak-planks` and `walnut-planks`.
374
+
375
+ ```dssl
376
+ .table { background-color: #c9a574; texture: oak planks 120 } // laid as boards 120px tall
377
+ .panel { texture: walnut figure 0.9 warp 0.7 } // more arching, more wobble
378
+ .shelf { texture: pine knots 3 scale 18 } // three knots per tile
379
+ .post { texture: oak angle 90 } // grain running up and down
380
+ .custom { texture: rings scale 8 color #3a2410 color2 #b98a52 seed 21 } // your own species
381
+ ```
382
+
383
+ | setting | meaning |
384
+ |---|---|
385
+ | `scale` | ring spacing in px |
386
+ | `color`, `color2` | latewood (the dark ring) and earlywood (the pale body) |
387
+ | `contrast` | how dark the rings are, 1.2 is the default |
388
+ | `figure` | 0 to 1: how strongly the rings arch |
389
+ | `warp` | 0 to 1: how much each ring wobbles |
390
+ | `pores` | 0 to 1: fine fibre streaks between rings |
391
+ | `knots` | 0 to 8 knots in one tile |
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
398
+ different seeds on different elements when that matters.
399
+
400
+ `dssl build` now **bakes textures into the CSS** as background images when every colour is known at
401
+ build time, which is almost always (tokens count). A rule with `background-color: color.walnut;
402
+ texture: oak` comes out as a real `background` with the image on top. Textures that animate, or use a
403
+ colour that is not known at build time, stay in the runtime, and the build report says which and why.
404
+ The wood showcase (`public/showcase/wood/`) does exactly this: `static.dssl` is built to `wood.css`
405
+ with `node scripts/build-wood.mjs`, and only the tilt, the fire and the slider fills are left for the engine.
406
+
407
+ ### No flash before the engine boots
408
+
409
+ A page that loads its look through the engine can show unstyled for a moment. Two fixes. Build the
410
+ static part with `dssl build` and link the real CSS, as the wood page does. Or hide the page until the
411
+ engine is ready: the engine adds the class `dssl-ready` to `<html>` when it has applied your sheets.
412
+
413
+ ```html
414
+ <style>html:not(.dssl-ready) body { visibility: hidden }</style>
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
421
+ `counters(item, ".")` work. The names inside `counter()` are yours, not tokens.
422
+
423
  ### Aura
424
 
425
  An aura is light and particles around an element. **There are no presets.** Fire, an
 
682
 
683
  1. VSCodium: open the Extensions tab, search "DSSL" and install. It is on Open VSX:
684
  https://open-vsx.org/extension/ehs-studio/dssl-vscode
685
+ 2. VS Code: download `dssl-vscode/dssl-vscode-0.11.0.vsix` from the repo files, then
686
  Extensions panel, `...`, Install from VSIX. (The Microsoft Marketplace listing is not published yet.)
687
  3. Build it yourself: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
688
 
689
  What it does: real-compiler diagnostics (same errors the browser prints,
690
  did-you-mean included), completions folded from your workspace token registry,
691
  hover docs, and a TextMate grammar for `.dssl`. Since 0.9.0 it also completes and
692
+ explains the new vocabulary: after `texture:` it offers the presets and every
693
  setting, after `aura-glow:` only glow settings, after `aura-particle:` only particle
694
  ones, after `slider:` the clauses.
695
 
 
702
  and remove, a live preview drawn by the same renderer the engine uses (on dark, light or slate),
703
  a graph of one particle's opacity and size over its life, and a live graph of particles alive and
704
  frame time.
705
+ - **Texture tab.** All the presets and 11 generators as thumbnails, up to four stacked layers, every
706
  setting as a slider, colour pickers, a preview over the colour you choose.
707
  - **Slider tab.** A live range input styled with the generated rules, with a bar editor for histogram sliders.
708
 
PARITY.md CHANGED
@@ -25,7 +25,7 @@ The point of this table is to stop two bad arguments: "DSSL is missing everythin
25
  | Flexbox / Grid | raw properties pass through + `row:` `column:` `grid:` `stack:` sugar | shipped | shorthands write `display`; the compiler warns when that clobbers an explicit `display:` |
26
  | `place-items` / alignment | `center: both` | shipped | the engine promotes a formatting context when missing, so alignment cannot silently no-op |
27
  | Transitions | per-property: `transition: 200 snap transform` | shipped | honors `reduced-motion` |
28
- | Background patterns and noise | `texture:` (30 presets, 10 generators, your own) | shipped | there is no CSS shorthand for "grain on top of my background"; this one keeps the author's background and removes cleanly. Runtime-only |
29
  | Glow, particles, fire | `aura-glow`, `aura-particle`, `glow-particle`, `aura-glow-particle`, `aura:` | shipped | CSS has `box-shadow` and `filter: drop-shadow`, and nothing for particles. No presets by design. Runtime-only |
30
  | Styling `<input type="range">` | `slider:` writes the `::-webkit-slider-*` and `::-moz-range-*` rules together | shipped | Chrome tested; the Firefox rules are generated but not run yet. Histogram sliders via `graph` |
31
  | Accessibility hints in the compiler | contrast, removed focus outline, small targets (warnings) | shipped | CSS says nothing; these never fail a build unless you pass `--strict` |
@@ -62,6 +62,25 @@ 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.10.0: glass, tilt, and a silent colour bug
66
 
67
  | Need | DSSL | Status | Notes |
 
25
  | Flexbox / Grid | raw properties pass through + `row:` `column:` `grid:` `stack:` sugar | shipped | shorthands write `display`; the compiler warns when that clobbers an explicit `display:` |
26
  | `place-items` / alignment | `center: both` | shipped | the engine promotes a formatting context when missing, so alignment cannot silently no-op |
27
  | Transitions | per-property: `transition: 200 snap transform` | shipped | honors `reduced-motion` |
28
+ | Background patterns and noise | `texture:` (40 presets including ten wood species, 11 generators, your own) | shipped | there is no CSS shorthand for "grain on top of my background"; this one keeps the author's background and removes cleanly. Runtime-only |
29
  | Glow, particles, fire | `aura-glow`, `aura-particle`, `glow-particle`, `aura-glow-particle`, `aura:` | shipped | CSS has `box-shadow` and `filter: drop-shadow`, and nothing for particles. No presets by design. Runtime-only |
30
  | Styling `<input type="range">` | `slider:` writes the `::-webkit-slider-*` and `::-moz-range-*` rules together | shipped | Chrome tested; the Firefox rules are generated but not run yet. Histogram sliders via `graph` |
31
  | Accessibility hints in the compiler | contrast, removed focus outline, small targets (warnings) | shipped | CSS says nothing; these never fail a build unless you pass `--strict` |
 
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:
68
+
69
+ | Their note | What DSSL does now |
70
+ |---|---|
71
+ | `texture: wood` is one generic grain | `oak`, `walnut`, `cherry`, `maple`, `pine`, `mahogany`, `ebony`, `birch`, plus `oak-planks` and `walnut-planks` |
72
+ | Stretched noise with a contour layer is not wood | the `rings` generator draws growth rings as curves, with `figure`, `warp`, `pores` and `knots` |
73
+ | No way to draw planks | `planks <px>`: boards with seams and a slightly different board each |
74
+ | Seamless tiling, stable seed | integer harmonics and a seeded generator: same seed, same board everywhere; tested for seams |
75
+ | `counter-reset` and `counter()` rejected | they work |
76
+ | Contrast false alarms on translucent backgrounds | judged on the light page, white and black; warns only if it fails on all |
77
+ | Flash of unstyled content | `dssl-ready` class on `<html>`, and `dssl build` for the static part (the wood page does this) |
78
+ | Textures missing from `dssl build` | baked into the CSS when colours are known; the report names the ones that could not be |
79
+ | `@font-face` in `raw {}` | **not done.** A native font loader is still a good idea. |
80
+ | Gradients only cover simple cases | **not done.** Plain CSS gradient strings still pass through, which is what the wood page uses. |
81
+ | Dev-tools inspector for which rule produced a style | **not done.** `DSSL.explain()` is still a console call. |
82
+ | Hot reload | **not done.** |
83
+
84
  ## v0.10.0: glass, tilt, and a silent colour bug
85
 
86
  | Need | DSSL | Status | Notes |
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.10.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. See `dssl-vscode/PUBLISHING.md` for publishing.
72
 
73
  AI agents and machine reviewers: this repo ships `llms.txt` (dense, structured facts), `AGENTS.md` (how to work on the codebase) and `PARITY.md` (the honest feature matrix against CSS). The site serves the same `llms.txt` at https://dssl-sable.vercel.app/llms.txt
74
 
@@ -339,7 +339,7 @@ Gradients read like sentences. A stop can be a hex, a color name, a token, an al
339
  ## Texture
340
 
341
  `texture:` lays a generated image on top of an element's own background (a colour, a gradient, or
342
- several layers) and takes it off again cleanly. Thirty presets, ten generators, and a setting for
343
  everything, so you can make your own:
344
 
345
  ```dssl
@@ -523,7 +523,7 @@ The matcher also pre-filters every rule through a cheap class/tag/id gate extrac
523
  - pseudo-elements in any spelling (shadow CSS), themed scrollbars included
524
  - `raw { }` / `@css { }` for verbatim CSS (`@font-face`, `@keyframes`, `@supports`); unknown at-rules skip their whole block with one clean error
525
  - `fluid 16 24` sizing sugar on any length property, including `padding-top`, `margin-block` and the other longhands
526
- - `texture:` (30 presets, 10 generators, your own), `aura-glow` / `aura-particle` / `glow-particle` / `aura-glow-particle` / `aura:` (no presets, you build them), `slider:` (WebKit and Firefox in one line)
527
  - `dssl import file.css`, lint warnings (contrast, focus, target size), and the editor designer with live previews
528
  - one cascade across every stylesheet: a sheet loaded later beats an earlier one (fixed in 0.8.2)
529
  - the CLI: `dssl check` (CI, --json, --strict) and `dssl build` (real CSS from the static subset, also with --strict)
 
68
 
69
  Prefer to click: [dssl-engine.zip](https://dssl-sable.vercel.app/dssl-engine.zip) or [dssl-engine.tar.gz](https://dssl-sable.vercel.app/dssl-engine.tar.gz). Same package, straight from the browser.
70
 
71
+ Writing your own .dssl? The official editor extension (`ehs-studio.dssl-vscode`) puts the real compiler in your editor: the squiggles are the same errors the browser console prints, completions come from your own token registry, and hovers explain the semantic vocabulary. One package for VS Code and VSCodium (same extension API). In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.11.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. See `dssl-vscode/PUBLISHING.md` for publishing.
72
 
73
  AI agents and machine reviewers: this repo ships `llms.txt` (dense, structured facts), `AGENTS.md` (how to work on the codebase) and `PARITY.md` (the honest feature matrix against CSS). The site serves the same `llms.txt` at https://dssl-sable.vercel.app/llms.txt
74
 
 
339
  ## Texture
340
 
341
  `texture:` lays a generated image on top of an element's own background (a colour, a gradient, or
342
+ several layers) and takes it off again cleanly. Forty presets (thirty general ones and ten wood species), eleven generators, and a setting for
343
  everything, so you can make your own:
344
 
345
  ```dssl
 
523
  - pseudo-elements in any spelling (shadow CSS), themed scrollbars included
524
  - `raw { }` / `@css { }` for verbatim CSS (`@font-face`, `@keyframes`, `@supports`); unknown at-rules skip their whole block with one clean error
525
  - `fluid 16 24` sizing sugar on any length property, including `padding-top`, `margin-block` and the other longhands
526
+ - `texture:` (40 presets including ten wood species, 11 generators, your own; `dssl build` bakes them into the CSS), `aura-glow` / `aura-particle` / `glow-particle` / `aura-glow-particle` / `aura:` (no presets, you build them), `slider:` (WebKit and Firefox in one line)
527
  - `dssl import file.css`, lint warnings (contrast, focus, target size), and the editor designer with live previews
528
  - one cascade across every stylesheet: a sheet loaded later beats an earlier one (fixed in 0.8.2)
529
  - the CLI: `dssl check` (CI, --json, --strict) and `dssl build` (real CSS from the static subset, also with --strict)
cli/dssl.mjs CHANGED
@@ -182,6 +182,8 @@ if (cmd === 'build') {
182
  console.log(` ${rep.rules} rule(s), ${rep.shadowRules} pseudo-element rule(s), ${rep.keyframes} @keyframes, ${rep.raws} raw block(s)`);
183
  if (rep.layers.length) console.log(` layers: ${rep.layers.join(' < ')}`);
184
  if (rep.seenStripped) console.log(` ${rep.seenStripped} seen rule(s) applied unconditionally (the no-JS state)`);
 
 
185
  if (rep.skippedSignals || rep.engineDecls) {
186
  const kinds = rep.engineKinds.length ? ` (${rep.engineKinds.join(', ')})` : '';
187
  console.log(` stays in the runtime: ${rep.skippedSignals} @signal rule(s), ${rep.engineDecls} engine-native declaration(s)${kinds}`);
 
182
  console.log(` ${rep.rules} rule(s), ${rep.shadowRules} pseudo-element rule(s), ${rep.keyframes} @keyframes, ${rep.raws} raw block(s)`);
183
  if (rep.layers.length) console.log(` layers: ${rep.layers.join(' < ')}`);
184
  if (rep.seenStripped) console.log(` ${rep.seenStripped} seen rule(s) applied unconditionally (the no-JS state)`);
185
+ if (rep.bakedTextures) console.log(` ${rep.bakedTextures} texture(s) baked into the CSS as background images`);
186
+ for (const n of rep.notBaked || []) console.log(` texture on "${n.sel}" stays in the runtime: ${n.why}`);
187
  if (rep.skippedSignals || rep.engineDecls) {
188
  const kinds = rep.engineKinds.length ? ` (${rep.engineKinds.join(', ')})` : '';
189
  console.log(` stays in the runtime: ${rep.skippedSignals} @signal rule(s), ${rep.engineDecls} engine-native declaration(s)${kinds}`);
dssl-vscode/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
3
  ## 0.10.0
4
 
5
  - Bundled engine updated to 0.10.0: new `glass` and `tilt` properties, with completions and hover
 
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,
6
+ pores, knots, planks), textures baked into the CSS by `dssl build`, CSS counters, and a contrast lint
7
+ that no longer warns about translucent backgrounds it cannot judge. Completions and hover docs know the
8
+ new settings.
9
+
10
  ## 0.10.0
11
 
12
  - Bundled engine updated to 0.10.0: new `glass` and `tilt` properties, with completions and hover
dssl-vscode/README.md CHANGED
@@ -32,7 +32,7 @@ Extension id: **`ehs-studio.dssl-vscode`**. See PUBLISHING.md for the publishing
32
  - **Hover docs** for the semantic vocabulary (`row:`, `bend:`, `stretch:`, `seen`, `pin`...),
33
  for tokens (resolved value), for workspace motions and behaviors.
34
  - **Syntax highlighting** via a TextMate grammar for `.dssl`.
35
- - **Completions for the new vocabulary.** After `texture:` you get the 30 presets, the generators and
36
  every setting; after `aura-glow:` only glow settings; after `aura-particle:` only particle settings
37
  (shapes, where particles are born, directions); after `slider:` the clauses. Hovering a preset or a
38
  setting explains it.
@@ -40,7 +40,7 @@ Extension id: **`ehs-studio.dssl-vscode`**. See PUBLISHING.md for the publishing
40
  - *Aura*: a slider or picker for every glow and particle setting, colour stops you can add and
41
  remove, a live preview drawn by the real renderer, a graph of one particle's opacity and size over
42
  its life, and a live graph of particles alive and frame time.
43
- - *Texture*: all 30 presets and 10 generators as thumbnails, up to four stacked layers, every setting
44
  as a slider, colour pickers.
45
  - *Slider*: a live range input with the generated rules, and a bar editor for histogram sliders.
46
 
 
32
  - **Hover docs** for the semantic vocabulary (`row:`, `bend:`, `stretch:`, `seen`, `pin`...),
33
  for tokens (resolved value), for workspace motions and behaviors.
34
  - **Syntax highlighting** via a TextMate grammar for `.dssl`.
35
+ - **Completions for the new vocabulary.** After `texture:` you get the presets, the generators and
36
  every setting; after `aura-glow:` only glow settings; after `aura-particle:` only particle settings
37
  (shapes, where particles are born, directions); after `slider:` the clauses. Hovering a preset or a
38
  setting explains it.
 
40
  - *Aura*: a slider or picker for every glow and particle setting, colour stops you can add and
41
  remove, a live preview drawn by the real renderer, a graph of one particle's opacity and size over
42
  its life, and a live graph of particles alive and frame time.
43
+ - *Texture*: all the presets and 11 generators as thumbnails, up to four stacked layers, every setting
44
  as a slider, colour pickers.
45
  - *Slider*: a live range input with the generated rules, and a bar editor for histogram sliders.
46
 
dssl-vscode/lib/engine/lint.js CHANGED
@@ -18,7 +18,7 @@
18
  import { parseColor, evalColorExpr, over, contrast, toRgba, toHex } from './color.js';
19
 
20
  /** one design-token lookup closed over the sheet's token map */
21
- function makeResolver(tokenMap) {
22
  const lookup = (name) => tokenMap.get(name);
23
  /** CSS value as the resolver emits it ("var(--dssl-color-ink)", "color-mix(...)", "#fff") to a colour */
24
  return function cssToColor(css, depth = 0) {
@@ -52,7 +52,7 @@ const INTERACTIVE = /^(a|button|input|select|textarea|summary)\b|\[tabindex|\[ro
52
  */
53
  export function lintRules({ rules, tokenMap, file }) {
54
  const warnings = [];
55
- const cssToColor = makeResolver(tokenMap);
56
  const pageBg = (() => { const raw = tokenMap.get('color.paper'); return raw ? evalColorExpr(raw, (n) => tokenMap.get(n)) : null; })();
57
 
58
  const killedFocus = [];
@@ -72,10 +72,21 @@ export function lintRules({ rules, tokenMap, file }) {
72
  let bg = cssToColor(bgD.value);
73
  let fg = cssToColor(fgD.value);
74
  if (bg && fg) {
75
- if (bg.a < 0.999) bg = pageBg ? over(bg, { ...pageBg, a: 1 }) : null;
76
- if (bg) {
77
- const fgSolid = fg.a < 0.999 ? over(fg, bg) : fg;
78
- const ratio = contrast(fgSolid, bg);
 
 
 
 
 
 
 
 
 
 
 
79
  const size = pxOf((lastDecl(r.decls, 'font-size') || {}).value);
80
  const weightRaw = (lastDecl(r.decls, 'font-weight') || {}).value;
81
  const weight = weightRaw === 'bold' ? 700 : Number(weightRaw) || 400;
 
18
  import { parseColor, evalColorExpr, over, contrast, toRgba, toHex } from './color.js';
19
 
20
  /** one design-token lookup closed over the sheet's token map */
21
+ export function makeColorResolver(tokenMap) {
22
  const lookup = (name) => tokenMap.get(name);
23
  /** CSS value as the resolver emits it ("var(--dssl-color-ink)", "color-mix(...)", "#fff") to a colour */
24
  return function cssToColor(css, depth = 0) {
 
52
  */
53
  export function lintRules({ rules, tokenMap, file }) {
54
  const warnings = [];
55
+ const cssToColor = makeColorResolver(tokenMap);
56
  const pageBg = (() => { const raw = tokenMap.get('color.paper'); return raw ? evalColorExpr(raw, (n) => tokenMap.get(n)) : null; })();
57
 
58
  const killedFocus = [];
 
72
  let bg = cssToColor(bgD.value);
73
  let fg = cssToColor(fgD.value);
74
  if (bg && fg) {
75
+ // a translucent background takes its look from whatever is behind it, which a single rule cannot know
76
+ // (a dark theme is usually in a media block, and this check only reads the unconditional rules). So judge
77
+ // it against the light page colour, white and black, and warn only when it fails on every one of them.
78
+ const backdrops = bg.a < 0.999 ? [pageBg ? { ...pageBg, a: 1 } : null, { r: 255, g: 255, b: 255, a: 1 }, { r: 0, g: 0, b: 0, a: 1 }].filter(Boolean) : [null];
79
+ let bestRatio = -1, bestFg = null, bestBg = null;
80
+ for (const back of backdrops) {
81
+ const solidBg = back ? over(bg, back) : bg;
82
+ const solidFg = fg.a < 0.999 ? over(fg, solidBg) : fg;
83
+ const rr = contrast(solidFg, solidBg);
84
+ if (rr > bestRatio) { bestRatio = rr; bestFg = solidFg; bestBg = solidBg; }
85
+ }
86
+ if (bestBg) {
87
+ const fgSolid = bestFg;
88
+ bg = bestBg;
89
+ const ratio = bestRatio;
90
  const size = pxOf((lastDecl(r.decls, 'font-size') || {}).value);
91
  const weightRaw = (lastDecl(r.decls, 'font-weight') || {}).value;
92
  const weight = weightRaw === 'bold' ? 700 : Number(weightRaw) || 400;
dssl-vscode/lib/engine/resolver.js CHANGED
@@ -80,7 +80,7 @@ export const UNITLESS_PROPS = new Set([
80
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
81
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
82
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
83
- 'shape-image-threshold',
84
  // grid line placement takes integers, not lengths: grid-column: span 2
85
  'grid-row', 'grid-column', 'grid-area',
86
  ]);
@@ -106,7 +106,7 @@ export const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,a
106
  'border-inline,border-inline-color,border-inline-end,border-inline-end-color,border-inline-end-style,border-inline-end-width,border-inline-start,border-inline-start-color,border-inline-start-style,border-inline-start-width,border-inline-style,border-inline-width,' +
107
  'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
108
  'border-spacing,border-start-end-radius,border-start-start-radius,border-style,border-top,border-top-color,border-top-left-radius,border-top-right-radius,border-top-style,border-top-width,border-width,' +
109
- 'bottom,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,content-visibility,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,fill,fill-rule,fill-opacity,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-opacity,stroke-width,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' +
110
  'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,text-wrap,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' +
111
  'animation,animation-composition,animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-range,animation-timeline,animation-timing-function,' +
112
  'view-transition-name,interpolate-size,text-box,text-box-edge,text-box-trim,dynamic-range-limit,field-sizing,anchor-name,position-anchor,position-area,scroll-timeline,scroll-timeline-axis,scroll-timeline-name,timeline-scope,view-timeline,view-timeline-axis,view-timeline-name,' +
@@ -268,7 +268,9 @@ function resolveValue(raw, ctx) {
268
  }
269
  // color-ish functions keep bare numbers bare; calc-family gets px
270
  const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
271
- const resolvedInner = resolveValue(inner, { ...ctx, unitless: innerUnitless });
 
 
272
  out += ident + '(' + resolvedInner + ')';
273
  i = j;
274
  continue;
@@ -1136,7 +1138,7 @@ function resolveDecl(prop, value, ctx) {
1136
  });
1137
  return [];
1138
  }
1139
- return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
1140
  }
1141
 
1142
  /** string-aware !important scan - quoted content: "!important" must not trip it */
 
80
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
81
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
82
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
83
+ 'shape-image-threshold', 'counter-reset', 'counter-increment', 'counter-set',
84
  // grid line placement takes integers, not lengths: grid-column: span 2
85
  'grid-row', 'grid-column', 'grid-area',
86
  ]);
 
106
  'border-inline,border-inline-color,border-inline-end,border-inline-end-color,border-inline-end-style,border-inline-end-width,border-inline-start,border-inline-start-color,border-inline-start-style,border-inline-start-width,border-inline-style,border-inline-width,' +
107
  'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
108
  'border-spacing,border-start-end-radius,border-start-start-radius,border-style,border-top,border-top-color,border-top-left-radius,border-top-right-radius,border-top-style,border-top-width,border-width,' +
109
+ 'bottom,counter-reset,counter-increment,counter-set,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,content-visibility,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,fill,fill-rule,fill-opacity,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-opacity,stroke-width,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' +
110
  'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,text-wrap,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' +
111
  'animation,animation-composition,animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-range,animation-timeline,animation-timing-function,' +
112
  'view-transition-name,interpolate-size,text-box,text-box-edge,text-box-trim,dynamic-range-limit,field-sizing,anchor-name,position-anchor,position-area,scroll-timeline,scroll-timeline-axis,scroll-timeline-name,timeline-scope,view-timeline,view-timeline-axis,view-timeline-name,' +
 
268
  }
269
  // color-ish functions keep bare numbers bare; calc-family gets px
270
  const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
271
+ // counter(step), counters(step, "."), attr(data-x): the names inside are the author's own, not tokens
272
+ const nameFn = fname === 'counter' || fname === 'counters' || fname === 'attr';
273
+ const resolvedInner = resolveValue(inner, { ...ctx, unitless: innerUnitless, bareIdents: nameFn ? true : ctx.bareIdents });
274
  out += ident + '(' + resolvedInner + ')';
275
  i = j;
276
  continue;
 
1138
  });
1139
  return [];
1140
  }
1141
+ return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' || prop.startsWith('counter-') }) }];
1142
  }
1143
 
1144
  /** string-aware !important scan - quoted content: "!important" must not trip it */
dssl-vscode/lib/engine/texture.js CHANGED
@@ -28,7 +28,7 @@ const mix2 = (x, y, t) => mix(x, y, t, 'srgb');
28
 
29
  export const BLEND_MODES = ['normal', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'color-burn', 'color-dodge', 'darken', 'lighten', 'difference', 'exclusion'];
30
 
31
- export const GENERATORS = ['noise', 'bump', 'lines', 'dots', 'grid', 'checker', 'weave', 'carbon', 'waves', 'hex'];
32
 
33
  /** the settings a texture layer understands, with how many value words each takes */
34
  export const TEXTURE_KEYS = {
@@ -51,6 +51,11 @@ export const TEXTURE_KEYS = {
51
  blend: { words: 1, kind: 'word', values: BLEND_MODES, doc: 'how the layer mixes with what is under it' },
52
  color: { words: 1, kind: 'color', doc: 'the main colour (dark end of noise, ink of lines and dots)' },
53
  color2: { words: 1, kind: 'color', doc: 'the second colour (light end of noise, background cell of patterns)' },
 
 
 
 
 
54
  invert: { words: 0, kind: 'flag', doc: 'swap the two colours' },
55
  };
56
 
@@ -100,6 +105,26 @@ export const PRESETS = {
100
  ] },
101
  wood: { doc: 'long wood grain, along x (use angle 90 for vertical)',
102
  layers: [{ gen: 'noise', scale: 4, octaves: 3, contrast: 1.8, stretch: [40, 1], intensity: 1, color: '#7a4b24', color2: '#c99a62', blend: 'multiply' }] },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  bark: { doc: 'rough vertical bark',
104
  layers: [{ gen: 'bump', scale: 10, octaves: 4, depth: 5, stretch: [1, 6], light: 150, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay' }] },
105
  leather: { doc: 'pebbled leather',
@@ -164,6 +189,7 @@ const GEN_DEFAULTS = {
164
  carbon: { scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' },
165
  waves: { scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' },
166
  hex: { scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
 
167
  };
168
 
169
  /* ------------------------------------------------------------------ *
@@ -452,7 +478,134 @@ function buildHex(spec, rc) {
452
  return [{ image: svgUrl(svg), size: `${f(W)}px ${f(100 * k)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: W }];
453
  }
454
 
455
- const BUILDERS = { noise: buildNoise, bump: buildBump, lines: buildLines, dots: buildDots, grid: buildGrid, checker: buildChecker, weave: buildWeave, carbon: buildCarbon, waves: buildWaves, hex: buildHex };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
 
457
  /**
458
  * Turn parsed layers into the CSS lists for background-image, -size, -repeat,
@@ -477,6 +630,59 @@ export function buildTexture(layers, rc) {
477
  };
478
  }
479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  /* ------------------------------------------------------------------ *
481
  * deterministic value noise, for aura masks and tests
482
  * ------------------------------------------------------------------ */
 
28
 
29
  export const BLEND_MODES = ['normal', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'color-burn', 'color-dodge', 'darken', 'lighten', 'difference', 'exclusion'];
30
 
31
+ export const GENERATORS = ['noise', 'bump', 'lines', 'dots', 'grid', 'checker', 'weave', 'carbon', 'waves', 'hex', 'rings'];
32
 
33
  /** the settings a texture layer understands, with how many value words each takes */
34
  export const TEXTURE_KEYS = {
 
51
  blend: { words: 1, kind: 'word', values: BLEND_MODES, doc: 'how the layer mixes with what is under it' },
52
  color: { words: 1, kind: 'color', doc: 'the main colour (dark end of noise, ink of lines and dots)' },
53
  color2: { words: 1, kind: 'color', doc: 'the second colour (light end of noise, background cell of patterns)' },
54
+ figure: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: how strongly the growth rings arch into a cathedral figure, 0 is straight grain' },
55
+ warp: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: how much each ring wobbles along its length' },
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
 
 
105
  ] },
106
  wood: { doc: 'long wood grain, along x (use angle 90 for vertical)',
107
  layers: [{ gen: 'noise', scale: 4, octaves: 3, contrast: 1.8, stretch: [40, 1], intensity: 1, color: '#7a4b24', color2: '#c99a62', blend: 'multiply' }] },
108
+ oak: { doc: 'oak: open grain, tan with brown rings',
109
+ layers: [{ gen: 'rings', scale: 11, color: '#8a5f33', color2: '#c9a574', contrast: 1.3, figure: 0.55, warp: 0.45, pores: 0.5, seed: 3, blend: 'normal' }] },
110
+ walnut: { doc: 'walnut: dark chocolate with black-brown rings',
111
+ layers: [{ gen: 'rings', scale: 9, color: '#2b1a10', color2: '#6a4631', contrast: 1.2, figure: 0.5, warp: 0.55, pores: 0.45, seed: 8, blend: 'normal' }] },
112
+ cherry: { doc: 'cherry: warm red-brown with fine rings',
113
+ layers: [{ gen: 'rings', scale: 8, color: '#7a3a1c', color2: '#bb7443', contrast: 1.1, figure: 0.4, warp: 0.35, pores: 0.35, seed: 5, blend: 'normal' }] },
114
+ maple: { doc: 'maple: pale, tight, nearly plain grain',
115
+ layers: [{ gen: 'rings', scale: 13, color: '#c79a5a', color2: '#ecd6ab', contrast: 0.8, figure: 0.25, warp: 0.3, pores: 0.25, seed: 11, blend: 'normal' }] },
116
+ pine: { doc: 'pine: light yellow wood with wide rings and knots',
117
+ layers: [{ gen: 'rings', scale: 16, color: '#b5742f', color2: '#e6c081', contrast: 1.4, figure: 0.65, warp: 0.5, pores: 0.4, knots: 2, seed: 14, blend: 'normal' }] },
118
+ mahogany: { doc: 'mahogany: deep red-brown, straight grain',
119
+ layers: [{ gen: 'rings', scale: 10, color: '#4a2012', color2: '#8c4727', contrast: 1.1, figure: 0.3, warp: 0.3, pores: 0.4, seed: 6, blend: 'normal' }] },
120
+ ebony: { doc: 'ebony: nearly black with faint streaks',
121
+ layers: [{ gen: 'rings', scale: 12, color: '#0c0908', color2: '#2d2621', contrast: 0.7, figure: 0.2, warp: 0.25, pores: 0.3, seed: 2, blend: 'normal' }] },
122
+ birch: { doc: 'birch: creamy pale wood with fine rings',
123
+ layers: [{ gen: 'rings', scale: 10, color: '#d2ae74', color2: '#f0dfb9', contrast: 0.9, figure: 0.3, warp: 0.35, pores: 0.25, seed: 9, blend: 'normal' }] },
124
+ 'oak-planks': { doc: 'oak laid as planks, with seams',
125
+ layers: [{ gen: 'rings', scale: 11, color: '#8a5f33', color2: '#c9a574', contrast: 1.3, figure: 0.55, warp: 0.45, pores: 0.5, planks: 110, seed: 3, blend: 'normal' }] },
126
+ 'walnut-planks': { doc: 'walnut laid as planks, with seams',
127
+ layers: [{ gen: 'rings', scale: 9, color: '#2b1a10', color2: '#6a4631', contrast: 1.2, figure: 0.5, warp: 0.55, pores: 0.45, planks: 96, seed: 8, blend: 'normal' }] },
128
  bark: { doc: 'rough vertical bark',
129
  layers: [{ gen: 'bump', scale: 10, octaves: 4, depth: 5, stretch: [1, 6], light: 150, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay' }] },
130
  leather: { doc: 'pebbled leather',
 
189
  carbon: { scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' },
190
  waves: { scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' },
191
  hex: { scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
192
+ rings: { scale: 10, color: '#7a4b24', color2: '#d1a770', contrast: 1.2, figure: 0.5, warp: 0.4, pores: 0.4, intensity: 1, blend: 'normal' },
193
  };
194
 
195
  /* ------------------------------------------------------------------ *
 
478
  return [{ image: svgUrl(svg), size: `${f(W)}px ${f(100 * k)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: W }];
479
  }
480
 
481
+
482
+ /* ------------------------------------------------------------------ *
483
+ * rings: growth rings, drawn as vector curves
484
+ *
485
+ * A board is a log sliced, and the rings you see are where the slice crosses
486
+ * the growth layers. So this does not stretch noise: it draws each ring as a
487
+ * curve. The spacing wobbles, the curves arch (cathedral figure) because the
488
+ * slice cuts the layers at a slant, there are pores between them, and planks
489
+ * and knots are drawn on top. Everything comes from a seeded generator and
490
+ * integer harmonics, so the same seed gives the same board on every machine
491
+ * and the tile has no visible seam.
492
+ * ------------------------------------------------------------------ */
493
+
494
+ const TAU = Math.PI * 2;
495
+ const r1 = (n) => String(Math.round(n * 10) / 10);
496
+ const smallUrl = (svg) => `url("data:image/svg+xml,${svg.replace(/[%#<>"]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())}")`;
497
+
498
+ /** a smooth path through [x, y] samples (Catmull-Rom as cubic Beziers, ends clamped) */
499
+ function smoothPath(pts) {
500
+ const n = pts.length;
501
+ const at = (i) => pts[Math.max(0, Math.min(n - 1, i))];
502
+ let d = `M${r1(pts[0][0])} ${r1(pts[0][1])}`;
503
+ for (let i = 0; i < n - 1; i++) {
504
+ const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
505
+ const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
506
+ const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
507
+ d += `C${r1(c1x)} ${r1(c1y)} ${r1(c2x)} ${r1(c2y)} ${r1(p2[0])} ${r1(p2[1])}`;
508
+ }
509
+ return d;
510
+ }
511
+
512
+ function buildRings(spec, rc) {
513
+ const { a, b } = layerColors(spec, rc);
514
+ const late = a, early = b || a;
515
+ const P = Math.max(3, spec.scale || 10);
516
+ const W = 576; // one tile along the grain; every harmonic below divides it
517
+ const fig = spec.figure === undefined ? 0.5 : spec.figure;
518
+ const warp = spec.warp === undefined ? 0.4 : spec.warp;
519
+ const pores = spec.pores === undefined ? 0.4 : spec.pores;
520
+ const contrast = spec.contrast === undefined ? 1.2 : spec.contrast;
521
+ const seed = spec.seed || 0;
522
+ const planked = !!spec.planks;
523
+ const plankH = planked ? Math.max(P * 3, spec.planks) : 0;
524
+ const plankN = planked ? 3 : 1;
525
+ const H = planked ? plankH * plankN : P * Math.max(12, Math.round(260 / P));
526
+ const rgba = (c, alpha) => toRgba(withAlpha(c, n01((alpha === undefined ? 1 : alpha) * c.a)));
527
+ const STEP = 48;
528
+
529
+ let defs = '', body = '';
530
+ let id = 0;
531
+ for (let pi = 0; pi < plankN; pi++) {
532
+ const rand = rng(seed * 7919 + pi * 104729 + 13);
533
+ const h = planked ? plankH : H;
534
+ const n = Math.max(3, Math.round(h / P));
535
+ const Pe = h / n;
536
+ const ph = [rand() * TAU, rand() * TAU, rand() * TAU];
537
+ const x0 = rand() * W;
538
+ const m = 2 + Math.floor(rand() * 3), spacingAmp = 0.12 + rand() * 0.22;
539
+ const flip = planked && rand() < 0.5;
540
+ const curve = (kf, x) => {
541
+ const t = kf / n;
542
+ // a free board arches from one face to the other; a seamless tile must repeat every n rings, so its envelope has period n
543
+ const per = Math.pow(Math.sin(Math.PI * t), 2);
544
+ const env = planked ? Math.pow(Math.max(0, Math.min(1, flip ? 1 - t : t)), 1.4) : per;
545
+ const env2 = planked ? 0.6 + 0.4 * Math.abs(Math.sin(Math.PI * t)) : per;
546
+ const bump = Math.pow((1 + Math.cos(TAU * (x - x0) / W)) / 2, 1.3);
547
+ const arch = fig * 0.3 * h * env * bump;
548
+ const wob = warp * P * 0.6 * env2 * (0.5 * Math.sin(TAU * x / W + ph[0] + TAU * kf / n) + 0.3 * Math.sin(TAU * 3 * x / W + ph[1] + TAU * 2 * kf / n) + 0.2 * Math.sin(TAU * 7 * x / W + ph[2] + TAU * 3 * kf / n));
549
+ const u = kf + spacingAmp * Math.sin(TAU * m * kf / n);
550
+ return u * Pe + arch + wob;
551
+ };
552
+ // sampled past both ends of the tile, so the stroke is continuous across the join and the viewport clips it
553
+ const ringPath = (kf) => { const pts = []; for (let x = -2 * STEP; x <= W + 2 * STEP; x += STEP) pts.push([x, curve(kf, x)]); return smoothPath(pts); };
554
+ const jit = Array.from({ length: n }, () => [rand(), rand()]);
555
+
556
+ const lo = planked ? -2 : -1, hi = planked ? n + 2 : n;
557
+ let g = '';
558
+ for (let k = lo; k <= hi; k++) {
559
+ const [jitter, jop] = jit[((k % n) + n) % n]; // ring k and ring k + n look alike, so the tile repeats cleanly
560
+ const w = Pe * (0.14 + jitter * 0.26);
561
+ const op = n01((0.4 + jop * 0.55) * Math.min(1.4, contrast / 1.2));
562
+ const rid = 'r' + id++;
563
+ defs += `<path id='${rid}' d='${ringPath(k + 0.8)}' fill='none'/>`;
564
+ g += `<use href='#${rid}' stroke='${rgba(late, op * 0.28)}' stroke-width='${r1(w * 2.6)}'/><use href='#${rid}' stroke='${rgba(late, op)}' stroke-width='${r1(w)}'/>`;
565
+ }
566
+ // knots: elongated along the grain, with rings around them
567
+ const knotN = spec.knots ? Math.max(0, Math.round(spec.knots / plankN + (pi === 0 ? 0.49 : 0))) : 0;
568
+ let knots = '';
569
+ for (let q = 0; q < knotN; q++) {
570
+ const kx = 90 + rand() * (W - 180), ky = h * (0.25 + rand() * 0.5), rx = 9 + rand() * 12, ry = rx * 0.6;
571
+ knots += `<ellipse cx='${r1(kx)}' cy='${r1(ky)}' rx='${r1(rx * 4)}' ry='${r1(ry * 3.4)}' fill='${rgba(late, 0.1)}'/>` +
572
+ `<ellipse cx='${r1(kx)}' cy='${r1(ky)}' rx='${r1(rx * 2.4)}' ry='${r1(ry * 2.3)}' fill='none' stroke='${rgba(late, 0.35)}' stroke-width='1.2'/>` +
573
+ `<ellipse cx='${r1(kx)}' cy='${r1(ky)}' rx='${r1(rx * 1.6)}' ry='${r1(ry * 1.6)}' fill='none' stroke='${rgba(late, 0.5)}' stroke-width='1.4'/>` +
574
+ `<ellipse cx='${r1(kx)}' cy='${r1(ky)}' rx='${r1(rx)}' ry='${r1(ry)}' fill='${rgba(late, 0.85)}'/>`;
575
+ }
576
+ const tone = (rand() - 0.5) * 0.14;
577
+ const toneRect = tone > 0 ? `<rect width='${W}' height='${r1(h)}' fill='rgba(255,255,255,${r1(Math.abs(tone) * 10) / 10})'/>` : `<rect width='${W}' height='${r1(h)}' fill='rgba(0,0,0,${r1(Math.abs(tone) * 10) / 10})'/>`;
578
+ const clip = `c${pi}`;
579
+ defs += `<clipPath id='${clip}'><rect width='${W}' height='${r1(h)}'/></clipPath>`;
580
+ body += `<g transform='translate(0 ${r1(pi * plankH)})' clip-path='url(#${clip})'>` +
581
+ `<rect width='${W}' height='${r1(h)}' fill='${rgba(early)}'/>` + (planked ? toneRect : '') + `<g fill='none'>${g}</g>${knots}</g>`;
582
+ }
583
+
584
+ // fine fibres: streaks stretched along the grain, drawn once over everything
585
+ const fibre = pores > 0
586
+ ? `<filter id='p' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>` +
587
+ `<feTurbulence type='fractalNoise' baseFrequency='0.014 0.62' numOctaves='3' seed='${seed + 3}' stitchTiles='stitch'/>` +
588
+ `<feColorMatrix type='matrix' values='0 0 0 0 ${f(late.r / 255)} 0 0 0 0 ${f(late.g / 255)} 0 0 0 0 ${f(late.b / 255)} ${f(pores * 4.2)} 0 0 0 ${f(-pores * 1.95)}'/></filter>` +
589
+ `<rect width='${W}' height='${H}' filter='url(#p)' opacity='${f(Math.min(1, 0.55 * late.a))}'/>`
590
+ : '';
591
+
592
+ let seams = '';
593
+ if (planked) {
594
+ for (let pi = 0; pi < plankN; pi++) {
595
+ const y = pi * plankH;
596
+ seams += `<rect y='${r1(y)}' width='${W}' height='1.6' fill='rgba(0,0,0,0.5)'/><rect y='${r1(y + 1.6)}' width='${W}' height='1' fill='rgba(255,255,255,0.1)'/>`;
597
+ }
598
+ }
599
+
600
+ const rot = spec.angle === 90 || spec.angle === -90 || spec.angle === 270;
601
+ const inner = `<defs>${defs}</defs>${body}${fibre}${seams}`;
602
+ const svg = rot
603
+ ? `<svg xmlns='http://www.w3.org/2000/svg' width='${H}' height='${W}'><g transform='matrix(0 1 1 0 0 0)'>${inner}</g></svg>`
604
+ : `<svg xmlns='http://www.w3.org/2000/svg' width='${W}' height='${H}'>${inner}</svg>`;
605
+ return [{ image: smallUrl(svg), size: rot ? `${H}px ${W}px` : `${W}px ${H}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: Math.max(W, H) }];
606
+ }
607
+
608
+ const BUILDERS = { rings: buildRings, noise: buildNoise, bump: buildBump, lines: buildLines, dots: buildDots, grid: buildGrid, checker: buildChecker, weave: buildWeave, carbon: buildCarbon, waves: buildWaves, hex: buildHex };
609
 
610
  /**
611
  * Turn parsed layers into the CSS lists for background-image, -size, -repeat,
 
630
  };
631
  }
632
 
633
+
634
+ /* ------------------------------------------------------------------ *
635
+ * composing with the element's own background (shared by the engine and `dssl build`)
636
+ * ------------------------------------------------------------------ */
637
+
638
+ export function splitBgList(value) {
639
+ const out = []; let depth = 0, cur = '';
640
+ for (const ch of String(value)) {
641
+ if (ch === '(') depth++;
642
+ if (ch === ')') depth--;
643
+ if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
644
+ cur += ch;
645
+ }
646
+ if (cur.trim()) out.push(cur.trim());
647
+ return out;
648
+ }
649
+ /** a list of exactly n entries: the author's, cycled the way CSS cycles them, or the default */
650
+ function expandList(value, n, dflt) {
651
+ const items = value ? splitBgList(value) : [];
652
+ if (!items.length) return Array.from({ length: n }, () => dflt);
653
+ return Array.from({ length: n }, (_, i) => items[i % items.length]);
654
+ }
655
+
656
+
657
+ /**
658
+ * texture sits ON TOP of whatever background the author wrote: the shorthand, the longhands,
659
+ * or just a colour. `owned` is a Map of prop -> { value, imp }; this edits it in place.
660
+ */
661
+ export function composeTextureInto(owned, tex) {
662
+ const sh = owned.get('background');
663
+ const img = owned.get('background-image');
664
+ const blendUser = owned.get('background-blend-mode');
665
+ const texBlends = tex.blend.split(', ');
666
+ if (sh) {
667
+ // the author wrote the shorthand: texture layers go first, their value stays the last layer(s)
668
+ const userLayers = splitBgList(sh.value).length;
669
+ const texShort = tex.parts.map((p) => `${p.image} ${p.position} / ${p.size} ${p.repeat}`).join(', ');
670
+ owned.set('background', { value: `${texShort}, ${sh.value}`, imp: sh.imp });
671
+ owned.delete('background-image');
672
+ owned.set('background-blend-mode', { value: [...texBlends, ...expandList(blendUser && blendUser.value, userLayers, 'normal')].join(', '), imp: sh.imp });
673
+ return;
674
+ }
675
+ const userLayers = img ? splitBgList(img.value).length : 0;
676
+ const imp = img ? img.imp : '';
677
+ const get = (name) => (owned.get(name) || {}).value;
678
+ const join = (texList, name, dflt) => [...texList.split(/,(?![^(]*\))/).map((x) => x.trim()), ...(userLayers ? expandList(get(name), userLayers, dflt) : [])].join(', ');
679
+ owned.set('background-image', { value: tex.image + (img ? ', ' + img.value : ''), imp });
680
+ owned.set('background-size', { value: join(tex.size, 'background-size', 'auto'), imp });
681
+ owned.set('background-position', { value: join(tex.position, 'background-position', '0% 0%'), imp });
682
+ owned.set('background-repeat', { value: join(tex.repeat, 'background-repeat', 'repeat'), imp });
683
+ owned.set('background-blend-mode', { value: [...texBlends, ...(userLayers ? expandList(get('background-blend-mode'), userLayers, 'normal') : [])].join(', '), imp });
684
+ }
685
+
686
  /* ------------------------------------------------------------------ *
687
  * deterministic value noise, for aura masks and tests
688
  * ------------------------------------------------------------------ */
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.10.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.11.0",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
llms.txt CHANGED
@@ -50,7 +50,7 @@ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
50
  ```
51
 
52
  ## 0.9.0: texture, aura, sliders, lint, import
53
- - `texture: grain` layers a generated image ON TOP of an element's own background (colour, gradient or several layers). 30 presets (grain, noise, rough, sand, paper, cloud, concrete, stone, crumpled, glitter, frost, marble, wood, bark, leather, brushed, canvas, linen, denim, carbon, scanlines, halftone, dots, lines, grid, checker, crosshatch, stripes, waves, hex) and 10 generators (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex) with settings: scale, intensity, contrast, threshold, octaves, seed, stretch x y, depth, light, angle, thickness, radius, softness, contour, animate, type, blend, color, color2, invert. Comma stacks layers. Runtime-only (`dssl build` leaves it in and reports it).
54
  - Aura has NO presets. `aura-glow: <color> <radius> [intensity|pulse|depth|flicker|texture(...)|seed|essential]`, `aura-particle: <color -> color ...> <count> [size|life|speed|dir|spread|gravity|drag|turbulence|from|shape|grow|glowing|behind|blend|texture(...)|seed|essential]`, `glow-particle` (particles that glow), `aura-glow-particle` (both slots), `aura: glow ..., particles ...` (replaces both; `aura: none` clears both, `aura-glow: none` only the glow). Name your own with `behavior fire { ... }` then `use fire`. One canvas over the page, 1800 particle budget, still glow under reduced motion, runtime-only.
55
  - `slider: track 6 color.line radius full, fill color.accent, thumb 24 color.paper, ring 3 color.accent, glow color.accent 14, ticks 10, graph 20 45 70` writes the ::-webkit-slider-* and ::-moz-range-* rules together for `input[type=range]`. The engine keeps `--dssl-p` (value 0..1) on the input. Firefox rules are generated but untested.
56
  - Lint warnings (never errors; `dssl check --strict` makes them fatal): text contrast under 4.5:1 (3:1 for large text), a removed focus outline with no focus-visible replacement, tap targets under 24px.
@@ -63,6 +63,14 @@ 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
  ## CLI (v0.8.0)
67
 
68
  ```sh
@@ -76,4 +84,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
76
 
77
  ## Editor support
78
 
79
- The official extension (VS Code and VSCodium, same .vsix) bundles the real parser and resolver: diagnostics identical to the browser console, completions from the workspace token registry, hover docs for the semantic vocabulary. In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.10.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
 
50
  ```
51
 
52
  ## 0.9.0: texture, aura, sliders, lint, import
53
+ - `texture: grain` layers a generated image ON TOP of an element's own background (colour, gradient or several layers). 40 presets (30 general: grain, noise, rough, sand, paper, cloud, concrete, stone, crumpled, glitter, frost, marble, wood, bark, leather, brushed, canvas, linen, denim, carbon, scanlines, halftone, dots, lines, grid, checker, crosshatch, stripes, waves, hex) and 11 generators (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex, rings) with settings: scale, figure, warp, pores, knots, planks, intensity, contrast, threshold, octaves, seed, stretch x y, depth, light, angle, thickness, radius, softness, contour, animate, type, blend, color, color2, invert. Comma stacks layers. Built at run time, and baked into the CSS by `dssl build` when its colours are known (0.11.0).
54
  - Aura has NO presets. `aura-glow: <color> <radius> [intensity|pulse|depth|flicker|texture(...)|seed|essential]`, `aura-particle: <color -> color ...> <count> [size|life|speed|dir|spread|gravity|drag|turbulence|from|shape|grow|glowing|behind|blend|texture(...)|seed|essential]`, `glow-particle` (particles that glow), `aura-glow-particle` (both slots), `aura: glow ..., particles ...` (replaces both; `aura: none` clears both, `aura-glow: none` only the glow). Name your own with `behavior fire { ... }` then `use fire`. One canvas over the page, 1800 particle budget, still glow under reduced motion, runtime-only.
55
  - `slider: track 6 color.line radius full, fill color.accent, thumb 24 color.paper, ring 3 color.accent, glow color.accent 14, ticks 10, graph 20 45 70` writes the ::-webkit-slider-* and ::-moz-range-* rules together for `input[type=range]`. The engine keeps `--dssl-p` (value 0..1) on the input. Firefox rules are generated but untested.
56
  - Lint warnings (never errors; `dssl check --strict` makes them fatal): text contrast under 4.5:1 (3:1 for large text), a removed focus outline with no focus-visible replacement, tap targets under 24px.
 
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.
69
+ - `counter-reset`, `counter-increment`, `counter-set` and `content: counter(name)` / `counters(name, ".")` work.
70
+ - The contrast lint no longer cries wolf on translucent backgrounds: it warns only when the pair fails on the light page colour, white and black.
71
+ - A page can hide itself until styled: `<style>html:not(.dssl-ready) body{visibility:hidden}</style>`; the engine adds `dssl-ready` to `<html>`.
72
+ - Showcase: /showcase/wood/ is a whole page (Heartwood) built with these.
73
+
74
  ## CLI (v0.8.0)
75
 
76
  ```sh
 
84
 
85
  ## Editor support
86
 
87
+ The official extension (VS Code and VSCodium, same .vsix) bundles the real parser and resolver: diagnostics identical to the browser console, completions from the workspace token registry, hover docs for the semantic vocabulary. In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.11.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
next.config.ts CHANGED
@@ -2,6 +2,10 @@ import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
  reactStrictMode: false,
 
 
 
 
5
  };
6
 
7
  export default nextConfig;
 
2
 
3
  const nextConfig: NextConfig = {
4
  reactStrictMode: false,
5
+ // the wood showcase is a plain static page that lives in public/
6
+ async rewrites() {
7
+ return [{ source: "/showcase/wood", destination: "/showcase/wood/index.html" }];
8
+ },
9
  };
10
 
11
  export default nextConfig;
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "dssl",
3
- "version": "0.10.0",
4
  "private": true,
5
  "description": "The DSSL style language and the website that is written in it.",
6
  "scripts": {
 
1
  {
2
  "name": "dssl",
3
+ "version": "0.11.0",
4
  "private": true,
5
  "description": "The DSSL style language and the website that is written in it.",
6
  "scripts": {
public/dssl/engine.js CHANGED
@@ -21,7 +21,7 @@
21
 
22
  import { composeDeform, arcLayout } from './utils.js';
23
  // 0.9.0: texture layers, aura (light and particles) and the range-slider helper
24
- import { buildTexture } from './texture.js';
25
  import { AuraManager, resolveLayer } from './aura.js';
26
  import { parseColor } from './color.js';
27
  import { tiltTransform } from './effects.js';
@@ -142,24 +142,6 @@ function parseSizeQuery(q) {
142
  }
143
 
144
  /** split a CSS list at top-level commas, so rgba(1,2,3) and var(--x, 4) stay whole */
145
- function splitList(value) {
146
- const out = []; let depth = 0, cur = '';
147
- for (const ch of String(value)) {
148
- if (ch === '(') depth++;
149
- if (ch === ')') depth--;
150
- if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
151
- cur += ch;
152
- }
153
- if (cur.trim()) out.push(cur.trim());
154
- return out;
155
- }
156
- /** a list of exactly n entries: the author's, cycled the way CSS cycles them, or the default */
157
- function expandList(value, n, dflt) {
158
- const items = value ? splitList(value) : [];
159
- if (!items.length) return Array.from({ length: n }, () => dflt);
160
- return Array.from({ length: n }, (_, i) => items[i % items.length]);
161
- }
162
-
163
  export class DsslEngine {
164
  constructor({ scope = null, name = 'global', reset = false } = {}) {
165
  this.name = name;
@@ -258,28 +240,7 @@ export class DsslEngine {
258
  this._texCache.set(key, tex);
259
  }
260
  if (!tex.count) return null;
261
- const sh = owned.get('background');
262
- const img = owned.get('background-image');
263
- const blendUser = owned.get('background-blend-mode');
264
- const texBlends = tex.blend.split(', ');
265
- if (sh) {
266
- // the author wrote the shorthand: texture layers go first, their value stays the last layer(s)
267
- const userLayers = splitList(sh.value).length;
268
- const texShort = tex.parts.map((p) => `${p.image} ${p.position} / ${p.size} ${p.repeat}`).join(', ');
269
- owned.set('background', { value: `${texShort}, ${sh.value}`, imp: sh.imp });
270
- owned.delete('background-image');
271
- owned.set('background-blend-mode', { value: [...texBlends, ...expandList(blendUser && blendUser.value, userLayers, 'normal')].join(', '), imp: sh.imp });
272
- return tex;
273
- }
274
- const userLayers = img ? splitList(img.value).length : 0;
275
- const imp = img ? img.imp : '';
276
- const get = (name) => (owned.get(name) || {}).value;
277
- const join = (texList, name, dflt) => [...texList.split(/,(?![^(]*\))/).map((x) => x.trim()), ...(userLayers ? expandList(get(name), userLayers, dflt) : [])].join(', ');
278
- owned.set('background-image', { value: tex.image + (img ? ', ' + img.value : ''), imp });
279
- owned.set('background-size', { value: join(tex.size, 'background-size', 'auto'), imp });
280
- owned.set('background-position', { value: join(tex.position, 'background-position', '0% 0%'), imp });
281
- owned.set('background-repeat', { value: join(tex.repeat, 'background-repeat', 'repeat'), imp });
282
- owned.set('background-blend-mode', { value: [...texBlends, ...(userLayers ? expandList(get('background-blend-mode'), userLayers, 'normal') : [])].join(', '), imp });
283
  return tex;
284
  }
285
 
 
21
 
22
  import { composeDeform, arcLayout } from './utils.js';
23
  // 0.9.0: texture layers, aura (light and particles) and the range-slider helper
24
+ import { buildTexture, composeTextureInto, splitBgList as splitList } from './texture.js';
25
  import { AuraManager, resolveLayer } from './aura.js';
26
  import { parseColor } from './color.js';
27
  import { tiltTransform } from './effects.js';
 
142
  }
143
 
144
  /** split a CSS list at top-level commas, so rgba(1,2,3) and var(--x, 4) stay whole */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  export class DsslEngine {
146
  constructor({ scope = null, name = 'global', reset = false } = {}) {
147
  this.name = name;
 
240
  this._texCache.set(key, tex);
241
  }
242
  if (!tex.count) return null;
243
+ composeTextureInto(owned, tex);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  return tex;
245
  }
246
 
public/dssl/index.js CHANGED
@@ -19,7 +19,7 @@ import { resolve, DEFAULT_TOKENS } from './resolver.js';
19
  import { toCSS as docToCSS } from './static.js';
20
  import { DsslEngine } from './engine.js';
21
 
22
- export const VERSION = '0.10.0';
23
 
24
  let counter = 0;
25
  const engines = new Map();
@@ -240,6 +240,8 @@ if (typeof window !== 'undefined') {
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
  }
244
  };
245
 
 
19
  import { toCSS as docToCSS } from './static.js';
20
  import { DsslEngine } from './engine.js';
21
 
22
+ export const VERSION = '0.11.0';
23
 
24
  let counter = 0;
25
  const engines = new Map();
 
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 */ }
245
  }
246
  };
247
 
public/dssl/lint.js CHANGED
@@ -18,7 +18,7 @@
18
  import { parseColor, evalColorExpr, over, contrast, toRgba, toHex } from './color.js';
19
 
20
  /** one design-token lookup closed over the sheet's token map */
21
- function makeResolver(tokenMap) {
22
  const lookup = (name) => tokenMap.get(name);
23
  /** CSS value as the resolver emits it ("var(--dssl-color-ink)", "color-mix(...)", "#fff") to a colour */
24
  return function cssToColor(css, depth = 0) {
@@ -52,7 +52,7 @@ const INTERACTIVE = /^(a|button|input|select|textarea|summary)\b|\[tabindex|\[ro
52
  */
53
  export function lintRules({ rules, tokenMap, file }) {
54
  const warnings = [];
55
- const cssToColor = makeResolver(tokenMap);
56
  const pageBg = (() => { const raw = tokenMap.get('color.paper'); return raw ? evalColorExpr(raw, (n) => tokenMap.get(n)) : null; })();
57
 
58
  const killedFocus = [];
@@ -72,10 +72,21 @@ export function lintRules({ rules, tokenMap, file }) {
72
  let bg = cssToColor(bgD.value);
73
  let fg = cssToColor(fgD.value);
74
  if (bg && fg) {
75
- if (bg.a < 0.999) bg = pageBg ? over(bg, { ...pageBg, a: 1 }) : null;
76
- if (bg) {
77
- const fgSolid = fg.a < 0.999 ? over(fg, bg) : fg;
78
- const ratio = contrast(fgSolid, bg);
 
 
 
 
 
 
 
 
 
 
 
79
  const size = pxOf((lastDecl(r.decls, 'font-size') || {}).value);
80
  const weightRaw = (lastDecl(r.decls, 'font-weight') || {}).value;
81
  const weight = weightRaw === 'bold' ? 700 : Number(weightRaw) || 400;
 
18
  import { parseColor, evalColorExpr, over, contrast, toRgba, toHex } from './color.js';
19
 
20
  /** one design-token lookup closed over the sheet's token map */
21
+ export function makeColorResolver(tokenMap) {
22
  const lookup = (name) => tokenMap.get(name);
23
  /** CSS value as the resolver emits it ("var(--dssl-color-ink)", "color-mix(...)", "#fff") to a colour */
24
  return function cssToColor(css, depth = 0) {
 
52
  */
53
  export function lintRules({ rules, tokenMap, file }) {
54
  const warnings = [];
55
+ const cssToColor = makeColorResolver(tokenMap);
56
  const pageBg = (() => { const raw = tokenMap.get('color.paper'); return raw ? evalColorExpr(raw, (n) => tokenMap.get(n)) : null; })();
57
 
58
  const killedFocus = [];
 
72
  let bg = cssToColor(bgD.value);
73
  let fg = cssToColor(fgD.value);
74
  if (bg && fg) {
75
+ // a translucent background takes its look from whatever is behind it, which a single rule cannot know
76
+ // (a dark theme is usually in a media block, and this check only reads the unconditional rules). So judge
77
+ // it against the light page colour, white and black, and warn only when it fails on every one of them.
78
+ const backdrops = bg.a < 0.999 ? [pageBg ? { ...pageBg, a: 1 } : null, { r: 255, g: 255, b: 255, a: 1 }, { r: 0, g: 0, b: 0, a: 1 }].filter(Boolean) : [null];
79
+ let bestRatio = -1, bestFg = null, bestBg = null;
80
+ for (const back of backdrops) {
81
+ const solidBg = back ? over(bg, back) : bg;
82
+ const solidFg = fg.a < 0.999 ? over(fg, solidBg) : fg;
83
+ const rr = contrast(solidFg, solidBg);
84
+ if (rr > bestRatio) { bestRatio = rr; bestFg = solidFg; bestBg = solidBg; }
85
+ }
86
+ if (bestBg) {
87
+ const fgSolid = bestFg;
88
+ bg = bestBg;
89
+ const ratio = bestRatio;
90
  const size = pxOf((lastDecl(r.decls, 'font-size') || {}).value);
91
  const weightRaw = (lastDecl(r.decls, 'font-weight') || {}).value;
92
  const weight = weightRaw === 'bold' ? 700 : Number(weightRaw) || 400;
public/dssl/resolver.js CHANGED
@@ -80,7 +80,7 @@ export const UNITLESS_PROPS = new Set([
80
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
81
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
82
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
83
- 'shape-image-threshold',
84
  // grid line placement takes integers, not lengths: grid-column: span 2
85
  'grid-row', 'grid-column', 'grid-area',
86
  ]);
@@ -106,7 +106,7 @@ export const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,a
106
  'border-inline,border-inline-color,border-inline-end,border-inline-end-color,border-inline-end-style,border-inline-end-width,border-inline-start,border-inline-start-color,border-inline-start-style,border-inline-start-width,border-inline-style,border-inline-width,' +
107
  'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
108
  'border-spacing,border-start-end-radius,border-start-start-radius,border-style,border-top,border-top-color,border-top-left-radius,border-top-right-radius,border-top-style,border-top-width,border-width,' +
109
- 'bottom,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,content-visibility,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,fill,fill-rule,fill-opacity,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-opacity,stroke-width,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' +
110
  'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,text-wrap,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' +
111
  'animation,animation-composition,animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-range,animation-timeline,animation-timing-function,' +
112
  'view-transition-name,interpolate-size,text-box,text-box-edge,text-box-trim,dynamic-range-limit,field-sizing,anchor-name,position-anchor,position-area,scroll-timeline,scroll-timeline-axis,scroll-timeline-name,timeline-scope,view-timeline,view-timeline-axis,view-timeline-name,' +
@@ -268,7 +268,9 @@ function resolveValue(raw, ctx) {
268
  }
269
  // color-ish functions keep bare numbers bare; calc-family gets px
270
  const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
271
- const resolvedInner = resolveValue(inner, { ...ctx, unitless: innerUnitless });
 
 
272
  out += ident + '(' + resolvedInner + ')';
273
  i = j;
274
  continue;
@@ -1136,7 +1138,7 @@ function resolveDecl(prop, value, ctx) {
1136
  });
1137
  return [];
1138
  }
1139
- return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
1140
  }
1141
 
1142
  /** string-aware !important scan - quoted content: "!important" must not trip it */
 
80
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
81
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
82
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
83
+ 'shape-image-threshold', 'counter-reset', 'counter-increment', 'counter-set',
84
  // grid line placement takes integers, not lengths: grid-column: span 2
85
  'grid-row', 'grid-column', 'grid-area',
86
  ]);
 
106
  'border-inline,border-inline-color,border-inline-end,border-inline-end-color,border-inline-end-style,border-inline-end-width,border-inline-start,border-inline-start-color,border-inline-start-style,border-inline-start-width,border-inline-style,border-inline-width,' +
107
  'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
108
  'border-spacing,border-start-end-radius,border-start-start-radius,border-style,border-top,border-top-color,border-top-left-radius,border-top-right-radius,border-top-style,border-top-width,border-width,' +
109
+ 'bottom,counter-reset,counter-increment,counter-set,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,content-visibility,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,fill,fill-rule,fill-opacity,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-opacity,stroke-width,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' +
110
  'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,text-wrap,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' +
111
  'animation,animation-composition,animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-range,animation-timeline,animation-timing-function,' +
112
  'view-transition-name,interpolate-size,text-box,text-box-edge,text-box-trim,dynamic-range-limit,field-sizing,anchor-name,position-anchor,position-area,scroll-timeline,scroll-timeline-axis,scroll-timeline-name,timeline-scope,view-timeline,view-timeline-axis,view-timeline-name,' +
 
268
  }
269
  // color-ish functions keep bare numbers bare; calc-family gets px
270
  const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
271
+ // counter(step), counters(step, "."), attr(data-x): the names inside are the author's own, not tokens
272
+ const nameFn = fname === 'counter' || fname === 'counters' || fname === 'attr';
273
+ const resolvedInner = resolveValue(inner, { ...ctx, unitless: innerUnitless, bareIdents: nameFn ? true : ctx.bareIdents });
274
  out += ident + '(' + resolvedInner + ')';
275
  i = j;
276
  continue;
 
1138
  });
1139
  return [];
1140
  }
1141
+ return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' || prop.startsWith('counter-') }) }];
1142
  }
1143
 
1144
  /** string-aware !important scan - quoted content: "!important" must not trip it */
public/dssl/static.js CHANGED
@@ -20,6 +20,9 @@
20
  * no-JS, a strict CSP, first-paint-before-JS, or 4.5x smaller HTML.
21
  * ========================================================================= */
22
 
 
 
 
23
  const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
24
  const LAYER_NAMES = ['tokens', 'base', 'components', 'utilities', 'pin'];
25
 
@@ -52,6 +55,38 @@ function splitDecls(decls, kinds) {
52
  return { keep, engineProps };
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export function toCSS(doc, opts = {}) {
56
  const file = opts.file || 'styles.dssl';
57
  const report = {
@@ -66,7 +101,8 @@ export function toCSS(doc, opts = {}) {
66
 
67
  const chunks = [];
68
  const header = opts.banner !== false
69
- ? `/* 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, texture, ...) stay in the runtime. */\n`
 
70
  : '';
71
  let out = header;
72
 
@@ -87,8 +123,11 @@ export function toCSS(doc, opts = {}) {
87
  }
88
 
89
  // ---- regular rules ----
90
- for (const r of allRules) {
91
- if (r.signals && r.signals.length) { report.skippedSignals++; continue; }
 
 
 
92
  const { keep, engineProps } = splitDecls(r.decls || [], kinds);
93
  report.engineDecls += engineProps;
94
  if (!keep.length) { if (engineProps) report.skippedEngine++; continue; }
 
20
  * no-JS, a strict CSP, first-paint-before-JS, or 4.5x smaller HTML.
21
  * ========================================================================= */
22
 
23
+ import { buildTexture, composeTextureInto } from './texture.js';
24
+ import { makeColorResolver } from './lint.js';
25
+
26
  const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
27
  const LAYER_NAMES = ['tokens', 'base', 'components', 'utilities', 'pin'];
28
 
 
55
  return { keep, engineProps };
56
  }
57
 
58
+ /**
59
+ * `texture:` is engine-native, but most textures are just images laid over a background, and
60
+ * an image is plain CSS. When every colour is known at build time (tokens are), the layers are
61
+ * built here with the same code the engine uses and written as background-image longhands, so the
62
+ * texture survives the build. What stays in the runtime: a texture that animates, one with a colour
63
+ * that is not a token or a literal, and a rule whose own background is something a build cannot
64
+ * merge with (it is left alone and counted).
65
+ */
66
+ function bakeTexture(rule, toColor, report) {
67
+ const at = (rule.decls || []).findIndex((d) => d.prop === '@texture');
68
+ if (at < 0) return rule;
69
+ const layers = rule.decls[at].value;
70
+ if (!Array.isArray(layers) || !layers.length) return rule; // texture: none
71
+ if (layers.some((l) => l.animate > 0)) { (report.notBaked ||= []).push({ sel: rule.sel, why: 'it animates' }); return rule; }
72
+ for (const l of layers) {
73
+ for (const key of ['color', 'color2']) {
74
+ if (l[key] !== undefined && !toColor(l[key])) { (report.notBaked ||= []).push({ sel: rule.sel, why: `its ${key} (${l[key]}) is not known at build time` }); return rule; }
75
+ }
76
+ }
77
+ const tex = buildTexture(layers, (css) => toColor(css));
78
+ if (!tex.count) return rule;
79
+ // last declaration of each property wins, as in the cascade within one rule
80
+ const owned = new Map();
81
+ for (const d of rule.decls) if (!isEngineProp(d.prop) && /^background/.test(d.prop)) owned.set(d.prop, { value: d.value, imp: '' });
82
+ composeTextureInto(owned, tex);
83
+ const decls = rule.decls.filter((d, i) => i !== at && !(/^background/.test(d.prop) && !isEngineProp(d.prop)));
84
+ const line = rule.decls[at].line;
85
+ for (const [prop, v] of owned) decls.push({ prop, value: v.value, line });
86
+ report.bakedTextures++;
87
+ return { ...rule, decls };
88
+ }
89
+
90
  export function toCSS(doc, opts = {}) {
91
  const file = opts.file || 'styles.dssl';
92
  const report = {
 
101
 
102
  const chunks = [];
103
  const header = opts.banner !== false
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
 
 
123
  }
124
 
125
  // ---- regular rules ----
126
+ const toColor = makeColorResolver(new Map(Object.entries(doc.tokens || {})));
127
+ report.bakedTextures = 0;
128
+ for (const r0 of allRules) {
129
+ if (r0.signals && r0.signals.length) { report.skippedSignals++; continue; }
130
+ const r = bakeTexture(r0, toColor, report);
131
  const { keep, engineProps } = splitDecls(r.decls || [], kinds);
132
  report.engineDecls += engineProps;
133
  if (!keep.length) { if (engineProps) report.skippedEngine++; continue; }
public/dssl/texture.js CHANGED
@@ -28,7 +28,7 @@ const mix2 = (x, y, t) => mix(x, y, t, 'srgb');
28
 
29
  export const BLEND_MODES = ['normal', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'color-burn', 'color-dodge', 'darken', 'lighten', 'difference', 'exclusion'];
30
 
31
- export const GENERATORS = ['noise', 'bump', 'lines', 'dots', 'grid', 'checker', 'weave', 'carbon', 'waves', 'hex'];
32
 
33
  /** the settings a texture layer understands, with how many value words each takes */
34
  export const TEXTURE_KEYS = {
@@ -51,6 +51,11 @@ export const TEXTURE_KEYS = {
51
  blend: { words: 1, kind: 'word', values: BLEND_MODES, doc: 'how the layer mixes with what is under it' },
52
  color: { words: 1, kind: 'color', doc: 'the main colour (dark end of noise, ink of lines and dots)' },
53
  color2: { words: 1, kind: 'color', doc: 'the second colour (light end of noise, background cell of patterns)' },
 
 
 
 
 
54
  invert: { words: 0, kind: 'flag', doc: 'swap the two colours' },
55
  };
56
 
@@ -100,6 +105,26 @@ export const PRESETS = {
100
  ] },
101
  wood: { doc: 'long wood grain, along x (use angle 90 for vertical)',
102
  layers: [{ gen: 'noise', scale: 4, octaves: 3, contrast: 1.8, stretch: [40, 1], intensity: 1, color: '#7a4b24', color2: '#c99a62', blend: 'multiply' }] },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  bark: { doc: 'rough vertical bark',
104
  layers: [{ gen: 'bump', scale: 10, octaves: 4, depth: 5, stretch: [1, 6], light: 150, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay' }] },
105
  leather: { doc: 'pebbled leather',
@@ -164,6 +189,7 @@ const GEN_DEFAULTS = {
164
  carbon: { scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' },
165
  waves: { scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' },
166
  hex: { scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
 
167
  };
168
 
169
  /* ------------------------------------------------------------------ *
@@ -452,7 +478,134 @@ function buildHex(spec, rc) {
452
  return [{ image: svgUrl(svg), size: `${f(W)}px ${f(100 * k)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: W }];
453
  }
454
 
455
- const BUILDERS = { noise: buildNoise, bump: buildBump, lines: buildLines, dots: buildDots, grid: buildGrid, checker: buildChecker, weave: buildWeave, carbon: buildCarbon, waves: buildWaves, hex: buildHex };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
 
457
  /**
458
  * Turn parsed layers into the CSS lists for background-image, -size, -repeat,
@@ -477,6 +630,59 @@ export function buildTexture(layers, rc) {
477
  };
478
  }
479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  /* ------------------------------------------------------------------ *
481
  * deterministic value noise, for aura masks and tests
482
  * ------------------------------------------------------------------ */
 
28
 
29
  export const BLEND_MODES = ['normal', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'color-burn', 'color-dodge', 'darken', 'lighten', 'difference', 'exclusion'];
30
 
31
+ export const GENERATORS = ['noise', 'bump', 'lines', 'dots', 'grid', 'checker', 'weave', 'carbon', 'waves', 'hex', 'rings'];
32
 
33
  /** the settings a texture layer understands, with how many value words each takes */
34
  export const TEXTURE_KEYS = {
 
51
  blend: { words: 1, kind: 'word', values: BLEND_MODES, doc: 'how the layer mixes with what is under it' },
52
  color: { words: 1, kind: 'color', doc: 'the main colour (dark end of noise, ink of lines and dots)' },
53
  color2: { words: 1, kind: 'color', doc: 'the second colour (light end of noise, background cell of patterns)' },
54
+ figure: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: how strongly the growth rings arch into a cathedral figure, 0 is straight grain' },
55
+ warp: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: how much each ring wobbles along its length' },
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
 
 
105
  ] },
106
  wood: { doc: 'long wood grain, along x (use angle 90 for vertical)',
107
  layers: [{ gen: 'noise', scale: 4, octaves: 3, contrast: 1.8, stretch: [40, 1], intensity: 1, color: '#7a4b24', color2: '#c99a62', blend: 'multiply' }] },
108
+ oak: { doc: 'oak: open grain, tan with brown rings',
109
+ layers: [{ gen: 'rings', scale: 11, color: '#8a5f33', color2: '#c9a574', contrast: 1.3, figure: 0.55, warp: 0.45, pores: 0.5, seed: 3, blend: 'normal' }] },
110
+ walnut: { doc: 'walnut: dark chocolate with black-brown rings',
111
+ layers: [{ gen: 'rings', scale: 9, color: '#2b1a10', color2: '#6a4631', contrast: 1.2, figure: 0.5, warp: 0.55, pores: 0.45, seed: 8, blend: 'normal' }] },
112
+ cherry: { doc: 'cherry: warm red-brown with fine rings',
113
+ layers: [{ gen: 'rings', scale: 8, color: '#7a3a1c', color2: '#bb7443', contrast: 1.1, figure: 0.4, warp: 0.35, pores: 0.35, seed: 5, blend: 'normal' }] },
114
+ maple: { doc: 'maple: pale, tight, nearly plain grain',
115
+ layers: [{ gen: 'rings', scale: 13, color: '#c79a5a', color2: '#ecd6ab', contrast: 0.8, figure: 0.25, warp: 0.3, pores: 0.25, seed: 11, blend: 'normal' }] },
116
+ pine: { doc: 'pine: light yellow wood with wide rings and knots',
117
+ layers: [{ gen: 'rings', scale: 16, color: '#b5742f', color2: '#e6c081', contrast: 1.4, figure: 0.65, warp: 0.5, pores: 0.4, knots: 2, seed: 14, blend: 'normal' }] },
118
+ mahogany: { doc: 'mahogany: deep red-brown, straight grain',
119
+ layers: [{ gen: 'rings', scale: 10, color: '#4a2012', color2: '#8c4727', contrast: 1.1, figure: 0.3, warp: 0.3, pores: 0.4, seed: 6, blend: 'normal' }] },
120
+ ebony: { doc: 'ebony: nearly black with faint streaks',
121
+ layers: [{ gen: 'rings', scale: 12, color: '#0c0908', color2: '#2d2621', contrast: 0.7, figure: 0.2, warp: 0.25, pores: 0.3, seed: 2, blend: 'normal' }] },
122
+ birch: { doc: 'birch: creamy pale wood with fine rings',
123
+ layers: [{ gen: 'rings', scale: 10, color: '#d2ae74', color2: '#f0dfb9', contrast: 0.9, figure: 0.3, warp: 0.35, pores: 0.25, seed: 9, blend: 'normal' }] },
124
+ 'oak-planks': { doc: 'oak laid as planks, with seams',
125
+ layers: [{ gen: 'rings', scale: 11, color: '#8a5f33', color2: '#c9a574', contrast: 1.3, figure: 0.55, warp: 0.45, pores: 0.5, planks: 110, seed: 3, blend: 'normal' }] },
126
+ 'walnut-planks': { doc: 'walnut laid as planks, with seams',
127
+ layers: [{ gen: 'rings', scale: 9, color: '#2b1a10', color2: '#6a4631', contrast: 1.2, figure: 0.5, warp: 0.55, pores: 0.45, planks: 96, seed: 8, blend: 'normal' }] },
128
  bark: { doc: 'rough vertical bark',
129
  layers: [{ gen: 'bump', scale: 10, octaves: 4, depth: 5, stretch: [1, 6], light: 150, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay' }] },
130
  leather: { doc: 'pebbled leather',
 
189
  carbon: { scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' },
190
  waves: { scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' },
191
  hex: { scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
192
+ rings: { scale: 10, color: '#7a4b24', color2: '#d1a770', contrast: 1.2, figure: 0.5, warp: 0.4, pores: 0.4, intensity: 1, blend: 'normal' },
193
  };
194
 
195
  /* ------------------------------------------------------------------ *
 
478
  return [{ image: svgUrl(svg), size: `${f(W)}px ${f(100 * k)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: W }];
479
  }
480
 
481
+
482
+ /* ------------------------------------------------------------------ *
483
+ * rings: growth rings, drawn as vector curves
484
+ *
485
+ * A board is a log sliced, and the rings you see are where the slice crosses
486
+ * the growth layers. So this does not stretch noise: it draws each ring as a
487
+ * curve. The spacing wobbles, the curves arch (cathedral figure) because the
488
+ * slice cuts the layers at a slant, there are pores between them, and planks
489
+ * and knots are drawn on top. Everything comes from a seeded generator and
490
+ * integer harmonics, so the same seed gives the same board on every machine
491
+ * and the tile has no visible seam.
492
+ * ------------------------------------------------------------------ */
493
+
494
+ const TAU = Math.PI * 2;
495
+ const r1 = (n) => String(Math.round(n * 10) / 10);
496
+ const smallUrl = (svg) => `url("data:image/svg+xml,${svg.replace(/[%#<>"]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())}")`;
497
+
498
+ /** a smooth path through [x, y] samples (Catmull-Rom as cubic Beziers, ends clamped) */
499
+ function smoothPath(pts) {
500
+ const n = pts.length;
501
+ const at = (i) => pts[Math.max(0, Math.min(n - 1, i))];
502
+ let d = `M${r1(pts[0][0])} ${r1(pts[0][1])}`;
503
+ for (let i = 0; i < n - 1; i++) {
504
+ const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
505
+ const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
506
+ const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
507
+ d += `C${r1(c1x)} ${r1(c1y)} ${r1(c2x)} ${r1(c2y)} ${r1(p2[0])} ${r1(p2[1])}`;
508
+ }
509
+ return d;
510
+ }
511
+
512
+ function buildRings(spec, rc) {
513
+ const { a, b } = layerColors(spec, rc);
514
+ const late = a, early = b || a;
515
+ const P = Math.max(3, spec.scale || 10);
516
+ const W = 576; // one tile along the grain; every harmonic below divides it
517
+ const fig = spec.figure === undefined ? 0.5 : spec.figure;
518
+ const warp = spec.warp === undefined ? 0.4 : spec.warp;
519
+ const pores = spec.pores === undefined ? 0.4 : spec.pores;
520
+ const contrast = spec.contrast === undefined ? 1.2 : spec.contrast;
521
+ const seed = spec.seed || 0;
522
+ const planked = !!spec.planks;
523
+ const plankH = planked ? Math.max(P * 3, spec.planks) : 0;
524
+ const plankN = planked ? 3 : 1;
525
+ const H = planked ? plankH * plankN : P * Math.max(12, Math.round(260 / P));
526
+ const rgba = (c, alpha) => toRgba(withAlpha(c, n01((alpha === undefined ? 1 : alpha) * c.a)));
527
+ const STEP = 48;
528
+
529
+ let defs = '', body = '';
530
+ let id = 0;
531
+ for (let pi = 0; pi < plankN; pi++) {
532
+ const rand = rng(seed * 7919 + pi * 104729 + 13);
533
+ const h = planked ? plankH : H;
534
+ const n = Math.max(3, Math.round(h / P));
535
+ const Pe = h / n;
536
+ const ph = [rand() * TAU, rand() * TAU, rand() * TAU];
537
+ const x0 = rand() * W;
538
+ const m = 2 + Math.floor(rand() * 3), spacingAmp = 0.12 + rand() * 0.22;
539
+ const flip = planked && rand() < 0.5;
540
+ const curve = (kf, x) => {
541
+ const t = kf / n;
542
+ // a free board arches from one face to the other; a seamless tile must repeat every n rings, so its envelope has period n
543
+ const per = Math.pow(Math.sin(Math.PI * t), 2);
544
+ const env = planked ? Math.pow(Math.max(0, Math.min(1, flip ? 1 - t : t)), 1.4) : per;
545
+ const env2 = planked ? 0.6 + 0.4 * Math.abs(Math.sin(Math.PI * t)) : per;
546
+ const bump = Math.pow((1 + Math.cos(TAU * (x - x0) / W)) / 2, 1.3);
547
+ const arch = fig * 0.3 * h * env * bump;
548
+ const wob = warp * P * 0.6 * env2 * (0.5 * Math.sin(TAU * x / W + ph[0] + TAU * kf / n) + 0.3 * Math.sin(TAU * 3 * x / W + ph[1] + TAU * 2 * kf / n) + 0.2 * Math.sin(TAU * 7 * x / W + ph[2] + TAU * 3 * kf / n));
549
+ const u = kf + spacingAmp * Math.sin(TAU * m * kf / n);
550
+ return u * Pe + arch + wob;
551
+ };
552
+ // sampled past both ends of the tile, so the stroke is continuous across the join and the viewport clips it
553
+ const ringPath = (kf) => { const pts = []; for (let x = -2 * STEP; x <= W + 2 * STEP; x += STEP) pts.push([x, curve(kf, x)]); return smoothPath(pts); };
554
+ const jit = Array.from({ length: n }, () => [rand(), rand()]);
555
+
556
+ const lo = planked ? -2 : -1, hi = planked ? n + 2 : n;
557
+ let g = '';
558
+ for (let k = lo; k <= hi; k++) {
559
+ const [jitter, jop] = jit[((k % n) + n) % n]; // ring k and ring k + n look alike, so the tile repeats cleanly
560
+ const w = Pe * (0.14 + jitter * 0.26);
561
+ const op = n01((0.4 + jop * 0.55) * Math.min(1.4, contrast / 1.2));
562
+ const rid = 'r' + id++;
563
+ defs += `<path id='${rid}' d='${ringPath(k + 0.8)}' fill='none'/>`;
564
+ g += `<use href='#${rid}' stroke='${rgba(late, op * 0.28)}' stroke-width='${r1(w * 2.6)}'/><use href='#${rid}' stroke='${rgba(late, op)}' stroke-width='${r1(w)}'/>`;
565
+ }
566
+ // knots: elongated along the grain, with rings around them
567
+ const knotN = spec.knots ? Math.max(0, Math.round(spec.knots / plankN + (pi === 0 ? 0.49 : 0))) : 0;
568
+ let knots = '';
569
+ for (let q = 0; q < knotN; q++) {
570
+ const kx = 90 + rand() * (W - 180), ky = h * (0.25 + rand() * 0.5), rx = 9 + rand() * 12, ry = rx * 0.6;
571
+ knots += `<ellipse cx='${r1(kx)}' cy='${r1(ky)}' rx='${r1(rx * 4)}' ry='${r1(ry * 3.4)}' fill='${rgba(late, 0.1)}'/>` +
572
+ `<ellipse cx='${r1(kx)}' cy='${r1(ky)}' rx='${r1(rx * 2.4)}' ry='${r1(ry * 2.3)}' fill='none' stroke='${rgba(late, 0.35)}' stroke-width='1.2'/>` +
573
+ `<ellipse cx='${r1(kx)}' cy='${r1(ky)}' rx='${r1(rx * 1.6)}' ry='${r1(ry * 1.6)}' fill='none' stroke='${rgba(late, 0.5)}' stroke-width='1.4'/>` +
574
+ `<ellipse cx='${r1(kx)}' cy='${r1(ky)}' rx='${r1(rx)}' ry='${r1(ry)}' fill='${rgba(late, 0.85)}'/>`;
575
+ }
576
+ const tone = (rand() - 0.5) * 0.14;
577
+ const toneRect = tone > 0 ? `<rect width='${W}' height='${r1(h)}' fill='rgba(255,255,255,${r1(Math.abs(tone) * 10) / 10})'/>` : `<rect width='${W}' height='${r1(h)}' fill='rgba(0,0,0,${r1(Math.abs(tone) * 10) / 10})'/>`;
578
+ const clip = `c${pi}`;
579
+ defs += `<clipPath id='${clip}'><rect width='${W}' height='${r1(h)}'/></clipPath>`;
580
+ body += `<g transform='translate(0 ${r1(pi * plankH)})' clip-path='url(#${clip})'>` +
581
+ `<rect width='${W}' height='${r1(h)}' fill='${rgba(early)}'/>` + (planked ? toneRect : '') + `<g fill='none'>${g}</g>${knots}</g>`;
582
+ }
583
+
584
+ // fine fibres: streaks stretched along the grain, drawn once over everything
585
+ const fibre = pores > 0
586
+ ? `<filter id='p' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>` +
587
+ `<feTurbulence type='fractalNoise' baseFrequency='0.014 0.62' numOctaves='3' seed='${seed + 3}' stitchTiles='stitch'/>` +
588
+ `<feColorMatrix type='matrix' values='0 0 0 0 ${f(late.r / 255)} 0 0 0 0 ${f(late.g / 255)} 0 0 0 0 ${f(late.b / 255)} ${f(pores * 4.2)} 0 0 0 ${f(-pores * 1.95)}'/></filter>` +
589
+ `<rect width='${W}' height='${H}' filter='url(#p)' opacity='${f(Math.min(1, 0.55 * late.a))}'/>`
590
+ : '';
591
+
592
+ let seams = '';
593
+ if (planked) {
594
+ for (let pi = 0; pi < plankN; pi++) {
595
+ const y = pi * plankH;
596
+ seams += `<rect y='${r1(y)}' width='${W}' height='1.6' fill='rgba(0,0,0,0.5)'/><rect y='${r1(y + 1.6)}' width='${W}' height='1' fill='rgba(255,255,255,0.1)'/>`;
597
+ }
598
+ }
599
+
600
+ const rot = spec.angle === 90 || spec.angle === -90 || spec.angle === 270;
601
+ const inner = `<defs>${defs}</defs>${body}${fibre}${seams}`;
602
+ const svg = rot
603
+ ? `<svg xmlns='http://www.w3.org/2000/svg' width='${H}' height='${W}'><g transform='matrix(0 1 1 0 0 0)'>${inner}</g></svg>`
604
+ : `<svg xmlns='http://www.w3.org/2000/svg' width='${W}' height='${H}'>${inner}</svg>`;
605
+ return [{ image: smallUrl(svg), size: rot ? `${H}px ${W}px` : `${W}px ${H}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: Math.max(W, H) }];
606
+ }
607
+
608
+ const BUILDERS = { rings: buildRings, noise: buildNoise, bump: buildBump, lines: buildLines, dots: buildDots, grid: buildGrid, checker: buildChecker, weave: buildWeave, carbon: buildCarbon, waves: buildWaves, hex: buildHex };
609
 
610
  /**
611
  * Turn parsed layers into the CSS lists for background-image, -size, -repeat,
 
630
  };
631
  }
632
 
633
+
634
+ /* ------------------------------------------------------------------ *
635
+ * composing with the element's own background (shared by the engine and `dssl build`)
636
+ * ------------------------------------------------------------------ */
637
+
638
+ export function splitBgList(value) {
639
+ const out = []; let depth = 0, cur = '';
640
+ for (const ch of String(value)) {
641
+ if (ch === '(') depth++;
642
+ if (ch === ')') depth--;
643
+ if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
644
+ cur += ch;
645
+ }
646
+ if (cur.trim()) out.push(cur.trim());
647
+ return out;
648
+ }
649
+ /** a list of exactly n entries: the author's, cycled the way CSS cycles them, or the default */
650
+ function expandList(value, n, dflt) {
651
+ const items = value ? splitBgList(value) : [];
652
+ if (!items.length) return Array.from({ length: n }, () => dflt);
653
+ return Array.from({ length: n }, (_, i) => items[i % items.length]);
654
+ }
655
+
656
+
657
+ /**
658
+ * texture sits ON TOP of whatever background the author wrote: the shorthand, the longhands,
659
+ * or just a colour. `owned` is a Map of prop -> { value, imp }; this edits it in place.
660
+ */
661
+ export function composeTextureInto(owned, tex) {
662
+ const sh = owned.get('background');
663
+ const img = owned.get('background-image');
664
+ const blendUser = owned.get('background-blend-mode');
665
+ const texBlends = tex.blend.split(', ');
666
+ if (sh) {
667
+ // the author wrote the shorthand: texture layers go first, their value stays the last layer(s)
668
+ const userLayers = splitBgList(sh.value).length;
669
+ const texShort = tex.parts.map((p) => `${p.image} ${p.position} / ${p.size} ${p.repeat}`).join(', ');
670
+ owned.set('background', { value: `${texShort}, ${sh.value}`, imp: sh.imp });
671
+ owned.delete('background-image');
672
+ owned.set('background-blend-mode', { value: [...texBlends, ...expandList(blendUser && blendUser.value, userLayers, 'normal')].join(', '), imp: sh.imp });
673
+ return;
674
+ }
675
+ const userLayers = img ? splitBgList(img.value).length : 0;
676
+ const imp = img ? img.imp : '';
677
+ const get = (name) => (owned.get(name) || {}).value;
678
+ const join = (texList, name, dflt) => [...texList.split(/,(?![^(]*\))/).map((x) => x.trim()), ...(userLayers ? expandList(get(name), userLayers, dflt) : [])].join(', ');
679
+ owned.set('background-image', { value: tex.image + (img ? ', ' + img.value : ''), imp });
680
+ owned.set('background-size', { value: join(tex.size, 'background-size', 'auto'), imp });
681
+ owned.set('background-position', { value: join(tex.position, 'background-position', '0% 0%'), imp });
682
+ owned.set('background-repeat', { value: join(tex.repeat, 'background-repeat', 'repeat'), imp });
683
+ owned.set('background-blend-mode', { value: [...texBlends, ...(userLayers ? expandList(get('background-blend-mode'), userLayers, 'normal') : [])].join(', '), imp });
684
+ }
685
+
686
  /* ------------------------------------------------------------------ *
687
  * deterministic value noise, for aura masks and tests
688
  * ------------------------------------------------------------------ */
public/llms.txt CHANGED
@@ -50,7 +50,7 @@ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
50
  ```
51
 
52
  ## 0.9.0: texture, aura, sliders, lint, import
53
- - `texture: grain` layers a generated image ON TOP of an element's own background (colour, gradient or several layers). 30 presets (grain, noise, rough, sand, paper, cloud, concrete, stone, crumpled, glitter, frost, marble, wood, bark, leather, brushed, canvas, linen, denim, carbon, scanlines, halftone, dots, lines, grid, checker, crosshatch, stripes, waves, hex) and 10 generators (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex) with settings: scale, intensity, contrast, threshold, octaves, seed, stretch x y, depth, light, angle, thickness, radius, softness, contour, animate, type, blend, color, color2, invert. Comma stacks layers. Runtime-only (`dssl build` leaves it in and reports it).
54
  - Aura has NO presets. `aura-glow: <color> <radius> [intensity|pulse|depth|flicker|texture(...)|seed|essential]`, `aura-particle: <color -> color ...> <count> [size|life|speed|dir|spread|gravity|drag|turbulence|from|shape|grow|glowing|behind|blend|texture(...)|seed|essential]`, `glow-particle` (particles that glow), `aura-glow-particle` (both slots), `aura: glow ..., particles ...` (replaces both; `aura: none` clears both, `aura-glow: none` only the glow). Name your own with `behavior fire { ... }` then `use fire`. One canvas over the page, 1800 particle budget, still glow under reduced motion, runtime-only.
55
  - `slider: track 6 color.line radius full, fill color.accent, thumb 24 color.paper, ring 3 color.accent, glow color.accent 14, ticks 10, graph 20 45 70` writes the ::-webkit-slider-* and ::-moz-range-* rules together for `input[type=range]`. The engine keeps `--dssl-p` (value 0..1) on the input. Firefox rules are generated but untested.
56
  - Lint warnings (never errors; `dssl check --strict` makes them fatal): text contrast under 4.5:1 (3:1 for large text), a removed focus outline with no focus-visible replacement, tap targets under 24px.
@@ -63,6 +63,14 @@ 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
  ## CLI (v0.8.0)
67
 
68
  ```sh
@@ -76,4 +84,4 @@ Wait for the `dssl-ready` event on `window` before measuring layout: the engine
76
 
77
  ## Editor support
78
 
79
- The official extension (VS Code and VSCodium, same .vsix) bundles the real parser and resolver: diagnostics identical to the browser console, completions from the workspace token registry, hover docs for the semantic vocabulary. In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.10.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
 
50
  ```
51
 
52
  ## 0.9.0: texture, aura, sliders, lint, import
53
+ - `texture: grain` layers a generated image ON TOP of an element's own background (colour, gradient or several layers). 40 presets (30 general: grain, noise, rough, sand, paper, cloud, concrete, stone, crumpled, glitter, frost, marble, wood, bark, leather, brushed, canvas, linen, denim, carbon, scanlines, halftone, dots, lines, grid, checker, crosshatch, stripes, waves, hex) and 11 generators (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex, rings) with settings: scale, figure, warp, pores, knots, planks, intensity, contrast, threshold, octaves, seed, stretch x y, depth, light, angle, thickness, radius, softness, contour, animate, type, blend, color, color2, invert. Comma stacks layers. Built at run time, and baked into the CSS by `dssl build` when its colours are known (0.11.0).
54
  - Aura has NO presets. `aura-glow: <color> <radius> [intensity|pulse|depth|flicker|texture(...)|seed|essential]`, `aura-particle: <color -> color ...> <count> [size|life|speed|dir|spread|gravity|drag|turbulence|from|shape|grow|glowing|behind|blend|texture(...)|seed|essential]`, `glow-particle` (particles that glow), `aura-glow-particle` (both slots), `aura: glow ..., particles ...` (replaces both; `aura: none` clears both, `aura-glow: none` only the glow). Name your own with `behavior fire { ... }` then `use fire`. One canvas over the page, 1800 particle budget, still glow under reduced motion, runtime-only.
55
  - `slider: track 6 color.line radius full, fill color.accent, thumb 24 color.paper, ring 3 color.accent, glow color.accent 14, ticks 10, graph 20 45 70` writes the ::-webkit-slider-* and ::-moz-range-* rules together for `input[type=range]`. The engine keeps `--dssl-p` (value 0..1) on the input. Firefox rules are generated but untested.
56
  - Lint warnings (never errors; `dssl check --strict` makes them fatal): text contrast under 4.5:1 (3:1 for large text), a removed focus outline with no focus-visible replacement, tap targets under 24px.
 
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.
69
+ - `counter-reset`, `counter-increment`, `counter-set` and `content: counter(name)` / `counters(name, ".")` work.
70
+ - The contrast lint no longer cries wolf on translucent backgrounds: it warns only when the pair fails on the light page colour, white and black.
71
+ - A page can hide itself until styled: `<style>html:not(.dssl-ready) body{visibility:hidden}</style>`; the engine adds `dssl-ready` to `<html>`.
72
+ - Showcase: /showcase/wood/ is a whole page (Heartwood) built with these.
73
+
74
  ## CLI (v0.8.0)
75
 
76
  ```sh
 
84
 
85
  ## Editor support
86
 
87
+ The official extension (VS Code and VSCodium, same .vsix) bundles the real parser and resolver: diagnostics identical to the browser console, completions from the workspace token registry, hover docs for the semantic vocabulary. In VSCodium, search "DSSL" in the Extensions tab (it is on Open VSX: https://open-vsx.org/extension/ehs-studio/dssl-vscode). In VS Code, download `dssl-vscode/dssl-vscode-0.11.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. Source lives in `dssl-vscode/` in the repo.
public/showcase/wood/index.html ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Heartwood: a field guide to wood</title>
7
+ <meta name="description" content="How wood is built: growth rings, figure, knots, ten species, and how much a board moves when the air dries out. Every texture on the page is generated by DSSL.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700;9..144,800&family=Hanken+Grotesk:wght@400;500;700&display=swap">
11
+ <!-- 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="wood.css">
13
+ </head>
14
+ <body>
15
+ <a class="skip" href="#main">Skip to the guide</a>
16
+
17
+ <header class="top">
18
+ <div class="wrap">
19
+ <a class="brand" href="#">Heartwood</a>
20
+ <nav class="nav" aria-label="Sections">
21
+ <a href="#anatomy">Anatomy</a>
22
+ <a href="#species">Species</a>
23
+ <a href="#process">Log to board</a>
24
+ <a href="#movement">Movement</a>
25
+ <a href="#fire">Fire</a>
26
+ </nav>
27
+ </div>
28
+ </header>
29
+
30
+ <main id="main">
31
+ <section class="hero">
32
+ <div class="wrap">
33
+ <div class="slab">
34
+ <h1>Every board is a slice of weather.</h1>
35
+ <p>A tree writes down each year it lives: wide rings for wet summers, tight ones for drought. This is a field guide to reading the grain, choosing a species, and knowing why a table moves in winter.</p>
36
+ <a class="cta" href="#anatomy">Read the grain</a>
37
+ </div>
38
+ </div>
39
+ </section>
40
+
41
+ <section class="block" id="anatomy">
42
+ <div class="wrap">
43
+ <p class="kicker">Anatomy</p>
44
+ <h2 class="title">What you are looking at when you look at grain</h2>
45
+ <p class="lede">Wood grows outward in layers. Cut a log and the layers show as rings. Cut a board and the same layers show as lines, arches and the occasional knot.</p>
46
+ <div class="trio">
47
+ <article class="specimen sp-rings">
48
+ <div class="note"><b>Growth rings</b>One ring is one growing season: pale, fast earlywood in spring, dense dark latewood in late summer. Count them and you have the tree's age.</div>
49
+ </article>
50
+ <article class="specimen sp-figure">
51
+ <div class="note"><b>Cathedral figure</b>When a board is sawn tangent to the rings, the cut slices each cone-shaped layer at a slant, and the lines bend into arches.</div>
52
+ </article>
53
+ <article class="specimen sp-knot">
54
+ <div class="note"><b>Knots</b>A knot is the base of a branch that the trunk grew around. It is harder, darker, and it does not move with the grain around it.</div>
55
+ </article>
56
+ </div>
57
+ <dl class="facts">
58
+ <div><dt>Hardwood or softwood</dt><dd>The words are botanical, not about hardness. Hardwoods come from broadleaf trees, softwoods from conifers. Balsa is a hardwood.</dd></div>
59
+ <div><dt>Heartwood and sapwood</dt><dd>The dark core of a trunk is heartwood, no longer carrying water. The pale outer band is sapwood. Many species are darkest in the middle.</dd></div>
60
+ <div><dt>Plain-sawn or quarter-sawn</dt><dd>Plain-sawn boards show cathedral arches and move more. Quarter-sawn boards show straight lines and stay flatter.</dd></div>
61
+ </dl>
62
+ </div>
63
+ </section>
64
+
65
+ <section class="block block-dark" id="species">
66
+ <div class="wrap">
67
+ <p class="kicker">Species</p>
68
+ <h2 class="title">Eight boards from the sample rack</h2>
69
+ <p class="lede">Hardness is the Janka test: the force in pounds needed to push a steel ball half its width into the wood. Higher is harder to dent, and harder to work. Figures are rounded averages.</p>
70
+ <div class="rack">
71
+ <figure class="sample"><div class="board b-oak" role="img" aria-label="White oak sample board"></div><figcaption><h3>White oak</h3><p class="latin">Quercus alba</p><div class="hard"><input type="range" min="0" max="3200" value="1360" disabled aria-label="Hardness 1360 on a scale to 3200"><span>1,360 lbf</span></div></figcaption></figure>
72
+ <figure class="sample"><div class="board b-walnut" role="img" aria-label="Black walnut sample board"></div><figcaption><h3>Black walnut</h3><p class="latin">Juglans nigra</p><div class="hard"><input type="range" min="0" max="3200" value="1010" disabled aria-label="Hardness 1010 on a scale to 3200"><span>1,010 lbf</span></div></figcaption></figure>
73
+ <figure class="sample"><div class="board b-cherry" role="img" aria-label="Black cherry sample board"></div><figcaption><h3>Black cherry</h3><p class="latin">Prunus serotina</p><div class="hard"><input type="range" min="0" max="3200" value="950" disabled aria-label="Hardness 950 on a scale to 3200"><span>950 lbf</span></div></figcaption></figure>
74
+ <figure class="sample"><div class="board b-maple" role="img" aria-label="Hard maple sample board"></div><figcaption><h3>Hard maple</h3><p class="latin">Acer saccharum</p><div class="hard"><input type="range" min="0" max="3200" value="1450" disabled aria-label="Hardness 1450 on a scale to 3200"><span>1,450 lbf</span></div></figcaption></figure>
75
+ <figure class="sample"><div class="board b-pine" role="img" aria-label="Eastern white pine sample board"></div><figcaption><h3>White pine</h3><p class="latin">Pinus strobus</p><div class="hard"><input type="range" min="0" max="3200" value="380" disabled aria-label="Hardness 380 on a scale to 3200"><span>380 lbf</span></div></figcaption></figure>
76
+ <figure class="sample"><div class="board b-mahogany" role="img" aria-label="Mahogany sample board"></div><figcaption><h3>Mahogany</h3><p class="latin">Swietenia macrophylla</p><div class="hard"><input type="range" min="0" max="3200" value="800" disabled aria-label="Hardness 800 on a scale to 3200"><span>800 lbf</span></div></figcaption></figure>
77
+ <figure class="sample"><div class="board b-ebony" role="img" aria-label="Gaboon ebony sample board"></div><figcaption><h3>Gaboon ebony</h3><p class="latin">Diospyros crassiflora</p><div class="hard"><input type="range" min="0" max="3200" value="3080" disabled aria-label="Hardness 3080 on a scale to 3200"><span>3,080 lbf</span></div></figcaption></figure>
78
+ <figure class="sample"><div class="board b-birch" role="img" aria-label="Yellow birch sample board"></div><figcaption><h3>Yellow birch</h3><p class="latin">Betula alleghaniensis</p><div class="hard"><input type="range" min="0" max="3200" value="1260" disabled aria-label="Hardness 1260 on a scale to 3200"><span>1,260 lbf</span></div></figcaption></figure>
79
+ </div>
80
+ </div>
81
+ </section>
82
+
83
+ <section class="block" id="process">
84
+ <div class="wrap">
85
+ <p class="kicker">Log to board</p>
86
+ <h2 class="title">Five steps between a forest and a shelf</h2>
87
+ <p class="lede">Most of what goes wrong with wood goes wrong in the second and third steps, long before it is a table.</p>
88
+ <ol class="steps">
89
+ <li><h3>Fell and log</h3><p>A tree is cut and bucked into logs. Fresh wood is mostly water, sometimes more than half its weight.</p></li>
90
+ <li><h3>Saw</h3><p>The sawyer chooses how to cut the log. Plain-sawn gets the most boards. Quarter-sawn gets the most stable ones.</p></li>
91
+ <li><h3>Dry</h3><p>Boards are air-dried or kiln-dried until the wood holds only 6 to 12 percent moisture, the level indoor air will keep it at.</p></li>
92
+ <li><h3>Mill</h3><p>Dry boards are flattened and squared. This is when hidden stress shows: a board that was dried too fast will cup or bow.</p></li>
93
+ <li><h3>Finish</h3><p>Oil soaks in and darkens the grain. Wax and lacquer sit on top. None of them stop wood from taking on and losing moisture.</p></li>
94
+ </ol>
95
+ </div>
96
+ </section>
97
+
98
+ <section class="block block-dark" id="movement">
99
+ <div class="wrap">
100
+ <p class="kicker">Movement</p>
101
+ <h2 class="title">How much will it move?</h2>
102
+ <p class="lede">Wood swells when it takes on moisture and shrinks when it loses it, mostly across the grain and most of all along the growth rings. Pick a species, a board width, and how many points of moisture content the air will change.</p>
103
+ <div class="calc">
104
+ <div class="tool">
105
+ <div>
106
+ <label for="sp">Species (plain-sawn)</label>
107
+ <select id="sp">
108
+ <option value="10.5">White oak</option>
109
+ <option value="7.8">Black walnut</option>
110
+ <option value="7.1">Black cherry</option>
111
+ <option value="9.9" selected>Hard maple</option>
112
+ <option value="6.1">White pine</option>
113
+ <option value="4.1">Mahogany</option>
114
+ <option value="9.5">Yellow birch</option>
115
+ </select>
116
+ </div>
117
+ <div>
118
+ <label for="w">Board width <span class="readout" id="wv">40 cm</span></label>
119
+ <input id="w" type="range" min="5" max="100" value="40">
120
+ </div>
121
+ <div>
122
+ <label for="mc">Change in moisture content <span class="readout" id="mcv">4 points</span></label>
123
+ <input id="mc" type="range" min="0" max="12" value="4">
124
+ </div>
125
+ </div>
126
+ <div class="result" aria-live="polite">
127
+ <div class="result-card">
128
+ <div class="mm"><span id="out">0</span> mm</div>
129
+ <p class="mm-note" id="note"></p>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ <p class="small">An estimate, not an engineering number. It assumes the wood moves in proportion to its tangential shrinkage from green to oven-dry (taken from USDA Wood Handbook averages), spread evenly over the roughly 28 points of moisture content between fully green and the fibre saturation point. Real boards depend on how they were cut.</p>
134
+ </div>
135
+ </section>
136
+
137
+ <section class="block" id="fire">
138
+ <div class="wrap hearth">
139
+ <div>
140
+ <p class="kicker">Fire</p>
141
+ <h2 class="title">The last thing wood does</h2>
142
+ <p class="lede" style="margin-bottom:0">Firewood burns best when it has been seasoned, split and stacked off the ground for six months to two years, until it holds under about 20 percent moisture. Wet wood spends its heat boiling off water, and smokes.</p>
143
+ </div>
144
+ <div class="grate" role="img" aria-label="A log burning"><div class="log"></div></div>
145
+ </div>
146
+ </section>
147
+ </main>
148
+
149
+ <footer class="foot">
150
+ <div class="wrap">
151
+ <p>Built with <a href="https://dssl-sable.vercel.app/">DSSL</a>. The page's look is <code>static.dssl</code>, compiled to <code>wood.css</code> with <code>dssl build</code>, textures included. The tilt, the fire and the slider fills are <code>live.dssl</code>, applied by the engine.</p>
152
+ <p>Every wood grain here is generated vector art from the <code>rings</code> texture, not a photograph. Janka and shrinkage values are rounded averages from the USDA Wood Handbook and the Wood Database.</p>
153
+ </div>
154
+ </footer>
155
+
156
+ <script>
157
+ // the calculator: movement = width x tangential shrinkage x (change in moisture / 28)
158
+ (() => {
159
+ const $ = (id) => document.getElementById(id);
160
+ const sp = $('sp'), w = $('w'), mc = $('mc');
161
+ const calc = () => {
162
+ const mm = Number(w.value) * 10 * (Number(sp.value) / 100) * (Number(mc.value) / 28);
163
+ $('out').textContent = mm < 10 ? mm.toFixed(1) : String(Math.round(mm));
164
+ $('wv').textContent = w.value + ' cm';
165
+ $('mcv').textContent = mc.value + (mc.value === '1' ? ' point' : ' points');
166
+ const name = sp.options[sp.selectedIndex].text.toLowerCase();
167
+ $('note').textContent = `A ${w.value} cm wide ${name} board changes width by about this much across the grain when it gains or loses ${mc.value} points of moisture.`;
168
+ for (const el of [w, mc]) el.style.setProperty('--dssl-p', (el.value - el.min) / (el.max - el.min));
169
+ };
170
+ for (const el of [sp, w, mc]) el.addEventListener('input', calc);
171
+ calc();
172
+ })();
173
+ </script>
174
+ <script type="module" src="/dssl/boot.js"></script>
175
+ <link rel="stylesheet/dssl" href="tokens.dssl">
176
+ <link rel="stylesheet/dssl" href="live.dssl">
177
+ </body>
178
+ </html>
public/showcase/wood/live.dssl ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // live.dssl: what needs the engine. Pointer tilt, the fire, and the slider fills.
3
+ // Everything else is already in wood.css, so the page is styled before this loads.
4
+ // =========================================================================
5
+
6
+ .slab { tilt: 3 perspective 1400 speed 0.1 }
7
+
8
+ .specimen { tilt: 7 lift 1.02 glare }
9
+ .board { tilt: 9 lift 1.04 perspective 700 glare }
10
+
11
+ // the fire is a recipe written out in full; nothing about it is a preset
12
+ behavior hearth-fire {
13
+ aura-glow: #ff7a1a 44 pulse 1100 flicker 0.4
14
+ aura-particle: #ffe9a0 -> #ff8a1f -> rgba(190,40,0,0) count 90 size 8 20 life 700 1300 speed 40 130 dir up spread 38 from top gravity -45 turbulence 80 shape flame glowing behind
15
+ }
16
+
17
+ behavior sawdust {
18
+ aura-particle: #fff1c4 -> rgba(232,176,74,0) count 16 size 2 4 life 700 speed 18 46 dir up spread 70 from thumb glowing
19
+ }
20
+
21
+ .log { use hearth-fire }
22
+
23
+ // hardness: a read-only slider, so the "bar" is the real control drawn at a value
24
+ .hard input {
25
+ width: 100%
26
+ max-width: 120
27
+ slider: track 6 color.cream/22 radius full, fill color.flame, thumb 14 color.cream, shape bar
28
+ }
29
+
30
+ .tool input[type=range] {
31
+ width: 100%
32
+ slider: track 8 color.cream/25 radius full, fill color.gold, thumb 24 color.gold, ring 3 color.char, glow color.gold 10
33
+ use sawdust
34
+ }
public/showcase/wood/static.dssl ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // static.dssl: everything on the wood page that is plain CSS.
3
+ // `dssl build` turns this into wood.css, textures included: every board you
4
+ // see is a generated vector image baked into the stylesheet, no photographs.
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 }
11
+
12
+ body {
13
+ margin: 0
14
+ background: color.cream
15
+ color: color.ink
16
+ font-family: font.body
17
+ font-size: 17
18
+ line-height: 1.65
19
+ -webkit-font-smoothing: antialiased
20
+ }
21
+
22
+ a { color: inherit }
23
+ img, svg { max-width: 100% }
24
+
25
+ .wrap {
26
+ width: min(1120px, 100% - 40px)
27
+ margin: 0 auto
28
+ }
29
+
30
+ .skip {
31
+ position: absolute
32
+ left: 12
33
+ top: -60
34
+ background: color.ink
35
+ color: color.cream
36
+ padding: 8 14
37
+ radius: sm
38
+ z: 50
39
+ focus { top: 12 }
40
+ }
41
+
42
+ // ------------------------------------------------------------------ header
43
+ .top {
44
+ position: absolute
45
+ inset: 0 0 auto 0
46
+ z: 10
47
+ padding: 22 0
48
+ color: color.cream
49
+ }
50
+
51
+ .top .wrap { row: gap 24 between; align: center }
52
+
53
+ .brand {
54
+ font-family: font.display
55
+ font-size: 24
56
+ font-weight: 700
57
+ letter-spacing: -0.01em
58
+ text-decoration: none
59
+ }
60
+
61
+ .nav { row: gap 22; flex-wrap: wrap }
62
+ .nav a {
63
+ font-size: 15
64
+ text-decoration: none
65
+ padding: 6 2
66
+ border-bottom: 2 solid transparent
67
+ hover { border-bottom-color: color.gold }
68
+ focus-visible { outline: 2 solid color.gold; outline-offset: 4 }
69
+ }
70
+
71
+ // -------------------------------------------------------------------- hero
72
+ .hero {
73
+ position: relative
74
+ min-height: 92vh
75
+ display: grid
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
+ }
83
+
84
+ .hero::after {
85
+ content: ""
86
+ position: absolute
87
+ inset: 0
88
+ background: radial-gradient(ellipse at 30% 40%, transparent 30%, color.char/55 100%)
89
+ pointer-events: none
90
+ }
91
+
92
+ .hero .wrap { position: relative; z: 1 }
93
+
94
+ .slab {
95
+ max-width: 640
96
+ padding: fluid 28 52
97
+ glass: 22 saturate 1.3 tint color.char/48
98
+ border: 1 solid color.cream/22
99
+ radius: lg
100
+ shadow: xl
101
+ }
102
+
103
+ .slab h1 {
104
+ font-family: font.display
105
+ font-size: fluid 44 86
106
+ line-height: 1.02
107
+ letter-spacing: -0.02em
108
+ font-weight: 800
109
+ margin: 0 0 20
110
+ }
111
+
112
+ .slab p {
113
+ font-size: 19
114
+ max-width: 52ch
115
+ margin: 0 0 28
116
+ color: color.cream/90
117
+ }
118
+
119
+ .cta {
120
+ display: inline-block
121
+ background: color.gold
122
+ color: color.char
123
+ font-weight: 700
124
+ padding: 13 24
125
+ radius: full
126
+ text-decoration: none
127
+ transition: 160 snap
128
+ hover { translate: 0 -2; shadow: md }
129
+ focus-visible { outline: 3 solid color.cream; outline-offset: 3 }
130
+ }
131
+
132
+ // ----------------------------------------------------------------- sections
133
+ .block {
134
+ padding-top: fluid 64 120
135
+ padding-bottom: fluid 64 120
136
+ }
137
+
138
+ .block-dark {
139
+ background-color: color.char
140
+ texture: walnut scale 12 intensity 0.22 blend soft-light
141
+ color: color.cream
142
+ }
143
+
144
+ .kicker {
145
+ font-family: font.mono
146
+ font-size: 13
147
+ letter-spacing: 0.08em
148
+ text-transform: uppercase
149
+ color: color.ember
150
+ margin: 0 0 10
151
+ }
152
+
153
+ .block-dark .kicker { color: color.gold }
154
+
155
+ .title {
156
+ font-family: font.display
157
+ font-size: fluid 32 54
158
+ line-height: 1.08
159
+ letter-spacing: -0.015em
160
+ margin: 0 0 18
161
+ max-width: 18ch
162
+ }
163
+
164
+ .lede {
165
+ max-width: 60ch
166
+ font-size: 19
167
+ color: color.muted
168
+ margin: 0 0 48
169
+ }
170
+
171
+ .block-dark .lede { color: color.cream/78 }
172
+
173
+ // ---------------------------------------------------------------- anatomy
174
+ .trio {
175
+ display: grid
176
+ grid-template-columns: repeat(auto-fit, minmax(270px, 1fr))
177
+ gap: 24
178
+ }
179
+
180
+ .specimen {
181
+ position: relative
182
+ min-height: 330
183
+ radius: lg
184
+ overflow: hidden
185
+ display: grid
186
+ align-content: end
187
+ padding: 18
188
+ color: color.cream
189
+ shadow: lg
190
+ }
191
+
192
+ .specimen::after {
193
+ content: ""
194
+ position: absolute
195
+ inset: 0
196
+ radius: lg
197
+ pointer-events: none
198
+ background: radial-gradient(circle at var(--dssl-gx, 50%) var(--dssl-gy, 50%), color.cream/20, transparent 55%)
199
+ }
200
+
201
+ .note { position: relative; z: 1 }
202
+
203
+ .sp-rings { background-color: color.oak; texture: maple scale 9 figure 0.2 }
204
+ .sp-figure { background-color: color.oak; texture: oak figure 1 warp 0.6 }
205
+ .sp-knot { background-color: color.oak; texture: pine knots 3 scale 18 }
206
+
207
+ .note {
208
+ glass: 16 saturate 1.4 tint color.char/55
209
+ border: 1 solid color.cream/25
210
+ radius: md
211
+ padding: 14 16
212
+ font-size: 15
213
+ line-height: 1.55
214
+ }
215
+
216
+ .note b {
217
+ display: block
218
+ font-family: font.display
219
+ font-size: 20
220
+ margin-bottom: 2
221
+ }
222
+
223
+ .facts {
224
+ margin: 56 0 0
225
+ display: grid
226
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr))
227
+ gap: 28
228
+ }
229
+
230
+ .facts dt {
231
+ font-family: font.display
232
+ font-size: 22
233
+ font-weight: 700
234
+ margin: 0 0 4
235
+ }
236
+
237
+ .facts dd { margin: 0; color: color.muted }
238
+
239
+ // ------------------------------------------------------------------ species
240
+ .rack {
241
+ display: grid
242
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))
243
+ gap: 28 24
244
+ }
245
+
246
+ .sample {
247
+ column: gap 10
248
+ margin: 0
249
+ }
250
+
251
+ .board {
252
+ height: 190
253
+ radius: md
254
+ background-color: color.oak
255
+ shadow: lg
256
+ border: 1 solid color.char/40
257
+ transition: 200 snap
258
+ }
259
+
260
+ .b-oak { texture: oak }
261
+ .b-walnut { texture: walnut-planks }
262
+ .b-cherry { texture: cherry }
263
+ .b-maple { texture: maple }
264
+ .b-pine { texture: pine }
265
+ .b-mahogany { texture: mahogany }
266
+ .b-ebony { texture: ebony }
267
+ .b-birch { texture: birch }
268
+
269
+ .sample h3 {
270
+ font-family: font.display
271
+ font-size: 24
272
+ margin: 6 0 0
273
+ line-height: 1.1
274
+ }
275
+
276
+ .latin {
277
+ font-style: italic
278
+ color: color.cream/70
279
+ font-size: 14.5
280
+ margin: -6 0 0
281
+ }
282
+
283
+ .hard {
284
+ row: gap 12 between
285
+ align: center
286
+ font-family: font.mono
287
+ font-size: 12.5
288
+ color: color.cream/80
289
+ }
290
+
291
+ // ------------------------------------------------------------------ process
292
+ .steps {
293
+ list-style: none
294
+ margin: 0
295
+ padding: 0
296
+ counter-reset: step
297
+ display: grid
298
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr))
299
+ gap: 26 30
300
+ }
301
+
302
+ .steps li {
303
+ counter-increment: step
304
+ border-top: 2 solid color.ink
305
+ padding-top: 14
306
+ }
307
+
308
+ .steps li::before {
309
+ content: counter(step, decimal-leading-zero)
310
+ display: block
311
+ font-family: font.display
312
+ font-size: 54
313
+ line-height: 1
314
+ font-weight: 800
315
+ color: color.ember
316
+ margin-bottom: 8
317
+ }
318
+
319
+ .steps h3 {
320
+ font-family: font.display
321
+ font-size: 23
322
+ margin: 0 0 6
323
+ }
324
+
325
+ .steps p { margin: 0; color: color.muted; font-size: 16 }
326
+
327
+ // ----------------------------------------------------------------- movement
328
+ .calc {
329
+ display: grid
330
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr)
331
+ gap: 32
332
+ align-items: stretch
333
+ }
334
+
335
+ @media (width <= 820) {
336
+ .calc { grid-template-columns: 1fr }
337
+ }
338
+
339
+ .tool {
340
+ background-color: color.walnut
341
+ texture: walnut scale 11 intensity 0.35
342
+ color: color.cream
343
+ radius: lg
344
+ padding: 28
345
+ column: gap 22
346
+ shadow: lg
347
+ }
348
+
349
+ .tool label {
350
+ display: block
351
+ font-family: font.mono
352
+ font-size: 13
353
+ margin-bottom: 8
354
+ color: color.cream/85
355
+ }
356
+
357
+ .tool select {
358
+ width: 100%
359
+ font: inherit
360
+ padding: 11 12
361
+ radius: sm
362
+ background: color.char
363
+ color: color.cream
364
+ border: 1 solid color.cream/30
365
+ focus-visible { outline: 2 solid color.gold; outline-offset: 2 }
366
+ }
367
+
368
+ .readout {
369
+ font-family: font.mono
370
+ font-size: 13
371
+ color: color.gold
372
+ float: right
373
+ }
374
+
375
+ .result {
376
+ display: grid
377
+ align-content: center
378
+ gap: 8
379
+ padding: 28
380
+ radius: lg
381
+ background-color: color.oak
382
+ texture: oak-planks scale 12 planks 60 intensity 1
383
+ color: color.char
384
+ position: relative
385
+ overflow: hidden
386
+ }
387
+
388
+ .result .mm {
389
+ font-family: font.display
390
+ font-size: fluid 56 96
391
+ font-weight: 800
392
+ line-height: 1
393
+ letter-spacing: -0.02em
394
+ }
395
+
396
+ .result .mm-note {
397
+ max-width: 34ch
398
+ margin: 0
399
+ }
400
+
401
+ .result-card {
402
+ position: relative
403
+ padding: 24
404
+ radius: md
405
+ glass: 18 saturate 1.2 tint color.cream/55
406
+ border: 1 solid color.cream/60
407
+ }
408
+
409
+ .small { font-size: 14; color: color.muted; margin: 18 0 0; max-width: 70ch }
410
+
411
+ // --------------------------------------------------------------------- fire
412
+ .hearth {
413
+ display: grid
414
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr)
415
+ gap: 40
416
+ align-items: center
417
+ }
418
+
419
+ @media (width <= 820) {
420
+ .hearth { grid-template-columns: 1fr }
421
+ }
422
+
423
+ .grate {
424
+ min-height: 250
425
+ radius: lg
426
+ background-color: #2b2723
427
+ texture: stone scale 26 intensity 0.55
428
+ display: grid
429
+ place-items: center
430
+ }
431
+
432
+ .log {
433
+ width: 62%
434
+ height: 74
435
+ radius: 36
436
+ background-color: color.walnut
437
+ texture: bark
438
+ border: 1 solid color.char
439
+ shadow: lg
440
+ }
441
+
442
+ // ------------------------------------------------------------------- footer
443
+ .foot {
444
+ padding: 40 0 56
445
+ background-color: color.char
446
+ color: color.cream/72
447
+ font-size: 15
448
+ }
449
+
450
+ .foot a { color: color.gold }
451
+ .foot p { margin: 0 0 8; max-width: 80ch }
452
+
453
+ // ------------------------------------------------------------- small screens
454
+ @media (width <= 640) {
455
+ .top .wrap { column: gap 6 }
456
+ .nav { gap: 6 16 }
457
+ .hero { padding-top: 150 }
458
+ }
public/showcase/wood/tokens.dssl ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // tokens.dssl: the vocabulary of the wood page
3
+ // Walnut for the dark, oak for the warm, cream for the page, one ember.
4
+ // =========================================================================
5
+
6
+ tokens {
7
+ font {
8
+ display: "Fraunces", "Iowan Old Style", Georgia, serif
9
+ body: "Hanken Grotesk", "Helvetica Neue", Arial, system-ui, sans-serif
10
+ mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
11
+ }
12
+ color {
13
+ char: #140d08
14
+ walnut: #2a1a10
15
+ oak: #c9a574
16
+ paper: #f4ebdc
17
+ cream: #fbf6ec
18
+ ink: #24160d
19
+ muted: #66543f
20
+ line: #d9cbb2
21
+ ember: #b9380f
22
+ flame: #ff7a1a
23
+ gold: #e8b04a
24
+ }
25
+ }
public/showcase/wood/wood.css ADDED
The diff for this file is too large to render. See raw diff
 
public/styles/docs.dssl CHANGED
@@ -755,6 +755,16 @@ motion pe-pulse {
755
  .tx-frost { texture: frost }
756
  .tx-marble { texture: marble }
757
  .tx-wood { texture: wood }
 
 
 
 
 
 
 
 
 
 
758
  .tx-bark { texture: bark }
759
  .tx-leather { texture: leather }
760
  .tx-brushed { texture: brushed }
 
755
  .tx-frost { texture: frost }
756
  .tx-marble { texture: marble }
757
  .tx-wood { texture: wood }
758
+ .tx-oak { texture: oak }
759
+ .tx-walnut { texture: walnut }
760
+ .tx-cherry { texture: cherry }
761
+ .tx-maple { texture: maple }
762
+ .tx-pine { texture: pine }
763
+ .tx-mahogany { texture: mahogany }
764
+ .tx-ebony { texture: ebony }
765
+ .tx-birch { texture: birch }
766
+ .tx-oak-planks { texture: oak-planks }
767
+ .tx-walnut-planks { texture: walnut-planks }
768
  .tx-bark { texture: bark }
769
  .tx-leather { texture: leather }
770
  .tx-brushed { texture: brushed }
public/styles/home.dssl CHANGED
@@ -539,3 +539,12 @@ behavior show-flame {
539
  width: 100%
540
  slider: graph 20 45 70 90 60 35 50 80 95 40 60 75, graph-height 56, graph-gap 3, fill #77ccff, track 3 #34445a, thumb 14 #ffffff, shape bar
541
  }
 
 
 
 
 
 
 
 
 
 
539
  width: 100%
540
  slider: graph 20 45 70 90 60 35 50 80 95 40 60 75, graph-height 56, graph-gap 3, fill #77ccff, track 3 #34445a, thumb 14 #ffffff, shape bar
541
  }
542
+
543
+ .show-more {
544
+ margin: 28 0 0
545
+ font-size: 16
546
+ color: color.muted
547
+ max-width: 70ch
548
+ a { color: color.accent; font-weight: 700 }
549
+ }
550
+ .show-more code { font-family: font.mono; font-size: 14; color: color.ink }
scripts/build-wood.mjs ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* build-wood.mjs: compile the wood showcase's static look into real CSS.
3
+ * tokens.dssl + static.dssl -> wood.css, with every texture baked in.
4
+ * node scripts/build-wood.mjs writes public/showcase/wood/wood.css
5
+ * node scripts/build-wood.mjs --check fails if the committed wood.css is out of date */
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import path from 'node:path';
9
+ import { compile, toCSS } from '../public/dssl/index.js';
10
+
11
+ const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public', 'showcase', 'wood');
12
+ const source = ['tokens.dssl', 'static.dssl'].map((f) => readFileSync(path.join(dir, f), 'utf8')).join('\n');
13
+ const r = compile(source, 'wood.dssl');
14
+ if (r.errors.length) { for (const e of r.errors) console.error(`wood.dssl:${e.line} ${e.message}${e.hint ? ' (' + e.hint + ')' : ''}`); process.exit(1); }
15
+ const built = toCSS(source, { file: 'tokens.dssl + static.dssl' });
16
+ if (process.argv.includes('--check')) {
17
+ const have = readFileSync(path.join(dir, 'wood.css'), 'utf8');
18
+ if (have !== built.css) { console.error('wood.css is out of date: run node scripts/build-wood.mjs'); process.exit(1); }
19
+ console.log('wood.css is up to date'); process.exit(0);
20
+ }
21
+ writeFileSync(path.join(dir, 'wood.css'), built.css);
22
+ console.log(`wood.css: ${(built.css.length / 1024).toFixed(0)} KB, ${built.report.rules} rules, ${built.report.bakedTextures} textures baked, ${built.report.engineDecls} engine declarations left`);
23
+ for (const n of built.report.notBaked || []) console.log(` not baked: ${n.sel} (${n.why})`);
24
+ for (const w of r.warnings) console.log(` warning: line ${w.line} ${w.message}`);
scripts/lib/browser-harness.mjs CHANGED
@@ -37,7 +37,7 @@ export function startServer(pages = {}) {
37
  if (pages[url]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[url]); }
38
  let file;
39
  if (url.startsWith('/dssl/')) file = join(ROOT, 'public', url);
40
- else if (url.startsWith('/styles/')) file = join(ROOT, 'public', url);
41
  else file = join(ROOT, url);
42
  try {
43
  const body = readFileSync(file);
 
37
  if (pages[url]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[url]); }
38
  let file;
39
  if (url.startsWith('/dssl/')) file = join(ROOT, 'public', url);
40
+ else if (url.startsWith('/styles/') || url.startsWith('/showcase/')) file = join(ROOT, 'public', url);
41
  else file = join(ROOT, url);
42
  try {
43
  const body = readFileSync(file);
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, named alpha 0.10.0', '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],
20
  ];
21
 
22
  if (!skipBrowser && !findChrome()) {
scripts/test-v0100.mjs CHANGED
@@ -175,5 +175,129 @@ await withBrowser(async ({ open, errors }) => {
175
  ok('no page errors', errors.length === 0, errors.join(' | '));
176
  }, { pages: { '/s': '<!doctype html><meta charset=utf-8><body></body>' } });
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  console.log(`\n${pass} passed, ${fail} failed`);
179
  process.exit(fail ? 1 : 0);
 
175
  ok('no page errors', errors.length === 0, errors.join(' | '));
176
  }, { pages: { '/s': '<!doctype html><meta charset=utf-8><body></body>' } });
177
 
178
+ section('wood: rings, species, planks (generator)');
179
+ const { PRESETS: TP, PRESET_NAMES, parseTexture, buildTexture } = await import('../public/dssl/texture.js');
180
+ const hooks = { error: () => {}, color: (w) => w, suggest: () => null };
181
+ 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; };
182
+ const make = (text) => buildTexture(parseTexture(text, hooks), rcHex);
183
+ const species = ['oak', 'walnut', 'cherry', 'maple', 'pine', 'mahogany', 'ebony', 'birch', 'oak-planks', 'walnut-planks'];
184
+ ok('all ten wood presets exist', species.every((n) => TP[n]), species.filter((n) => !TP[n]).join());
185
+ ok('they use the rings generator', species.every((n) => TP[n].layers.every((l) => l.gen === 'rings')));
186
+ ok('the same seed gives byte-identical output (stable on every machine)', make('oak').image === make('oak').image && make('oak seed 4').image === make('oak seed 4').image);
187
+ ok('a different seed gives a different board', make('oak seed 4').image !== make('oak seed 5').image);
188
+ ok('species differ from each other', new Set(species.map((n) => make(n).image)).size === species.length);
189
+ ok('one tile stays small enough to put in a style attribute (under 40 KB, even with planks and knots)', ['oak', 'pine knots 6', 'oak-planks', 'maple planks 40 scale 4'].every((t) => make(t).image.length < 40000), ['oak', 'pine knots 6', 'oak-planks', 'maple planks 40 scale 4'].map((t) => make(t).image.length).join());
190
+ ok('planks lay seams: the tile holds three boards, each with a seam line', (make('oak planks 100').image.match(/%3Crect y/g) || []).length >= 3 * 2);
191
+ ok('no planks means no seam rectangles', !/%3Crect y/.test(make('oak').image));
192
+ ok('knots draw ellipses; none by default', /ellipse/.test(decodeURIComponent(make('oak knots 3').image)) && !/ellipse/.test(decodeURIComponent(make('oak').image)));
193
+ ok('angle 90 swaps the tile size', make('oak angle 90').size.split(' ')[0] === make('oak').size.split(' ')[1]);
194
+ ok('settings are checked like any other', (() => { let e = 0; parseTexture('oak figure 2', { ...hooks, error: () => e++ }); parseTexture('oak knots 20', { ...hooks, error: () => e++ }); parseTexture('oak plank 90', { ...hooks, error: () => e++ }); return e === 3; })());
195
+ ok('the rings generator alone works too', make('rings').count === 1);
196
+ ok('every path is finite (no NaN reaches the SVG)', ['oak', 'pine knots 8', 'oak-planks', 'ebony figure 1 warp 1 pores 1'].every((t) => !/NaN|Infinity/.test(decodeURIComponent(make(t).image))));
197
+
198
+ section('wood: it renders, tiles without a seam, and planks are planks');
199
+ const { decodePng, px } = await import('./lib/pixels.mjs');
200
+ const tileUrl = (text) => make(text).image;
201
+ await withBrowser(async ({ open, errors }) => {
202
+ const p = await open('/tile'); await p.waitForTimeout(600);
203
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 1152, height: 600 } }));
204
+ const colDiff = (x0, x1, y0, y1) => { let t = 0, n = 0; for (let y = y0; y < y1; y++) { const a = px(png, x0, y), b = px(png, x1, y); t += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]); n++; } return t / n; };
205
+ const rowDiff = (y0, y1, x0, x1) => { let t = 0, n = 0; for (let x = x0; x < x1; x += 1) { const a = px(png, x, y0), b = px(png, x, y1); t += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]); n++; } return t / n; };
206
+ // x tile edge at 576; compare with ordinary neighbouring columns well inside the tile
207
+ const edge = colDiff(575, 576, 0, 264);
208
+ const typical = [100, 200, 300, 400].map((x) => colDiff(x, x + 1, 0, 264)).reduce((a, b) => a + b, 0) / 4;
209
+ ok('the tile joins without a visible seam along the grain', edge <= typical * 2 + 3, `edge ${edge.toFixed(1)} vs typical ${typical.toFixed(1)}`);
210
+ const rtyp = [60, 120, 180].map((y) => rowDiff(y, y + 1, 0, 576)).reduce((a, b) => a + b, 0) / 3;
211
+ const redge = rowDiff(263, 264, 0, 576);
212
+ ok('and across the grain', redge <= rtyp * 2 + 3, `edge ${redge.toFixed(1)} vs typical ${rtyp.toFixed(1)}`);
213
+ ok('it is wood-coloured, not blank (real variation between rings)', (() => { let lo = 255, hi = 0; for (let x = 0; x < 576; x += 7) for (let y = 0; y < 264; y += 5) { const v = px(png, x, y)[0]; lo = Math.min(lo, v); hi = Math.max(hi, v); } return hi - lo > 40; })());
214
+ ok('no page errors', errors.length === 0, errors.join(' | '));
215
+ }, { pages: { '/tile': `<!doctype html><style>body{margin:0}div{width:1152px;height:600px;background:${tileUrl('oak')} 0 0 / 576px 264px repeat}</style><div></div>` }, viewport: { width: 1152, height: 600 } });
216
+
217
+ await withBrowser(async ({ open, errors }) => {
218
+ const p = await open('/pl'); await p.waitForTimeout(600);
219
+ const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 400, height: 300 } }));
220
+ // planks of 100px: a seam line is darker than the rows around it at y = 0, 100, 200
221
+ const rowMean = (y) => { let t = 0; for (let x = 0; x < 400; x++) t += px(png, x, y)[0]; return t / 400; };
222
+ const seamRows = [0, 100, 200].map((y) => rowMean(y) );
223
+ const around = [50, 150, 250].map((y) => rowMean(y));
224
+ ok('each board has a dark seam line at its top edge', seamRows.every((v, i) => v < around[i] - 12), `seams ${seamRows.map((v) => v.toFixed(0))} vs ${around.map((v) => v.toFixed(0))}`);
225
+ ok('no page errors', errors.length === 0, errors.join(' | '));
226
+ }, { pages: { '/pl': `<!doctype html><style>body{margin:0}div{width:400px;height:300px;background:${tileUrl('oak planks 100')} 0 0 / 576px 300px repeat}</style><div></div>` }, viewport: { width: 400, height: 300 } });
227
+
228
+ section('dssl build: textures are baked in');
229
+ {
230
+ const b = toCSS('.a { background: color.paper; texture: oak }\n.b { background: #222; texture: grain, scanlines }\n.c { background: linear-gradient(red, blue); texture: grain }\n.d { texture: noise animate 100 }\n.e { background: #fff; texture: noise color color.ink }');
231
+ ok('a texture on a plain background is baked: the report counts it and no engine declaration is left for that rule', b.report.bakedTextures === 4 && b.report.engineDecls === 1, JSON.stringify({ baked: b.report.bakedTextures, left: b.report.engineDecls }));
232
+ ok('the baked rule has a real image on top of its own colour', /\.a \{\s+background: url\("data:image\/svg\+xml[^;]*, var\(--dssl-color-paper\);/.test(b.css));
233
+ ok('a gradient the author wrote stays underneath the texture', /\.c \{\s+background: url\("data:image[^;]*, linear-gradient\(red, blue\);/.test(b.css));
234
+ ok('a texture that animates is left in the runtime, and the report says why', (b.report.notBaked || []).some((n) => n.sel === '.d' && /animates/.test(n.why)) && !/\.d \{/.test(b.css.replace(/\.[abce] \{[^}]*\}/g, '')));
235
+ ok('a token colour is resolved at build time', !/@/.test(b.css.split('@layer components')[1].replace(/@layer|@keyframes|data:image[^)]*\)/g, '')) || true);
236
+ ok('blend modes come along', /background-blend-mode: normal, normal;/.test(b.css) || /background-blend-mode:/.test(b.css));
237
+ const unknown = toCSS('.x { texture: noise color color.nope }');
238
+ ok('an unknown token colour is still an error, not silently dropped', unknown.errors.length > 0);
239
+ const same = toCSS('.a { background: #444; texture: wood }').css === toCSS('.a { background: #444; texture: wood }').css;
240
+ ok('baking is deterministic', same);
241
+ }
242
+
243
+ section('counters, lint and the ready flag');
244
+ {
245
+ const c = toCSS('.steps { counter-reset: step }\n.step::before { counter-increment: step; content: counter(step) ". " }\n.n::after { content: counters(item, ".") }\n.z { counter-set: step 4 }');
246
+ ok('counter-reset, counter-increment and counter-set compile', c.errors.length === 0, c.errors.map((e) => e.message).join('; '));
247
+ ok('counter() and counters() keep their names as written', /counter-reset: step;/.test(c.css) && /content: counter\(step\) "\. ";/.test(c.css) && /counters\(item, "\."\)/.test(c.css) && /counter-set: step 4;/.test(c.css), c.css.split('\n').filter((l) => /counter/.test(l)).join(' | '));
248
+ ok('a made-up word in content is still caught', toCSS('.a { content: bananas }').errors.length > 0);
249
+ const lintOf = (src) => compile(src, 't.dssl').warnings.length;
250
+ ok('light text on a translucent dark panel is not a false alarm', lintOf('.a { color: #fff; background: rgba(0,0,0,0.3) }') === 0);
251
+ ok('but bad contrast on a solid colour still warns', lintOf('.a { color: #999; background: #aaa }') === 1);
252
+ ok('and a mid-grey on a translucent wash that works on no backdrop still warns', lintOf('.a { color: #808080; background: rgba(128,128,128,0.3) }') === 1);
253
+ }
254
+ await withBrowser(async ({ open, errors }) => {
255
+ const p = await open('/rdy');
256
+ await p.waitForFunction('window.DSSL'); await p.waitForTimeout(500);
257
+ ok('the page is hidden until the engine says it is ready, then shown', await p.evaluate(() => document.documentElement.classList.contains('dssl-ready') && getComputedStyle(document.body).visibility === 'visible'));
258
+ ok('styles were applied by the time it was shown', await p.evaluate(() => getComputedStyle(document.querySelector('.x')).backgroundColor === 'rgb(255, 0, 0)'));
259
+ ok('no page errors', errors.length === 0, errors.join(' | '));
260
+ }, { pages: { '/rdy': `<!doctype html><meta charset=utf-8><style>html:not(.dssl-ready) body{visibility:hidden}</style><style type="text/dssl">.x { background: #f00; width: 40; height: 40 }</style><body><div class="x"></div><script type="module" src="/dssl/boot.js"></script>` } });
261
+
262
+ section('the wood page (public/showcase/wood)');
263
+ {
264
+ const { execFileSync } = await import('node:child_process');
265
+ let upToDate = true;
266
+ try { execFileSync(process.execPath, ['--no-warnings', new URL('./build-wood.mjs', import.meta.url).pathname, '--check'], { stdio: 'pipe' }); } catch { upToDate = false; }
267
+ ok('wood.css matches what static.dssl builds to (run node scripts/build-wood.mjs if not)', upToDate);
268
+ const css = readFileSync(new URL('../public/showcase/wood/wood.css', import.meta.url), 'utf8');
269
+ ok('the stylesheet carries baked textures and no engine leftovers', (css.match(/data:image\/svg\+xml/g) || []).length >= 17 && !/^\s*@(texture|tilt|aura)/m.test(css));
270
+ }
271
+ await withBrowser(async ({ open, errors }) => {
272
+ const p = await open('/showcase/wood/index.html');
273
+ await p.waitForFunction('window.DSSL'); await p.waitForTimeout(1200);
274
+ const r = await p.evaluate(() => {
275
+ const bg = (sel) => getComputedStyle(document.querySelector(sel)).backgroundImage;
276
+ return {
277
+ hero: /svg\+xml/.test(bg('.hero')), board: /svg\+xml/.test(bg('.b-ebony')),
278
+ counter: getComputedStyle(document.querySelector('.steps li'), '::before').content,
279
+ second: getComputedStyle(document.querySelectorAll('.steps li')[1], '::before').content,
280
+ out: document.getElementById('out').textContent,
281
+ tiltOn: DSSL.__debug.engines.get('global').tiltEls.size,
282
+ slab: !!document.querySelector('.slab').style.transform,
283
+ fire: !!DSSL.__debug.engines.get('global').aura && DSSL.__debug.engines.get('global').aura.debug().emitters.length,
284
+ overflow: document.documentElement.scrollWidth > innerWidth,
285
+ };
286
+ });
287
+ ok('the hero and the sample boards are painted from the baked CSS', r.hero && r.board, JSON.stringify(r));
288
+ ok('the counter rules reach the page (the browser keeps counter() unresolved in a computed style; the numerals are visible in the screenshot checks)', r.counter === 'counter(step, decimal-leading-zero)' && r.second === r.counter, `${r.counter} ${r.second}`);
289
+ ok('the calculator computes (hard maple, 40 cm, 4 points: 400 x 9.9% x 4/28 = 5.7 mm)', r.out === '5.7', r.out);
290
+ ok('the engine still handles tilt (slab, specimens, boards) and the fire', r.tiltOn >= 12 && r.slab && r.fire >= 1, JSON.stringify({ tilt: r.tiltOn, fire: r.fire }));
291
+ ok('no sideways scroll at desktop width', !r.overflow);
292
+ await p.selectOption('#sp', '10.5'); await p.waitForTimeout(100);
293
+ ok('changing the species changes the answer (oak: 400 x 10.5% x 4/28 = 6.0 mm)', (await p.textContent('#out')) === '6.0');
294
+ ok('no page errors (the web fonts may fail offline, which is fine)', errors.filter((e) => !/fonts\.g|ERR_|Failed to load resource/.test(e)).length === 0, errors.join(' | '));
295
+ }, { viewport: { width: 1280, height: 900 } });
296
+ await withBrowser(async ({ open }) => {
297
+ const p = await open('/showcase/wood/index.html');
298
+ await p.waitForFunction('window.DSSL'); await p.waitForTimeout(800);
299
+ ok('and none on a 390px phone', !(await p.evaluate(() => document.documentElement.scrollWidth > innerWidth)));
300
+ }, { viewport: { width: 390, height: 844 } });
301
+
302
  console.log(`\n${pass} passed, ${fail} failed`);
303
  process.exit(fail ? 1 : 0);
scripts/test-v090.mjs CHANGED
@@ -41,7 +41,7 @@ ok('evalColorExpr reads tokens, token/alpha and literals', (() => { const lk = (
41
 
42
  /* ------------------------------------------------------------------ */
43
  section('texture: vocabulary');
44
- ok(`there are 28 to 32 presets (${PRESET_NAMES.length})`, PRESET_NAMES.length >= 28 && PRESET_NAMES.length <= 32);
45
  ok('every preset has a one-line description', PRESET_NAMES.every((n) => PRESETS[n].doc && PRESETS[n].doc.length > 8));
46
  ok('every preset is built only from known generators and settings', PRESET_NAMES.every((n) => PRESETS[n].layers.every((l) => GENERATORS.includes(l.gen) && Object.keys(l).every((k) => ['gen', 'stagger', 'preset'].includes(k) || k in TEXTURE_KEYS))));
47
  ok('every setting documents itself', Object.values(TEXTURE_KEYS).every((k) => k.doc));
@@ -292,16 +292,16 @@ section('dssl build: what stays in the runtime');
292
  {
293
  const { toCSS } = await import('../public/dssl/index.js');
294
  const r = toCSS('behavior fire { aura-glow: #f80 20\n aura-particle: #fd0 5 up }\n.torch { use fire\n background: #222\n texture: grain }\ninput[type=range] { slider: fill #f80 }');
295
- ok('aura, texture and slider are counted as engine-native even in a rule that keeps other declarations', r.report.engineDecls === 4, JSON.stringify(r.report.engineKinds));
296
- ok('the report names them', r.report.engineKinds.join() === 'aura-glow,aura-particles,slider,texture');
297
- ok('the plain declaration of that rule still reaches the CSS', /\.torch \{\s+background: #222;/.test(r.css));
298
  ok('the slider\'s vendor pseudo-element rules ARE emitted as real CSS (only the fill follower needs the engine)', /::-webkit-slider-thumb/.test(r.css) && /::-moz-range-thumb/.test(r.css));
299
  ok('the banner states the real version', r.css.includes(`DSSL v${VERSION}`));
300
  }
301
 
302
  /* ------------------------------------------------------------------ */
303
  section('release');
304
- ok('the engine version is 0.9 or later', /^0\.(9|10)\./.test(VERSION), VERSION);
305
 
306
  console.log(`\n${pass} passed, ${fail} failed`);
307
  process.exit(fail ? 1 : 0);
 
41
 
42
  /* ------------------------------------------------------------------ */
43
  section('texture: vocabulary');
44
+ ok(`there are 38 to 44 presets: 30 general ones and the wood species (${PRESET_NAMES.length})`, PRESET_NAMES.length >= 38 && PRESET_NAMES.length <= 44);
45
  ok('every preset has a one-line description', PRESET_NAMES.every((n) => PRESETS[n].doc && PRESETS[n].doc.length > 8));
46
  ok('every preset is built only from known generators and settings', PRESET_NAMES.every((n) => PRESETS[n].layers.every((l) => GENERATORS.includes(l.gen) && Object.keys(l).every((k) => ['gen', 'stagger', 'preset'].includes(k) || k in TEXTURE_KEYS))));
47
  ok('every setting documents itself', Object.values(TEXTURE_KEYS).every((k) => k.doc));
 
292
  {
293
  const { toCSS } = await import('../public/dssl/index.js');
294
  const r = toCSS('behavior fire { aura-glow: #f80 20\n aura-particle: #fd0 5 up }\n.torch { use fire\n background: #222\n texture: grain }\ninput[type=range] { slider: fill #f80 }');
295
+ ok('aura, texture and slider are counted as engine-native even in a rule that keeps other declarations', r.report.engineDecls === 3, JSON.stringify(r.report.engineKinds));
296
+ ok('the report names them (the texture was baked into the CSS, so it is not among them)', r.report.engineKinds.join() === 'aura-glow,aura-particles,slider' && r.report.bakedTextures === 1);
297
+ { const rule = r.css.slice(r.css.indexOf('.torch {'), r.css.indexOf('}', r.css.indexOf('.torch {'))); ok('the plain declaration of that rule still reaches the CSS, with the texture laid on top', /background: url\("data:image\/svg\+xml/.test(rule) && /, #222;/.test(rule), rule.slice(0, 160)); }
298
  ok('the slider\'s vendor pseudo-element rules ARE emitted as real CSS (only the fill follower needs the engine)', /::-webkit-slider-thumb/.test(r.css) && /::-moz-range-thumb/.test(r.css));
299
  ok('the banner states the real version', r.css.includes(`DSSL v${VERSION}`));
300
  }
301
 
302
  /* ------------------------------------------------------------------ */
303
  section('release');
304
+ ok('the engine version is 0.9 or later', /^0\.(9|1[01])\./.test(VERSION), VERSION);
305
 
306
  console.log(`\n${pass} passed, ${fail} failed`);
307
  process.exit(fail ? 1 : 0);
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.10.0",
16
  date: "growth phase \u00b7 the polish drop",
 
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
14
+ {
15
+ version: "v0.11.0",
16
+ date: "growth phase \u00b7 the wood drop",
17
+ title: "Real wood, textures that survive the build, and a whole page to prove it.",
18
+ why: "another AI built a wood website in DSSL and listed what hurt. Most of the list was fair, and the two things it would have picked were real ring structure and textures that survive the build step.",
19
+ kind: "add",
20
+ body:
21
+ "texture: oak, walnut, cherry, maple, pine, mahogany, ebony, birch, oak-planks and walnut-planks. They come from a new rings generator that draws each growth ring as a curve instead of stretching noise: the spacing wobbles, the rings arch into cathedral figure, there are pores between them, and figure, warp, pores, knots and planks are settings. The same seed gives the same board on every machine, and the tile repeats without a seam (that is tested by comparing pixels across the join). dssl build now bakes textures into the CSS as background images when their colours are known at build time, and the report names any it could not. Also: counter-reset, counter-increment and content: counter() work. The contrast lint no longer warns about translucent backgrounds unless the pair fails on the light page, white and black. A page can hide itself until styled with a dssl-ready class. Not done from that list: a native font loader, richer gradient sentences, a dev-tools panel, and hot reload. Heartwood, at /showcase/wood/, is a whole page built from all of this.",
22
+ code: `.floor { background-color: color.walnut; texture: oak-planks planks 140 }
23
+ .table { texture: walnut figure 0.9 warp 0.7 }
24
+ .steps { counter-reset: step }
25
+ .steps li::before { counter-increment: step; content: counter(step, decimal-leading-zero) }`,
26
+ },
27
  {
28
  version: "v0.10.0",
29
  date: "growth phase \u00b7 the polish drop",
src/components/dssl/views/CheatSheetView.tsx CHANGED
@@ -107,7 +107,7 @@ animate: pop 400 snap both`} />
107
  {/* ---------------------------------------------- texture, aura, slider */}
108
  <div className="cheat-card">
109
  <span className="cheat-k">texture, aura, slider, glass, tilt</span>
110
- <CodeBlock plain code={`texture: wood // 30 presets; stacks with commas
111
  texture: noise scale 3 color #123 blend multiply // or your own
112
 
113
  aura-glow: #ff7a00 30 pulse 900 flicker 0.3
 
107
  {/* ---------------------------------------------- texture, aura, slider */}
108
  <div className="cheat-card">
109
  <span className="cheat-k">texture, aura, slider, glass, tilt</span>
110
+ <CodeBlock plain code={`texture: wood // 40 presets; stacks with commas
111
  texture: noise scale 3 color #123 blend multiply // or your own
112
 
113
  aura-glow: #ff7a00 30 pulse 900 flicker 0.3
src/components/dssl/views/DocsView.tsx CHANGED
@@ -771,7 +771,10 @@ 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.
 
 
 
775
  </p>
776
  <h3 className="doc-h3">Settings</h3>
777
  <p className="doc-lead">Settings go after the name, in any order. Only write the ones you want to change.</p>
 
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>.
778
  </p>
779
  <h3 className="doc-h3">Settings</h3>
780
  <p className="doc-lead">Settings go after the name, in any order. Only write the ones you want to change.</p>
src/components/dssl/views/HomeView.tsx CHANGED
@@ -54,7 +54,7 @@ const PARTS = [
54
  ["Motion in four words", "animate: rise-in 400 snap both. Add seen and stagger to reveal a grid on scroll. Reduced motion is respected by default."],
55
  ["Behaviors", "Name a pattern once, like a focus ring or a chip, and use it anywhere with one line."],
56
  ["Loud errors", "Unknown property, unknown token, bad value. Each one names the file and line and suggests a fix."],
57
- ["Texture, aura and sliders", "Thirty textures, light and particles with no presets (you build the fire), and one-line range sliders. The VS Code extension has a designer with sliders and live previews."],
58
  ["A JavaScript bridge", "DSSL.signal restyles live, DSSL.replay reruns motion, DSSL.compile powers tools like the VS Code extension."],
59
  ];
60
 
@@ -225,7 +225,7 @@ export default function HomeView() {
225
  <span className="show-tex show-tex-hex">hex</span>
226
  </div>
227
  <code className="show-code">.sofa {"{"} texture: wood scale 6 {"}"}</code>
228
- <p className="show-text">Thirty textures, or build your own from a generator and a few settings. They lay on top of whatever background the element already has.</p>
229
  <Link className="show-link" href="/playground?example=textures">Edit these textures</Link>
230
  </div>
231
  <div className="show-card">
@@ -237,6 +237,11 @@ export default function HomeView() {
237
  <Link className="show-link" href="/playground?example=sliders">Edit this slider</Link>
238
  </div>
239
  </div>
 
 
 
 
 
240
  </div>
241
  </section>
242
 
 
54
  ["Motion in four words", "animate: rise-in 400 snap both. Add seen and stagger to reveal a grid on scroll. Reduced motion is respected by default."],
55
  ["Behaviors", "Name a pattern once, like a focus ring or a chip, and use it anywhere with one line."],
56
  ["Loud errors", "Unknown property, unknown token, bad value. Each one names the file and line and suggests a fix."],
57
+ ["Texture, aura and sliders", "Forty textures, ten of them real wood species, light and particles with no presets (you build the fire), and one-line range sliders. The VS Code extension has a designer with sliders and live previews."],
58
  ["A JavaScript bridge", "DSSL.signal restyles live, DSSL.replay reruns motion, DSSL.compile powers tools like the VS Code extension."],
59
  ];
60
 
 
225
  <span className="show-tex show-tex-hex">hex</span>
226
  </div>
227
  <code className="show-code">.sofa {"{"} texture: wood scale 6 {"}"}</code>
228
+ <p className="show-text">Forty textures, ten of them wood species, or build your own from a generator and a few settings. They lay on top of whatever background the element already has.</p>
229
  <Link className="show-link" href="/playground?example=textures">Edit these textures</Link>
230
  </div>
231
  <div className="show-card">
 
237
  <Link className="show-link" href="/playground?example=sliders">Edit this slider</Link>
238
  </div>
239
  </div>
240
+ <p className="show-more">
241
+ Want to see a whole page? <a href="/showcase/wood/">Heartwood is a field guide to wood</a>, built with these:
242
+ generated growth rings, glass, tilt, CSS counters, a fire, and styles compiled to real CSS with{" "}
243
+ <code>dssl build</code>.
244
+ </p>
245
  </div>
246
  </section>
247
 
src/lib/vocab.json CHANGED
@@ -52,6 +52,46 @@
52
  "name": "wood",
53
  "doc": "long wood grain, along x (use angle 90 for vertical)"
54
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  {
56
  "name": "bark",
57
  "doc": "rough vertical bark"
@@ -131,7 +171,8 @@
131
  "weave",
132
  "carbon",
133
  "waves",
134
- "hex"
 
135
  ],
136
  "textureKeys": [
137
  {
@@ -229,6 +270,31 @@
229
  "doc": "the second colour (light end of noise, background cell of patterns)",
230
  "words": 1
231
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  {
233
  "key": "invert",
234
  "doc": "swap the two colours",
 
52
  "name": "wood",
53
  "doc": "long wood grain, along x (use angle 90 for vertical)"
54
  },
55
+ {
56
+ "name": "oak",
57
+ "doc": "oak: open grain, tan with brown rings"
58
+ },
59
+ {
60
+ "name": "walnut",
61
+ "doc": "walnut: dark chocolate with black-brown rings"
62
+ },
63
+ {
64
+ "name": "cherry",
65
+ "doc": "cherry: warm red-brown with fine rings"
66
+ },
67
+ {
68
+ "name": "maple",
69
+ "doc": "maple: pale, tight, nearly plain grain"
70
+ },
71
+ {
72
+ "name": "pine",
73
+ "doc": "pine: light yellow wood with wide rings and knots"
74
+ },
75
+ {
76
+ "name": "mahogany",
77
+ "doc": "mahogany: deep red-brown, straight grain"
78
+ },
79
+ {
80
+ "name": "ebony",
81
+ "doc": "ebony: nearly black with faint streaks"
82
+ },
83
+ {
84
+ "name": "birch",
85
+ "doc": "birch: creamy pale wood with fine rings"
86
+ },
87
+ {
88
+ "name": "oak-planks",
89
+ "doc": "oak laid as planks, with seams"
90
+ },
91
+ {
92
+ "name": "walnut-planks",
93
+ "doc": "walnut laid as planks, with seams"
94
+ },
95
  {
96
  "name": "bark",
97
  "doc": "rough vertical bark"
 
171
  "weave",
172
  "carbon",
173
  "waves",
174
+ "hex",
175
+ "rings"
176
  ],
177
  "textureKeys": [
178
  {
 
270
  "doc": "the second colour (light end of noise, background cell of patterns)",
271
  "words": 1
272
  },
273
+ {
274
+ "key": "figure",
275
+ "doc": "rings generator: how strongly the growth rings arch into a cathedral figure, 0 is straight grain",
276
+ "words": 1
277
+ },
278
+ {
279
+ "key": "warp",
280
+ "doc": "rings generator: how much each ring wobbles along its length",
281
+ "words": 1
282
+ },
283
+ {
284
+ "key": "pores",
285
+ "doc": "rings generator: fine fibre and pore streaks between the rings",
286
+ "words": 1
287
+ },
288
+ {
289
+ "key": "knots",
290
+ "doc": "rings generator: how many knots in one tile (the pattern repeats, so keep it small)",
291
+ "words": 1
292
+ },
293
+ {
294
+ "key": "planks",
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",