EHS-Studio Claude Sonnet 5.5 commited on
Commit
e92b981
·
1 Parent(s): 39309b1

VS Code extension 0.9.0: aura, texture and slider designers, CSS conversion

Browse files

Live webview designers that read and write DSSL, backed by pure modules and
tests against a fake vscode API and a real Chrome.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

dssl-vscode/PUBLISHING.md CHANGED
@@ -12,21 +12,20 @@ there, "DSSL" shows up in the tab, searchable, one-click install, updates includ
12
  One-time, on your side (about 2 minutes):
13
 
14
  1. Go to https://open-vsx.org and sign in with your GitHub account.
15
- 2. Top right: your avatar → **Settings** → **Access Tokens** → **Generate token**.
16
- 3. Give it any name, copy the value, and paste it in chat. It gets stored the
17
- same way as the HF and Vercel tokens (chmod 600, outside every tracked file,
18
- mirrored into `.git/backups/`).
19
 
20
- Then it is one command on my side:
21
 
22
  ```sh
23
- bash scripts/publish-openvsx.sh
24
  ```
25
 
26
- The script reads the token, creates the `ehs-studio` namespace (tolerates "already
27
- exists"), and publishes the newest `.vsix`. After that:
28
 
29
- - VSCodium: Extensions tab → search "DSSL" → Install.
30
  - Future updates: bump `version` in `dssl-vscode/package.json`, repackage with
31
  `npx @vscode/vsce package --no-dependencies`, rerun the script.
32
 
@@ -40,7 +39,7 @@ gatekeeper: it requires an Azure DevOps organization and a personal access token
40
  Suggested ID: `ehs-studio`.
41
  3. In Azure DevOps: User settings → **Personal access tokens** → New token with
42
  scope **Marketplace → Manage**.
43
- 4. Hand over the token like the others, then it is:
44
 
45
  ```sh
46
  cd dssl-vscode && npx @vscode/vsce publish --pat <token>
@@ -58,4 +57,4 @@ This can happen at any time; Open VSX first does not block it.
58
 
59
  - `node scripts/sync-extension-engine.mjs` after ANY engine change, so the
60
  bundled compiler matches the browser compiler.
61
- - Run the extension lib tests (36 assertions) before packaging.
 
12
  One-time, on your side (about 2 minutes):
13
 
14
  1. Go to https://open-vsx.org and sign in with your GitHub account.
15
+ 2. Open your profile, then **Settings**, then **Access Tokens**, and generate a token.
16
+ 3. Accept the Eclipse publisher agreement on the same page. Publishing fails without it.
17
+ 4. Put the token in a file that is not in the repo, for example `~/Dokumente/tokens/openvsx token`.
 
18
 
19
+ Then it is one command:
20
 
21
  ```sh
22
+ OPENVSX_TOKEN_FILE="$HOME/Dokumente/tokens/openvsx token" bash scripts/publish-openvsx.sh
23
  ```
24
 
25
+ (`OPENVSX_TOKEN` or a first argument work too.) The script creates the `ehs-studio` namespace
26
+ (tolerating "already exists") and publishes the newest `.vsix` in this folder. After that:
27
 
28
+ - VSCodium: Extensions tab, search "DSSL", Install.
29
  - Future updates: bump `version` in `dssl-vscode/package.json`, repackage with
30
  `npx @vscode/vsce package --no-dependencies`, rerun the script.
31
 
 
39
  Suggested ID: `ehs-studio`.
40
  3. In Azure DevOps: User settings → **Personal access tokens** → New token with
41
  scope **Marketplace → Manage**.
42
+ 4. Keep the token in a file outside the repo, then it is:
43
 
44
  ```sh
45
  cd dssl-vscode && npx @vscode/vsce publish --pat <token>
 
57
 
58
  - `node scripts/sync-extension-engine.mjs` after ANY engine change, so the
59
  bundled compiler matches the browser compiler.
60
+ - Run `npm run test:fast` and `node scripts/test-designer-host.mjs` before packaging. The second one loads the real `extension.js` against a fake editor API, which is the closest thing to opening it in VS Code. Do also open the packaged `.vsix` in VS Code once.
dssl-vscode/README.md CHANGED
@@ -8,9 +8,9 @@ Works in **VS Code** and **VSCodium** (same extension API; install the `.vsix` i
8
  via Extensions panel → ⋯ → *Install from VSIX*).
9
 
10
  Extension id: **`ehs-studio.dssl-vscode`**. An Open VSX listing (the registry VSCodium's
11
- Extensions tab searches) is staged: one Open VSX token from the user and
12
- `scripts/publish-openvsx.sh` puts it in the tab, searchable. See PUBLISHING.md for both
13
- that flow and the Microsoft Marketplace flow.
14
 
15
  ## Features
16
 
@@ -22,6 +22,23 @@ that flow and the Microsoft Marketplace flow.
22
  - **Hover docs** for the semantic vocabulary (`row:`, `bend:`, `stretch:`, `seen`, `pin`...),
23
  for tokens (resolved value), for workspace motions and behaviors.
24
  - **Syntax highlighting** via a TextMate grammar for `.dssl`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  ## What it does not do (yet)
27
 
@@ -37,8 +54,8 @@ that flow and the Microsoft Marketplace flow.
37
 
38
  ## How it works
39
 
40
- `lib/engine/` holds a synced copy of `public/dssl/{parser,resolver,utils}.js` from the
41
- main repo (keep them identical with `scripts/sync-extension-engine.mjs`). On change, every
42
  `.dssl` file in the workspace is parsed once, tokens fold in load order, and the real
43
  `resolve()` produces the diagnostics. Completions and hovers read the folded registry.
44
 
 
8
  via Extensions panel → ⋯ → *Install from VSIX*).
9
 
10
  Extension id: **`ehs-studio.dssl-vscode`**. An Open VSX listing (the registry VSCodium's
11
+ Extensions tab searches) is staged: it needs an Open VSX access token, then
12
+ `scripts/publish-openvsx.sh` puts it in the tab. See PUBLISHING.md for that flow and the
13
+ Microsoft Marketplace one.
14
 
15
  ## Features
16
 
 
22
  - **Hover docs** for the semantic vocabulary (`row:`, `bend:`, `stretch:`, `seen`, `pin`...),
23
  for tokens (resolved value), for workspace motions and behaviors.
24
  - **Syntax highlighting** via a TextMate grammar for `.dssl`.
25
+ - **Completions for the new vocabulary.** After `texture:` you get the 30 presets, the generators and
26
+ every setting; after `aura-glow:` only glow settings; after `aura-particle:` only particle settings
27
+ (shapes, where particles are born, directions); after `slider:` the clauses. Hovering a preset or a
28
+ setting explains it.
29
+ - **The designer** (*DSSL: Open the designer*, or the paint-can button on a `.dssl` tab). Three tabs:
30
+ - *Aura*: a slider or picker for every glow and particle setting, colour stops you can add and
31
+ remove, a live preview drawn by the real renderer, a graph of one particle's opacity and size over
32
+ its life, and a live graph of particles alive and frame time.
33
+ - *Texture*: all 30 presets and 10 generators as thumbnails, up to four stacked layers, every setting
34
+ as a slider, colour pickers.
35
+ - *Slider*: a live range input with the generated rules, and a bar editor for histogram sliders.
36
+
37
+ Each tab writes the DSSL for you and checks it with the real compiler as you go. **Insert at cursor**
38
+ puts it in your file (indented to match, replacing a selection if there is one). **Load from selection**
39
+ reads an existing `aura-glow:`, `texture:` or `slider:` declaration back into the controls.
40
+ - **DSSL: Convert a CSS file to DSSL** (right-click a `.css` file). Runs the importer, writes the `.dssl`
41
+ next to it, and lists in an output channel what it could not carry over.
42
 
43
  ## What it does not do (yet)
44
 
 
54
 
55
  ## How it works
56
 
57
+ `lib/engine/` holds a synced copy of the engine's modules (`parser`, `resolver`, `utils`, `color`, `texture`,
58
+ `aura`, `slider`, `lint`, `import`) from the main repo (keep them identical with `scripts/sync-extension-engine.mjs`). On change, every
59
  `.dssl` file in the workspace is parsed once, tokens fold in load order, and the real
60
  `resolve()` produces the diagnostics. Completions and hovers read the folded registry.
61
 
dssl-vscode/extension.js CHANGED
@@ -1,4 +1,4 @@
1
- /* extension.js — thin vscode wiring over lib/analysis.js.
2
  * The analysis layer (and the DSSL compiler it wraps) is ESM; the extension
3
  * host is happy with a dynamic import from CommonJS. Everything testable
4
  * lives in lib/, this file only talks to the editor. */
@@ -9,6 +9,9 @@ let mod = null; // lib/analysis.js module
9
  let analysis = null; // current workspace analysis result
10
  let diags = null; // DiagnosticCollection (created once)
11
  let debounceTimer = null;
 
 
 
12
 
13
  const KIND = {
14
  property: vscode.CompletionItemKind.Property,
@@ -90,6 +93,88 @@ function registerHover() {
90
  });
91
  }
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  async function activate(context) {
94
  mod = await import('./lib/analysis.js');
95
  diags = vscode.languages.createDiagnosticCollection('dssl');
@@ -103,7 +188,14 @@ async function activate(context) {
103
  vscode.workspace.onDidChangeWorkspaceFolders(() => scheduleAnalysis()),
104
  registerCompletion(),
105
  registerHover(),
 
 
 
 
 
 
106
  );
 
107
 
108
  // run once so diagnostics are present before the first keystroke
109
  await runAnalysis();
 
1
+ /* extension.js - thin vscode wiring over lib/analysis.js.
2
  * The analysis layer (and the DSSL compiler it wraps) is ESM; the extension
3
  * host is happy with a dynamic import from CommonJS. Everything testable
4
  * lives in lib/, this file only talks to the editor. */
 
9
  let analysis = null; // current workspace analysis result
10
  let diags = null; // DiagnosticCollection (created once)
11
  let debounceTimer = null;
12
+ let designerHost = null; // lib/designer-host.js
13
+ let designerPanel = null;
14
+ let lastDsslEditor = null; // the panel steals focus, so remember which .dssl editor to insert into
15
 
16
  const KIND = {
17
  property: vscode.CompletionItemKind.Property,
 
93
  });
94
  }
95
 
96
+ /* ---- the designer panel ---------------------------------------------- */
97
+
98
+ /** the .dssl editor to talk to: the last one the user was in, if it is still on screen */
99
+ function findDsslEditor() {
100
+ const visible = vscode.window.visibleTextEditors.filter((e) => e.document.languageId === 'dssl');
101
+ if (lastDsslEditor && visible.includes(lastDsslEditor)) return lastDsslEditor;
102
+ return visible[0] || null;
103
+ }
104
+
105
+ /** the editor, as designer-host.js wants it. New lines keep the indentation of the line you insert on. */
106
+ function editorBridge() {
107
+ return {
108
+ activeDsslEditor() {
109
+ const ed = findDsslEditor();
110
+ if (!ed) return null;
111
+ return {
112
+ async insert(text) {
113
+ const sel = ed.selection;
114
+ const indent = (ed.document.lineAt(sel.start.line).text.match(/^\s*/) || [''])[0];
115
+ const body = text.replace(/\n/g, '\n' + indent);
116
+ await ed.edit((b) => (sel.isEmpty ? b.insert(sel.active, body) : b.replace(sel, body)));
117
+ },
118
+ selectionText() { return ed.document.getText(ed.selection); },
119
+ };
120
+ },
121
+ copy: (text) => vscode.env.clipboard.writeText(text),
122
+ info: (m) => vscode.window.setStatusBarMessage('DSSL designer: ' + m, 4000),
123
+ post: (m) => designerPanel && designerPanel.webview.postMessage(m),
124
+ };
125
+ }
126
+
127
+ async function openDesigner(context, tab) {
128
+ if (!designerHost) designerHost = await import('./lib/designer-host.js');
129
+ if (designerPanel) {
130
+ designerPanel.reveal(undefined, true);
131
+ designerPanel.webview.postMessage({ type: 'tab', tab });
132
+ return;
133
+ }
134
+ const column = vscode.window.activeTextEditor ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active;
135
+ designerPanel = vscode.window.createWebviewPanel('dsslDesigner', 'DSSL designer', { viewColumn: column, preserveFocus: true }, {
136
+ enableScripts: true,
137
+ retainContextWhenHidden: true,
138
+ localResourceRoots: [context.extensionUri],
139
+ });
140
+ const uri = (rel) => designerPanel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, ...rel.split('/'))).toString();
141
+ designerPanel.webview.html = designerHost.designerHtml({ cspSource: designerPanel.webview.cspSource, uri, tab });
142
+ const bridge = editorBridge();
143
+ designerPanel.webview.onDidReceiveMessage((msg) => designerHost.handleMessage(msg, bridge).catch((e) => vscode.window.showErrorMessage('DSSL designer: ' + e.message)));
144
+ designerPanel.onDidDispose(() => { designerPanel = null; });
145
+ }
146
+
147
+ /* ---- CSS to DSSL -------------------------------------------------------- */
148
+
149
+ let importChannel = null;
150
+
151
+ async function convertCss(uri) {
152
+ const target = uri || (vscode.window.activeTextEditor && vscode.window.activeTextEditor.document.uri);
153
+ if (!target || !/\.css$/i.test(target.fsPath)) { vscode.window.showInformationMessage('DSSL: pick a .css file to convert (right-click it in the Explorer, or open it first).'); return; }
154
+ const { importCss } = await import('./lib/engine/import.js');
155
+ const text = Buffer.from(await vscode.workspace.fs.readFile(target)).toString('utf8');
156
+ const { dssl, report } = importCss(text);
157
+ const out = vscode.Uri.file(target.fsPath.replace(/\.css$/i, '.dssl'));
158
+ let exists = true;
159
+ try { await vscode.workspace.fs.stat(out); } catch { exists = false; }
160
+ if (exists) {
161
+ const pick = await vscode.window.showWarningMessage(`${out.fsPath.split(/[\\/]/).pop()} already exists.`, { modal: true }, 'Overwrite');
162
+ if (pick !== 'Overwrite') return;
163
+ }
164
+ await vscode.workspace.fs.writeFile(out, Buffer.from(dssl, 'utf8'));
165
+ if (!importChannel) importChannel = vscode.window.createOutputChannel('DSSL import');
166
+ importChannel.clear();
167
+ importChannel.appendLine(`Converted ${target.fsPath}`);
168
+ importChannel.appendLine(` ${report.rules} rule(s), ${report.declarations} declaration(s), ${report.converted} rewritten, ${report.pinned} moved to pin { }, ${report.motions} motion(s), ${report.raw} at-rule(s) kept in raw { }`);
169
+ for (const d of report.dropped) importChannel.appendLine(` dropped line ${d.line}: ${d.text}\n ${d.reason}`);
170
+ for (const n of report.notes) importChannel.appendLine(` note line ${n.line}: ${n.message}${n.hint ? '\n ' + n.hint : ''}`);
171
+ importChannel.appendLine(report.compile.ok ? ' The result compiles.' : ` The result still has ${report.compile.errors.length} error(s).`);
172
+ await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(out), { preview: false });
173
+ const bits = [`${report.converted} values rewritten`];
174
+ if (report.dropped.length) bits.push(`${report.dropped.length} line(s) dropped`);
175
+ vscode.window.showInformationMessage(`DSSL: converted to ${out.fsPath.split(/[\\/]/).pop()} (${bits.join(', ')}).`, 'Show the report').then((c) => { if (c) importChannel.show(true); });
176
+ }
177
+
178
  async function activate(context) {
179
  mod = await import('./lib/analysis.js');
180
  diags = vscode.languages.createDiagnosticCollection('dssl');
 
188
  vscode.workspace.onDidChangeWorkspaceFolders(() => scheduleAnalysis()),
189
  registerCompletion(),
190
  registerHover(),
191
+ vscode.window.onDidChangeActiveTextEditor((ed) => { if (ed && ed.document.languageId === 'dssl') lastDsslEditor = ed; }),
192
+ vscode.commands.registerCommand('dssl.openDesigner', () => openDesigner(context, 'aura')),
193
+ vscode.commands.registerCommand('dssl.openAuraDesigner', () => openDesigner(context, 'aura')),
194
+ vscode.commands.registerCommand('dssl.openTextureDesigner', () => openDesigner(context, 'texture')),
195
+ vscode.commands.registerCommand('dssl.openSliderDesigner', () => openDesigner(context, 'slider')),
196
+ vscode.commands.registerCommand('dssl.convertCss', (uri) => convertCss(uri)),
197
  );
198
+ if (vscode.window.activeTextEditor && vscode.window.activeTextEditor.document.languageId === 'dssl') lastDsslEditor = vscode.window.activeTextEditor;
199
 
200
  // run once so diagnostics are present before the first keystroke
201
  await runAnalysis();
dssl-vscode/lib/analysis.js CHANGED
@@ -1,4 +1,4 @@
1
- /* analysis.js — pure analysis over .dssl text. No vscode imports here; the
2
  * extension layer feeds it text and offsets, tests feed it strings.
3
  * The point of this module: diagnostics come from the REAL parser and
4
  * resolver (the same code the browser runs), and completions come from the
@@ -7,11 +7,52 @@
7
  import { parse, PSEUDO_ELEMENTS } from './engine/parser.js';
8
  import { resolve, DEFAULT_TOKENS, CSS_KEYWORDS } from './engine/resolver.js';
9
  import { SEMANTIC, CSS, STATES, AT_RULES, BLOCKS, PROPERTY_NAMES } from './props-docs.js';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  /* ---- workspace analysis ------------------------------------------------ */
12
 
13
  /* docs: Map<absolute path, text>. Fold order: tokens*.dssl first, then
14
- * alphabetical — the same convention the site uses (tokens first). */
15
  export function analyzeWorkspace(docs) {
16
  const paths = [...docs.keys()].sort((a, b) => {
17
  const ta = /tokens[^/]*\.dssl$/.test(a) ? 0 : 1;
@@ -174,6 +215,7 @@ export function completionsAt(analysis, path, text, offset) {
174
  items.push({ label: k, kind: 'keyword', detail: 'animate option', documentation: ANIMATE_EXTRAS[k] || 'animate option' });
175
  }
176
  }
 
177
  for (const k of CSS_KEYWORDS) {
178
  items.push({ label: k, kind: 'keyword', detail: 'keyword' });
179
  }
@@ -241,6 +283,8 @@ export function hoverAt(analysis, path, text, offset) {
241
  if (BLOCKS[clean]) return { contents: `**${clean}** \u2014 ${BLOCKS[clean]}` };
242
  if (AT_RULES[clean]) return { contents: `**${clean}** \u2014 ${AT_RULES[clean]}` };
243
  if (CSS[clean]) return { contents: `**${clean}** \u2014 ${CSS[clean]}` };
 
 
244
  if (analysis.motions.has(clean)) {
245
  return { contents: `**${clean}** _motion_ \u2014 keyframe animation. Run with \`animate: ${clean} 600 ease-out\`.` };
246
  }
 
1
+ /* analysis.js - pure analysis over .dssl text. No vscode imports here; the
2
  * extension layer feeds it text and offsets, tests feed it strings.
3
  * The point of this module: diagnostics come from the REAL parser and
4
  * resolver (the same code the browser runs), and completions come from the
 
7
  import { parse, PSEUDO_ELEMENTS } from './engine/parser.js';
8
  import { resolve, DEFAULT_TOKENS, CSS_KEYWORDS } from './engine/resolver.js';
9
  import { SEMANTIC, CSS, STATES, AT_RULES, BLOCKS, PROPERTY_NAMES } from './props-docs.js';
10
+ import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, BLEND_MODES } from './engine/texture.js';
11
+ import { GLOW_KEYS, PARTICLE_KEYS, SHAPES, FROM_WORDS, DIR_WORDS } from './engine/aura.js';
12
+ import { SLIDER_CLAUSES, THUMB_SHAPES } from './engine/slider.js';
13
+
14
+ /* ---- the 0.9.0 vocabulary: what to offer after `texture:`, `aura-glow:`, `slider:` ... ----- */
15
+ const AURA_PROP = /^(aura|aura-glow|aura-particles?|glow-particles?|aura-glow-particles?)$/;
16
+ function domainItems(prop) {
17
+ const items = [];
18
+ const add = (label, detail, documentation, kind = 'keyword') => items.push({ label, kind, detail, documentation });
19
+ if (prop === 'texture') {
20
+ for (const n of PRESET_NAMES) add(n, 'texture preset', PRESETS[n].doc, 'value');
21
+ for (const g of GENERATORS) add(g, 'texture generator', 'start from scratch with this generator, then add settings');
22
+ for (const [k, d] of Object.entries(TEXTURE_KEYS)) add(k, 'texture setting', d.doc);
23
+ for (const b of BLEND_MODES) add(b, 'blend mode', 'how the texture mixes with what is under it');
24
+ } else if (prop === 'slider') {
25
+ for (const [k, usage] of Object.entries(SLIDER_CLAUSES)) add(k, 'slider setting', usage);
26
+ for (const t of THUMB_SHAPES) add(t, 'thumb shape', 'shape ' + t);
27
+ } else if (AURA_PROP.test(prop)) {
28
+ const glowOnly = prop === 'aura-glow';
29
+ const partsOnly = /particle/.test(prop) && prop !== 'aura-glow-particle' && prop !== 'aura-glow-particles';
30
+ if (prop === 'aura') { add('glow', 'aura layer', 'a soft light around the element'); add('particles', 'aura layer', 'things that leave the element'); add('none', 'clears', 'remove the whole aura'); }
31
+ if (!partsOnly) for (const [k, d] of Object.entries(GLOW_KEYS)) add(k, 'glow setting', d.doc);
32
+ if (!glowOnly) for (const [k, d] of Object.entries(PARTICLE_KEYS)) add(k, 'particle setting', d.doc);
33
+ if (!glowOnly) {
34
+ for (const s of SHAPES) add(s, 'particle shape', 'shape ' + s);
35
+ for (const f of FROM_WORDS) add(f, 'where particles are born', 'from ' + f);
36
+ for (const d of Object.keys(DIR_WORDS)) add(d, 'direction', 'dir ' + d);
37
+ }
38
+ for (const n of PRESET_NAMES) add(`texture(${n})`, 'texture inside an aura', PRESETS[n].doc);
39
+ }
40
+ return items;
41
+ }
42
+ function domainHover(word) {
43
+ if (PRESETS[word]) return `**${word}** _texture preset_ \u2014 ${PRESETS[word].doc}. Use it as \`texture: ${word}\`.`;
44
+ if (GENERATORS.includes(word)) return `**${word}** _texture generator_ \u2014 start from scratch with it, then add settings.`;
45
+ if (TEXTURE_KEYS[word]) return `**${word}** _texture setting_ \u2014 ${TEXTURE_KEYS[word].doc}`;
46
+ if (GLOW_KEYS[word]) return `**${word}** _glow setting_ \u2014 ${GLOW_KEYS[word].doc}`;
47
+ if (PARTICLE_KEYS[word]) return `**${word}** _particle setting_ \u2014 ${PARTICLE_KEYS[word].doc}`;
48
+ if (SLIDER_CLAUSES[word]) return `**${word}** _slider setting_ \u2014 \`${SLIDER_CLAUSES[word]}\``;
49
+ return null;
50
+ }
51
 
52
  /* ---- workspace analysis ------------------------------------------------ */
53
 
54
  /* docs: Map<absolute path, text>. Fold order: tokens*.dssl first, then
55
+ * alphabetical - the same convention the site uses (tokens first). */
56
  export function analyzeWorkspace(docs) {
57
  const paths = [...docs.keys()].sort((a, b) => {
58
  const ta = /tokens[^/]*\.dssl$/.test(a) ? 0 : 1;
 
215
  items.push({ label: k, kind: 'keyword', detail: 'animate option', documentation: ANIMATE_EXTRAS[k] || 'animate option' });
216
  }
217
  }
218
+ items.push(...domainItems(prop));
219
  for (const k of CSS_KEYWORDS) {
220
  items.push({ label: k, kind: 'keyword', detail: 'keyword' });
221
  }
 
283
  if (BLOCKS[clean]) return { contents: `**${clean}** \u2014 ${BLOCKS[clean]}` };
284
  if (AT_RULES[clean]) return { contents: `**${clean}** \u2014 ${AT_RULES[clean]}` };
285
  if (CSS[clean]) return { contents: `**${clean}** \u2014 ${CSS[clean]}` };
286
+ const dh = domainHover(clean);
287
+ if (dh) return { contents: dh };
288
  if (analysis.motions.has(clean)) {
289
  return { contents: `**${clean}** _motion_ \u2014 keyframe animation. Run with \`animate: ${clean} 600 ease-out\`.` };
290
  }
dssl-vscode/lib/designer-host.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* designer-host.js: the editor-side half of the designer, with no vscode import so it can be tested.
2
+ * extension.js hands it the real vscode API; the tests hand it a fake one. */
3
+
4
+ const TABS = ['aura', 'texture', 'slider'];
5
+
6
+ /** the HTML shell for the panel. `uri('media/designer.js')` turns an extension-relative path into a webview URI. */
7
+ export function designerHtml({ cspSource, uri, tab = 'aura' }) {
8
+ const t = TABS.includes(tab) ? tab : 'aura';
9
+ // The panel needs: its own module scripts (script-src), data: images for textures, and
10
+ // inline <style> for the slider preview (style-src 'unsafe-inline'); nothing from the network.
11
+ const csp = `default-src 'none'; img-src ${cspSource} data:; style-src ${cspSource} 'unsafe-inline'; script-src ${cspSource}; font-src ${cspSource};`;
12
+ return `<!doctype html>
13
+ <html lang="en"><head><meta charset="utf-8">
14
+ <meta http-equiv="Content-Security-Policy" content="${csp}">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <title>DSSL designer</title>
17
+ <link rel="stylesheet" href="${uri('media/designer.css')}">
18
+ </head><body data-tab="${t}">
19
+ <div id="app"></div>
20
+ <script type="module" src="${uri('media/designer.js')}"></script>
21
+ </body></html>`;
22
+ }
23
+
24
+ /**
25
+ * Handle a message from the panel. `api` is the editor, however it is spelled:
26
+ * api.activeDsslEditor() -> { insert(text), replaceSelection(text), selectionText() } | null
27
+ * api.copy(text) -> Promise
28
+ * api.info(message) show a small notice
29
+ * api.post(message) send a message to the panel
30
+ */
31
+ export async function handleMessage(msg, api) {
32
+ if (!msg || typeof msg !== 'object') return;
33
+ if (msg.type === 'insert') {
34
+ const ed = api.activeDsslEditor();
35
+ if (!ed) { api.info('Open a .dssl file first, then press Insert. The text is still in the box: use Copy.'); return; }
36
+ if (typeof msg.text !== 'string' || !msg.text) return;
37
+ await ed.insert(msg.text);
38
+ api.info('Inserted.');
39
+ } else if (msg.type === 'copy') {
40
+ if (typeof msg.text === 'string') await api.copy(msg.text);
41
+ } else if (msg.type === 'requestSelection') {
42
+ const ed = api.activeDsslEditor();
43
+ if (!ed) { api.info('Open a .dssl file and select a declaration (for example aura-glow: ...) to load it.'); return; }
44
+ api.post({ type: 'selection', text: ed.selectionText() });
45
+ }
46
+ }
47
+
48
+ export const DESIGNER_TABS = TABS;
dssl-vscode/lib/engine/aura.js ADDED
@@ -0,0 +1,833 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: aura.js
3
+ *
4
+ * .orb { aura-glow: color.accent 28 pulse 1600 }
5
+ * .orb { aura-particle: #7cf -> transparent count 40 dir up from bottom }
6
+ * .orb { aura: glow #ff7a00 30 flicker 0.3, particles #fc4 -> #f30 count 60 shape flame glowing }
7
+ *
8
+ * An aura is light and particles around an element. It has NO presets: fire,
9
+ * an energy aura, snow and sparks are all just parameters, and you name your
10
+ * own with a `behavior` block. What you get is the set of knobs.
11
+ *
12
+ * Two kinds of layer:
13
+ * glow a soft light around the element (color, radius, pulse, flicker)
14
+ * particles things that leave the element (count, size, life, speed, dir ...)
15
+ *
16
+ * Properties (all of them end up in the same two slots, so they stack with the cascade):
17
+ * aura: glow and/or particles, comma separated. Replaces both slots.
18
+ * aura-glow: the glow slot
19
+ * aura-particle(s): the particles slot
20
+ * glow-particle(s): the particles slot, every particle glowing
21
+ * aura-glow-particle(s): both slots at once; settings are shared
22
+ *
23
+ * This file has two halves. The top is PURE (parseAura, no DOM) and runs in the
24
+ * compiler. The bottom is the canvas RUNTIME (AuraManager) that the engine drives.
25
+ * ========================================================================= */
26
+
27
+ import { parseColor, toRgba, withAlpha } from './color.js';
28
+ import { parseTexture, makeMask, splitWords, rng } from './texture.js';
29
+
30
+ /* ------------------------------------------------------------------ *
31
+ * vocabulary
32
+ * ------------------------------------------------------------------ */
33
+
34
+ export const FROM_WORDS = ['edge', 'bottom', 'top', 'left', 'right', 'center', 'corners', 'thumb'];
35
+ export const SHAPES = ['circle', 'spark', 'square', 'ring', 'star', 'flame', 'smoke', 'line'];
36
+ export const DIR_WORDS = { up: -90, down: 90, left: 180, right: 0, out: 'out', in: 'in' };
37
+ export const AURA_BLENDS = ['add', 'normal'];
38
+
39
+ const num = (min, max, extra = {}) => ({ kind: 'number', words: 1, min, max, ...extra });
40
+
41
+ export const GLOW_KEYS = {
42
+ color: { kind: 'color', words: 1, doc: 'the light colour (default: the element\'s text colour)' },
43
+ radius: num(1, 400, { doc: 'how far the glow reaches, in px' }),
44
+ intensity: num(0, 4, { doc: 'brightness, 1 is normal' }),
45
+ pulse: num(0, 60000, { doc: 'milliseconds for one breath in and out; 0 is steady' }),
46
+ depth: num(0, 1, { doc: 'how deep the pulse goes, 0 to 1' }),
47
+ flicker: num(0, 1, { doc: 'random flicker, like a flame: 0 is steady, 1 is wild' }),
48
+ seed: num(0, 99999, { int: true, doc: 'a different random flicker with the same look' }),
49
+ texture: { kind: 'texture', words: 1, doc: 'texture(grain scale 2): break the light up with a texture' },
50
+ essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
51
+ };
52
+
53
+ export const PARTICLE_KEYS = {
54
+ color: { kind: 'colors', words: 1, doc: 'colours over a particle\'s life: color #ff0 -> #f60 -> transparent' },
55
+ count: num(1, 600, { int: true, doc: 'how many particles are alive at once' }),
56
+ size: { kind: 'range', words: 2, min: 0.5, max: 300, doc: 'diameter in px: size 4, or size 10 4 (start and end of a random range)' },
57
+ life: { kind: 'range', words: 2, min: 50, max: 60000, doc: 'how long one lives, in ms: life 900, or life 600 1200' },
58
+ speed: { kind: 'range', words: 2, min: 0, max: 3000, doc: 'px per second: speed 60, or speed 30 90' },
59
+ dir: { kind: 'dir', words: 1, doc: 'where they head: up, down, left, right, out, in, or degrees (-90 is up)' },
60
+ spread: num(0, 360, { doc: 'how wide the fan of directions is, in degrees' }),
61
+ gravity: num(-2000, 2000, { doc: 'px/s squared; negative pulls up (flames), positive down (snow)' }),
62
+ drag: num(0, 10, { doc: 'air resistance: how fast particles slow down' }),
63
+ turbulence: num(0, 1000, { doc: 'random sideways wobble, px/s' }),
64
+ from: { kind: 'word', words: 1, values: FROM_WORDS, doc: 'where they are born: edge, bottom, top, left, right, center, corners, thumb (range sliders)' },
65
+ shape: { kind: 'word', words: 1, values: SHAPES, doc: 'circle, spark, square, ring, star, flame, smoke or line' },
66
+ grow: num(0, 10, { doc: 'size at the end of life as a multiple of the start: 0.2 shrinks, 3 swells' }),
67
+ blend: { kind: 'word', words: 1, values: AURA_BLENDS, doc: 'add makes overlapping particles brighter, normal just stacks them' },
68
+ glowing: { kind: 'flag', words: 0, doc: 'soft glowing particles that add light' },
69
+ behind: { kind: 'flag', words: 0, doc: 'draw only outside the element\'s box, as if the element hid them' },
70
+ seed: num(0, 99999, { int: true, doc: 'a different random pattern with the same look' }),
71
+ texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
72
+ essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
73
+ };
74
+
75
+ /* ------------------------------------------------------------------ *
76
+ * parsing (compile time, no DOM)
77
+ * ------------------------------------------------------------------ */
78
+
79
+ function splitCommas(s) {
80
+ const out = []; let depth = 0, cur = '';
81
+ for (const ch of s) {
82
+ if (ch === '(') depth++;
83
+ if (ch === ')') depth--;
84
+ if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
85
+ cur += ch;
86
+ }
87
+ if (cur.trim()) out.push(cur.trim());
88
+ return out;
89
+ }
90
+
91
+ const isNumeric = (w) => w !== '' && !Number.isNaN(Number(w));
92
+ /** could this word be a colour? A bare unknown word (speedd) must NOT be swallowed as one, or the typo
93
+ * never gets its "did you mean" */
94
+ const looksLikeColor = (w) => /^#[0-9a-f]{3,8}(\/[\d.]+%?)?$/i.test(w) || /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix|var)\(/i.test(w)
95
+ || /^[a-z][\w-]*(\.[\w-]+)+(\/[\d.]+%?)?$/i.test(w) || /^(transparent|currentcolor)$/i.test(w) || parseColor(w) !== null;
96
+
97
+ /**
98
+ * Parse one layer's words.
99
+ * kind: 'glow' | 'particles' | 'both' which keys are legal
100
+ * forceGlowing: glow-particle
101
+ * Returns { glow?, particles? } layers, or null (an error was reported).
102
+ */
103
+ function parseLayerWords(words, kind, hooks, opts = {}) {
104
+ const wantGlow = kind === 'glow' || kind === 'both';
105
+ const wantParticles = kind === 'particles' || kind === 'both';
106
+ const keys = kind === 'glow' ? GLOW_KEYS : kind === 'particles' ? PARTICLE_KEYS : { ...GLOW_KEYS, ...PARTICLE_KEYS };
107
+ const g = {}, p = {};
108
+ const setBoth = (k, v) => { if (k in GLOW_KEYS && wantGlow) g[k] = v; if (k in PARTICLE_KEYS && wantParticles) p[k] = v; };
109
+ const textureOf = (inner) => parseTexture(inner, hooks);
110
+
111
+ let i = 0;
112
+ const err = (m, h) => { hooks.error(m, h); return null; };
113
+
114
+ // positional shorthand: aura-glow: <color> <radius> aura-particle: <colors> <count>
115
+ const isKey = (w) => Object.prototype.hasOwnProperty.call(keys, w) || w in DIR_WORDS;
116
+ if (words.length && !isKey(words[0]) && words[0] !== '->') {
117
+ const stops = [];
118
+ while (i < words.length && !isKey(words[i]) && !isNumeric(words[i]) && (words[i] === '->' || looksLikeColor(words[i]))) {
119
+ if (words[i] === '->') { i++; continue; }
120
+ const css = hooks.color(words[i]);
121
+ if (css === null) return null;
122
+ stops.push(css);
123
+ i++;
124
+ }
125
+ if (stops.length) {
126
+ if (wantGlow) g.color = stops[0];
127
+ if (wantParticles) p.colors = stops;
128
+ }
129
+ if (i < words.length && isNumeric(words[i])) {
130
+ const n = Number(words[i]);
131
+ if (wantGlow && !wantParticles) g.radius = n;
132
+ else if (wantParticles && !wantGlow) p.count = n;
133
+ else { g.radius = n; }
134
+ i++;
135
+ }
136
+ }
137
+
138
+ while (i < words.length) {
139
+ const w = words[i];
140
+ if (w in DIR_WORDS && !(w in keys)) { if (wantParticles) p.dir = DIR_WORDS[w]; i++; continue; }
141
+ // texture(grain scale 2) is one function-style word that carries its own settings
142
+ const fnTex = /^texture\((.*)\)$/s.exec(w);
143
+ if (fnTex && keys.texture) {
144
+ i++;
145
+ const t = textureOf(fnTex[1].trim());
146
+ if (t === null) return null;
147
+ setBoth('texture', t);
148
+ continue;
149
+ }
150
+ const def = keys[w];
151
+ if (!def) {
152
+ const all = [...Object.keys(keys), ...Object.keys(DIR_WORDS)];
153
+ return err(`unknown aura setting "${w}"`, hooks.suggest(w, all) || `settings: ${Object.keys(keys).join(', ')}`);
154
+ }
155
+ i++;
156
+ if (def.kind === 'flag') {
157
+ if (w === 'glowing') { if (wantParticles) p.glowing = true; }
158
+ else setBoth(w, true);
159
+ continue;
160
+ }
161
+ if (def.kind === 'colors') {
162
+ const stops = [];
163
+ while (i < words.length && (words[i] === '->' || (!isKey(words[i]) && !isNumeric(words[i]) && looksLikeColor(words[i])))) {
164
+ if (words[i] === '->') { i++; continue; }
165
+ const css = hooks.color(words[i]);
166
+ if (css === null) return null;
167
+ stops.push(css);
168
+ i++;
169
+ }
170
+ if (!stops.length) return err('color needs at least one colour', def.doc);
171
+ if (wantGlow && 'color' in GLOW_KEYS) g.color = stops[0];
172
+ if (wantParticles) p.colors = stops;
173
+ continue;
174
+ }
175
+ if (def.kind === 'color') {
176
+ const css = hooks.color(words[i]);
177
+ if (css === null) return null;
178
+ i++;
179
+ setBoth(w, css);
180
+ if (w === 'color' && wantParticles) p.colors = [css];
181
+ continue;
182
+ }
183
+ if (def.kind === 'texture') {
184
+ const word = words[i];
185
+ i++;
186
+ const m = /^texture\((.*)\)$/s.exec(word || '');
187
+ const inner = m ? m[1] : word;
188
+ if (!inner) return err('texture needs a name', def.doc);
189
+ const t = textureOf(inner);
190
+ if (t === null) return null;
191
+ setBoth('texture', t);
192
+ continue;
193
+ }
194
+ if (def.kind === 'dir') {
195
+ const v = words[i]; i++;
196
+ if (v in DIR_WORDS) p.dir = DIR_WORDS[v];
197
+ else if (isNumeric(v)) p.dir = Number(v);
198
+ else return err(`unknown direction "${v}"`, hooks.suggest(v, Object.keys(DIR_WORDS)) || 'up, down, left, right, out, in, or degrees');
199
+ continue;
200
+ }
201
+ if (def.kind === 'word') {
202
+ const v = words[i]; i++;
203
+ if (!def.values.includes(v)) return err(`unknown ${w} "${v}"`, hooks.suggest(v, def.values) || `use one of: ${def.values.join(', ')}`);
204
+ if (wantParticles) p[w] = v;
205
+ continue;
206
+ }
207
+ if (def.kind === 'range') {
208
+ const vals = [];
209
+ while (i < words.length && vals.length < 2 && isNumeric(words[i])) { vals.push(Number(words[i])); i++; }
210
+ if (!vals.length) return err(`${w} needs a number`, def.doc);
211
+ if (vals.some((n) => n < def.min || n > def.max)) return err(`${w} must be between ${def.min} and ${def.max}`, def.doc);
212
+ if (wantParticles) p[w] = vals.length === 1 ? [vals[0], vals[0]] : [Math.min(...vals), Math.max(...vals)];
213
+ continue;
214
+ }
215
+ // plain number
216
+ const raw = words[i]; i++;
217
+ if (!isNumeric(raw)) return err(`${w} needs a number, got "${raw ?? ''}"`, def.doc);
218
+ const n = Number(raw);
219
+ if (n < def.min || n > def.max) return err(`${w} must be between ${def.min} and ${def.max}`, def.doc);
220
+ setBoth(w === 'turbulence' ? 'turbulence' : w, def.int ? Math.round(n) : n);
221
+ }
222
+
223
+ if (opts.forceGlowing && wantParticles) p.glowing = true;
224
+ const out = {};
225
+ if (wantGlow && (kind === 'glow' || Object.keys(g).length || opts.requireGlow)) out.glow = { kind: 'glow', ...g };
226
+ if (wantParticles && (kind === 'particles' || Object.keys(p).length || opts.requireParticles)) out.particles = { kind: 'particles', ...p };
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Parse the value of one of the aura properties. `prop` decides the shape:
232
+ * 'aura' layers need a leading glow / particles word
233
+ * 'aura-glow' one glow layer
234
+ * 'aura-particle' one particles layer
235
+ * 'glow-particle' one particles layer, glowing
236
+ * 'aura-glow-particle' a glow layer AND a glowing particles layer, settings shared
237
+ * Returns { glow: [layers]|null, particles: [layers]|null } where null means "clear this slot",
238
+ * or null when invalid.
239
+ */
240
+ export function parseAura(prop, value, hooks) {
241
+ const text = String(value || '').trim();
242
+ if (!text) { hooks.error(`${prop} needs a value`, 'e.g. aura-glow: color.accent 24'); return null; }
243
+ const base = prop.replace(/s$/, ''); // aura-particles -> aura-particle
244
+ if (text === 'none') {
245
+ // `none` clears exactly the slots this property owns, so aura-glow: none leaves particles alone
246
+ const clears = { 'aura': { glow: null, particles: null }, 'aura-glow': { glow: null }, 'aura-particle': { particles: null }, 'glow-particle': { particles: null }, 'aura-glow-particle': { glow: null, particles: null } };
247
+ return clears[base] || { glow: null, particles: null };
248
+ }
249
+
250
+ if (prop === 'aura') {
251
+ const glow = [], particles = [];
252
+ for (const part of splitCommas(text)) {
253
+ const words = splitWords(part);
254
+ const head = words[0];
255
+ let kind;
256
+ if (head === 'glow') kind = 'glow';
257
+ else if (head === 'particles' || head === 'particle') kind = 'particles';
258
+ else {
259
+ hooks.error(`an aura layer starts with glow or particles, not "${head}"`, hooks.suggest(head, ['glow', 'particles']) || 'e.g. aura: glow color.accent 24, particles #fc4 count 40');
260
+ return null;
261
+ }
262
+ const r = parseLayerWords(words.slice(1), kind, hooks);
263
+ if (!r) return null;
264
+ if (r.glow) glow.push(r.glow);
265
+ if (r.particles) particles.push(r.particles);
266
+ }
267
+ return { glow, particles };
268
+ }
269
+
270
+ const out = { glow: undefined, particles: undefined };
271
+ const layersOf = (kind, opts) => {
272
+ const arr = [];
273
+ for (const part of splitCommas(text)) {
274
+ const r = parseLayerWords(splitWords(part), kind, hooks, opts);
275
+ if (!r) return null;
276
+ if (r.glow) arr.push(r.glow);
277
+ if (r.particles) arr.push(r.particles);
278
+ }
279
+ return arr;
280
+ };
281
+
282
+ if (base === 'aura-glow') { const a = layersOf('glow'); if (!a) return null; out.glow = a; }
283
+ else if (base === 'aura-particle') { const a = layersOf('particles'); if (!a) return null; out.particles = a; }
284
+ else if (base === 'glow-particle') { const a = layersOf('particles', { forceGlowing: true }); if (!a) return null; out.particles = a; }
285
+ else if (base === 'aura-glow-particle') {
286
+ const glow = [], particles = [];
287
+ for (const part of splitCommas(text)) {
288
+ const r = parseLayerWords(splitWords(part), 'both', hooks, { forceGlowing: true, requireGlow: true, requireParticles: true });
289
+ if (!r) return null;
290
+ if (r.glow) glow.push(r.glow);
291
+ if (r.particles) particles.push(r.particles);
292
+ }
293
+ out.glow = glow; out.particles = particles;
294
+ } else { hooks.error(`unknown aura property "${prop}"`, null); return null; }
295
+ return out;
296
+ }
297
+
298
+ /* ------------------------------------------------------------------ *
299
+ * turning a parsed layer into numbers the runtime can use
300
+ * ------------------------------------------------------------------ */
301
+
302
+ export const GLOW_DEFAULTS = { radius: 24, intensity: 1, pulse: 0, depth: 0.35, flicker: 0, seed: 1 };
303
+ export const PARTICLE_DEFAULTS = {
304
+ count: 40, size: [4, 4], life: [900, 900], speed: [40, 40], dir: -90, spread: 30,
305
+ gravity: 0, drag: 0, turbulence: 0, from: 'edge', shape: 'circle', grow: 0.3, blend: null, glowing: false, seed: 1,
306
+ };
307
+
308
+ /**
309
+ * Resolve a parsed layer for drawing. `rc(cssColor)` returns {r,g,b,a} or null,
310
+ * `fallback` is the colour used when the layer names none (the element's text colour).
311
+ */
312
+ export function resolveLayer(layer, rc, fallback) {
313
+ const pick = (css) => (css === undefined || /^currentcolor$/i.test(css) ? fallback : (rc(css) || fallback));
314
+ if (layer.kind === 'glow') {
315
+ return { ...GLOW_DEFAULTS, ...layer, rgba: pick(layer.color), tex: layer.texture && layer.texture.length ? layer.texture : null };
316
+ }
317
+ const stops = (layer.colors && layer.colors.length ? layer.colors : [undefined]).map(pick);
318
+ if (stops.length === 1) stops.push(withAlpha(stops[0], 0)); // a single colour fades out
319
+ const glowing = !!layer.glowing;
320
+ return {
321
+ ...PARTICLE_DEFAULTS, ...layer, stops, glowing, behind: !!layer.behind,
322
+ blend: layer.blend || (glowing ? 'add' : 'normal'),
323
+ tex: layer.texture && layer.texture.length ? layer.texture : null,
324
+ };
325
+ }
326
+
327
+ /* ====================================================================== *
328
+ * RUNTIME. Everything below needs a browser.
329
+ * ====================================================================== */
330
+
331
+ const TAU = Math.PI * 2;
332
+ const lerp = (a, b, t) => a + (b - a) * t;
333
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
334
+
335
+ function roundRectPath(ctx, x, y, w, h, r) {
336
+ r = Math.max(0, Math.min(r, w / 2, h / 2));
337
+ ctx.moveTo(x + r, y);
338
+ ctx.arcTo(x + w, y, x + w, y + h, r);
339
+ ctx.arcTo(x + w, y + h, x, y + h, r);
340
+ ctx.arcTo(x, y + h, x, y, r);
341
+ ctx.arcTo(x, y, x + w, y, r);
342
+ ctx.closePath();
343
+ }
344
+
345
+ function colorAt(stops, t) {
346
+ const n = stops.length - 1;
347
+ if (n <= 0) return stops[0];
348
+ const f = clamp01(t) * n;
349
+ const i = Math.min(n - 1, Math.floor(f));
350
+ const k = f - i;
351
+ const a = stops[i], b = stops[i + 1];
352
+ return { r: lerp(a.r, b.r, k), g: lerp(a.g, b.g, k), b: lerp(a.b, b.b, k), a: lerp(a.a, b.a, k) };
353
+ }
354
+
355
+ /** A tiny signature, used to tell "same settings" from "changed" without deep compares */
356
+ const sigOf = (x) => JSON.stringify(x);
357
+
358
+ export class AuraManager {
359
+ /**
360
+ * opts.reducedMotion: a MediaQueryList (or anything with .matches)
361
+ * opts.budget: the most particles alive across the whole page
362
+ */
363
+ constructor(opts = {}) {
364
+ this.reducedMotion = opts.reducedMotion || null;
365
+ this.budget = opts.budget || 1800;
366
+ this.emitters = new Map(); // element -> emitter
367
+ this.canvas = null;
368
+ this.ctx = null;
369
+ this.dpr = 1;
370
+ this.vw = 0; this.vh = 0;
371
+ this.running = false;
372
+ this.paused = false;
373
+ this.last = 0;
374
+ this.sprites = new Map();
375
+ this.masks = new Map();
376
+ this.io = null;
377
+ this.stats = { particles: 0, emitters: 0, frameMs: 0, fps: 0, frames: 0 };
378
+ this._fpsT = 0; this._fpsN = 0;
379
+ this._onResize = () => this.resize();
380
+ // reduced motion draws a still picture, so it only needs a new frame when something could have moved
381
+ this._onScroll = () => { if (this.reduced && !this.running && !this.paused && this.emitters.size) this.start(); };
382
+ this._poll = null;
383
+ this._frame = (t) => this.frame(t);
384
+ }
385
+
386
+ get reduced() { return !!(this.reducedMotion && this.reducedMotion.matches); }
387
+
388
+ /* ---- setup ---- */
389
+
390
+ ensureCanvas() {
391
+ if (this.canvas) return;
392
+ const c = document.createElement('canvas');
393
+ c.setAttribute('aria-hidden', 'true');
394
+ c.setAttribute('data-dssl-aura', '');
395
+ c.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483000;';
396
+ (document.body || document.documentElement).appendChild(c);
397
+ this.canvas = c;
398
+ this.ctx = c.getContext('2d');
399
+ this.resize();
400
+ window.addEventListener('resize', this._onResize, { passive: true });
401
+ window.addEventListener('scroll', this._onScroll, { passive: true, capture: true });
402
+ if (typeof IntersectionObserver !== 'undefined') {
403
+ this.io = new IntersectionObserver((entries) => {
404
+ for (const e of entries) {
405
+ const em = this.emitters.get(e.target);
406
+ if (em) { em.visible = e.isIntersecting; if (e.isIntersecting) this.start(); }
407
+ }
408
+ }, { rootMargin: '200px' });
409
+ }
410
+ }
411
+
412
+ resize() {
413
+ if (!this.canvas) return;
414
+ this.dpr = Math.min(2, window.devicePixelRatio || 1);
415
+ this.vw = window.innerWidth; this.vh = window.innerHeight;
416
+ this.canvas.width = Math.max(1, Math.round(this.vw * this.dpr));
417
+ this.canvas.height = Math.max(1, Math.round(this.vh * this.dpr));
418
+ this.dirty = true;
419
+ }
420
+
421
+ /* ---- the engine's interface ---- */
422
+
423
+ /** Set (or change) the aura of an element. glow/particles are arrays of resolved layers. */
424
+ set(el, glow, particles) {
425
+ const hasGlow = glow && glow.length, hasParts = particles && particles.length;
426
+ if (!hasGlow && !hasParts) { this.remove(el); return; }
427
+ this.ensureCanvas();
428
+ let em = this.emitters.get(el);
429
+ if (!em) {
430
+ em = { el, visible: true, rect: null, radius: 0, glow: [], layers: [], dying: false, fade: 1, sig: '', age: 0, radiusAt: -1 };
431
+ this.emitters.set(el, em);
432
+ if (this.io) this.io.observe(el);
433
+ }
434
+ em.dying = false;
435
+ const sig = sigOf([glow, particles]);
436
+ if (sig === em.sig) { this.start(); return; }
437
+ em.sig = sig;
438
+ em.glow = (glow || []).map((g, i) => ({ cfg: g, rnd: rng((g.seed || 1) * 7919 + i), flick: 0, flickTarget: 0, t0: performance.now() }));
439
+ // keep running particles when the layer list keeps its shape; otherwise start fresh
440
+ const old = em.layers;
441
+ em.layers = (particles || []).map((cfg, i) => {
442
+ const prev = old[i];
443
+ const keep = prev && prev.parts;
444
+ return { cfg, rnd: keep ? prev.rnd : rng((cfg.seed || 1) * 104729 + i), parts: keep ? prev.parts : [], acc: keep ? prev.acc : 0 };
445
+ });
446
+ this.start();
447
+ }
448
+
449
+ remove(el) {
450
+ const em = this.emitters.get(el);
451
+ if (!em) return;
452
+ em.dying = true; // stop emitting, let what is alive finish, fade the glow
453
+ this.start();
454
+ }
455
+
456
+ pause() { this.paused = true; this.running = false; if (this._poll) { clearTimeout(this._poll); this._poll = null; } if (this.ctx) this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); }
457
+ resume() { this.paused = false; this.start(); }
458
+
459
+ destroy() {
460
+ this.running = false;
461
+ window.removeEventListener('resize', this._onResize);
462
+ window.removeEventListener('scroll', this._onScroll, { capture: true });
463
+ if (this._poll) { clearTimeout(this._poll); this._poll = null; }
464
+ if (this.io) this.io.disconnect();
465
+ if (this.canvas) this.canvas.remove();
466
+ this.canvas = null; this.ctx = null; this.emitters.clear();
467
+ }
468
+
469
+ /* ---- loop ---- */
470
+
471
+ start() {
472
+ if (this.running || this.paused || !this.canvas) return;
473
+ this.running = true;
474
+ this.last = 0;
475
+ requestAnimationFrame(this._frame);
476
+ }
477
+
478
+ frame(now) {
479
+ if (!this.running) return;
480
+ const t0 = performance.now();
481
+ const dt = this.last ? Math.min(0.05, (now - this.last) / 1000) : 0.016;
482
+ this.last = now;
483
+ const reduced = this.reduced;
484
+ const ctx = this.ctx, dpr = this.dpr;
485
+
486
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
487
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
488
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
489
+
490
+ // global budget: if everyone asks for more than the page can afford, everyone gets a share
491
+ let want = 0;
492
+ for (const em of this.emitters.values()) if (em.visible && !em.dying) for (const l of em.layers) want += l.cfg.count;
493
+ const share = want > this.budget ? this.budget / want : 1;
494
+
495
+ let alive = 0, anyActive = false;
496
+ for (const [el, em] of this.emitters) {
497
+ if (!el.isConnected) { this.emitters.delete(el); if (this.io) this.io.unobserve(el); continue; }
498
+ // read geometry once per frame for visible emitters
499
+ let rect = null;
500
+ if (em.visible || em.layers.some((l) => l.parts.length)) {
501
+ rect = el.getBoundingClientRect();
502
+ if (rect.width === 0 && rect.height === 0) rect = null;
503
+ }
504
+ em.rect = rect;
505
+ if (em.radiusAt < 0 || (em.age++ % 45) === 0) { em.radius = this.cornerRadius(el, rect); em.radiusAt = 0; }
506
+
507
+ if (em.dying) em.fade = Math.max(0, em.fade - dt * 3);
508
+ else em.fade = Math.min(1, em.fade + dt * 4);
509
+
510
+ // glow
511
+ if (rect && em.fade > 0 && (em.visible || em.dying)) {
512
+ for (const g of em.glow) { this.drawGlow(ctx, rect, em, g, now, reduced); }
513
+ if (em.glow.length) anyActive = true;
514
+ }
515
+
516
+ // particles
517
+ for (const L of em.layers) {
518
+ const cfg = L.cfg;
519
+ const moving = !reduced || cfg.essential;
520
+ if (rect && em.visible && !em.dying && moving) this.emit(L, rect, em, dt, share, el);
521
+ if (L.parts.length) this.stepAndDraw(ctx, L, dt, moving, cfg.behind && rect ? { rect, radius: em.radius } : null);
522
+ alive += L.parts.length;
523
+ if (L.parts.length || (rect && em.visible && !em.dying && moving)) anyActive = true;
524
+ }
525
+ if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
526
+ this.emitters.delete(el);
527
+ if (this.io) this.io.unobserve(el);
528
+ }
529
+ }
530
+
531
+ this.stats.particles = alive;
532
+ this.stats.emitters = this.emitters.size;
533
+ this.stats.frameMs = performance.now() - t0;
534
+ this.stats.frames++;
535
+ this._fpsN++;
536
+ if (now - this._fpsT > 500) { this.stats.fps = Math.round((this._fpsN * 1000) / Math.max(1, now - this._fpsT)); this._fpsT = now; this._fpsN = 0; }
537
+
538
+ if (this.emitters.size === 0) { this.running = false; ctx.clearRect(0, 0, this.vw, this.vh); return; }
539
+ // Normally the loop runs every frame (the glow follows the element, particles move). With reduced
540
+ // motion nothing moves, so one frame is a complete picture; it is redrawn on scroll, resize, a
541
+ // style change, and a slow poll for layout shifts. Essential layers and fading-out emitters still loop.
542
+ let needLoop = !reduced;
543
+ if (reduced) {
544
+ for (const em of this.emitters.values()) {
545
+ if (em.dying) needLoop = true;
546
+ if (em.glow.some((g) => g.cfg.essential) || em.layers.some((l) => l.cfg.essential && (l.parts.length || (em.visible && !em.dying)))) needLoop = true;
547
+ }
548
+ }
549
+ if (needLoop) { requestAnimationFrame(this._frame); return; }
550
+ this.running = false;
551
+ if (!this._poll) this._poll = setTimeout(() => { this._poll = null; this.start(); }, 600);
552
+ }
553
+
554
+ cornerRadius(el, rect) {
555
+ try {
556
+ const v = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
557
+ const r = rect ? Math.min(rect.width, rect.height) / 2 : 0;
558
+ return Math.min(v, r);
559
+ } catch { return 0; }
560
+ }
561
+
562
+ /* ---- glow ---- */
563
+
564
+ drawGlow(ctx, rect, em, g, now, reduced) {
565
+ const cfg = g.cfg;
566
+ const moving = !reduced || cfg.essential;
567
+ let a = cfg.intensity * em.fade;
568
+ if (moving && cfg.pulse > 0) {
569
+ const ph = ((now - g.t0) / cfg.pulse) * TAU;
570
+ a *= 1 - cfg.depth * 0.5 * (1 + Math.sin(ph)) ;
571
+ }
572
+ if (moving && cfg.flicker > 0) {
573
+ // smooth random flicker: ease toward a new random target a few times a second
574
+ if (Math.random() < 0.18) g.flickTarget = g.rnd();
575
+ g.flick += (g.flickTarget - g.flick) * 0.35;
576
+ a *= 1 - cfg.flicker * g.flick * 0.9;
577
+ }
578
+ if (a <= 0.004) return;
579
+ const c = cfg.rgba;
580
+ const R = cfg.radius;
581
+ const dpr = this.dpr;
582
+ const x = rect.left, y = rect.top, w = rect.width, h = rect.height;
583
+ const pad = Math.ceil(R * 1.6);
584
+ const lw = Math.ceil(w + pad * 2), lh = Math.ceil(h + pad * 2);
585
+ if (lw * lh * dpr * dpr > 6e6) return; // a glow bitmap bigger than this would cost more than it is worth
586
+
587
+ // The blur is the expensive part, and pulsing and flickering only change how BRIGHT the shape is, not
588
+ // what it looks like. So the shape is painted once into this glow's own bitmap, and each frame just
589
+ // draws that bitmap at the current alpha. It repaints only when the shape really changes.
590
+ const key = `${lw}x${lh}|${dpr}|${c.r | 0},${c.g | 0},${c.b | 0},${c.a.toFixed(2)}|${R}|${em.radius}|${cfg.tex ? sigOf(cfg.tex[0]) : ''}`;
591
+ if (!g.bitmap || g.bitmapKey !== key) {
592
+ if (!g.bitmap) g.bitmap = document.createElement('canvas');
593
+ const off = g.bitmap;
594
+ off.width = Math.ceil(lw * dpr); off.height = Math.ceil(lh * dpr);
595
+ const o = off.getContext('2d');
596
+ o.save();
597
+ // clip so the light never paints over the element itself
598
+ o.beginPath();
599
+ o.rect(0, 0, off.width, off.height);
600
+ o.scale(dpr, dpr);
601
+ roundRectPath(o, pad, pad, w, h, em.radius);
602
+ o.setTransform(1, 0, 0, 1, 0, 0);
603
+ o.clip('evenodd');
604
+ const SH = 10000; // draw the shape far off-canvas and let only its blurred shadow land here
605
+ o.shadowOffsetX = SH * dpr;
606
+ o.fillStyle = '#000';
607
+ for (const [k, mul] of [[1, 0.8], [0.55, 0.45], [0.25, 0.4]]) {
608
+ o.shadowBlur = R * k * dpr;
609
+ o.shadowColor = `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a * mul)})`;
610
+ o.beginPath();
611
+ o.scale(dpr, dpr);
612
+ roundRectPath(o, pad - SH, pad, w, h, em.radius);
613
+ o.setTransform(1, 0, 0, 1, 0, 0);
614
+ o.fill();
615
+ }
616
+ o.restore();
617
+ if (cfg.tex) {
618
+ const mask = this.maskCanvas(cfg.tex[0], 128);
619
+ o.save();
620
+ o.globalCompositeOperation = 'destination-in';
621
+ const pat = o.createPattern(mask, 'repeat');
622
+ if (pat) { o.fillStyle = pat; o.fillRect(0, 0, off.width, off.height); }
623
+ o.restore();
624
+ }
625
+ g.bitmapKey = key;
626
+ }
627
+ ctx.save();
628
+ ctx.globalCompositeOperation = 'lighter';
629
+ // intensity above 1 brightens by drawing the same light more than once
630
+ const passes = Math.max(1, Math.ceil(a));
631
+ ctx.globalAlpha = clamp01(a / passes);
632
+ for (let i = 0; i < passes; i++) ctx.drawImage(g.bitmap, 0, 0, g.bitmap.width, g.bitmap.height, x - pad, y - pad, lw, lh);
633
+ ctx.restore();
634
+ }
635
+
636
+ /* ---- masks and sprites ---- */
637
+
638
+ maskCanvas(texLayer, size) {
639
+ const key = sigOf([texLayer, size]);
640
+ let c = this.masks.get(key);
641
+ if (c) return c;
642
+ const data = makeMask(texLayer, size);
643
+ c = document.createElement('canvas');
644
+ c.width = c.height = size;
645
+ const x = c.getContext('2d');
646
+ const img = x.createImageData(size, size);
647
+ for (let i = 0; i < data.length; i++) { img.data[i * 4 + 3] = data[i]; img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = 255; }
648
+ x.putImageData(img, 0, 0);
649
+ this.masks.set(key, c);
650
+ return c;
651
+ }
652
+
653
+ /** a soft round sprite in one colour, optionally textured; cached by quantised colour */
654
+ sprite(c, shape, tex, variant) {
655
+ const q = (v) => (Math.round(v / 16) * 16) | 0;
656
+ const key = `${q(c.r)},${q(c.g)},${q(c.b)}|${shape}|${tex ? sigOf(tex[0]) : ''}|${variant}`;
657
+ let s = this.sprites.get(key);
658
+ if (s) return s;
659
+ if (this.sprites.size > 600) this.sprites.clear();
660
+ const S = 64;
661
+ s = document.createElement('canvas');
662
+ s.width = s.height = S;
663
+ const x = s.getContext('2d');
664
+ const rgb = `${q(c.r)},${q(c.g)},${q(c.b)}`;
665
+ if (shape === 'ring') {
666
+ x.strokeStyle = `rgb(${rgb})`; x.lineWidth = 5; x.beginPath(); x.arc(S / 2, S / 2, S / 2 - 6, 0, TAU); x.stroke();
667
+ } else if (shape === 'star') {
668
+ x.fillStyle = `rgb(${rgb})`; x.beginPath();
669
+ for (let i = 0; i < 8; i++) { const r = i % 2 ? S * 0.1 : S * 0.48, a = (i / 8) * TAU - Math.PI / 2; x.lineTo(S / 2 + Math.cos(a) * r, S / 2 + Math.sin(a) * r); }
670
+ x.closePath(); x.fill();
671
+ } else if (shape === 'square') {
672
+ x.fillStyle = `rgb(${rgb})`; x.fillRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7);
673
+ } else {
674
+ const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
675
+ const hard = shape === 'circle' ? 0.55 : 0.0;
676
+ g.addColorStop(0, `rgba(${rgb},1)`);
677
+ g.addColorStop(hard, `rgba(${rgb},${shape === 'circle' ? 0.95 : 0.7})`);
678
+ g.addColorStop(1, `rgba(${rgb},0)`);
679
+ x.fillStyle = g; x.fillRect(0, 0, S, S);
680
+ }
681
+ if (tex) {
682
+ // multiply the sprite's alpha by a texture mask, a different offset per variant
683
+ const m = this.maskCanvas(tex[0], 64);
684
+ const id = x.getImageData(0, 0, S, S);
685
+ const mx = m.getContext('2d').getImageData(0, 0, S, S).data;
686
+ const off = (variant * 13) % S;
687
+ for (let yy = 0; yy < S; yy++) for (let xx = 0; xx < S; xx++) {
688
+ const mi = (((yy + off) % S) * S + ((xx + off * 2) % S)) * 4 + 3;
689
+ const i = (yy * S + xx) * 4 + 3;
690
+ id.data[i] = (id.data[i] * mx[mi]) / 255;
691
+ }
692
+ x.putImageData(id, 0, 0);
693
+ }
694
+ this.sprites.set(key, s);
695
+ return s;
696
+ }
697
+
698
+ /* ---- particles ---- */
699
+
700
+ emit(L, rect, em, dt, share, el) {
701
+ const c = L.cfg;
702
+ const meanLife = ((c.life[0] + c.life[1]) / 2) / 1000;
703
+ const rate = (c.count * share) / meanLife; // per second
704
+ L.acc += rate * dt;
705
+ const cap = Math.max(1, Math.round(c.count * share));
706
+ while (L.acc >= 1) {
707
+ L.acc -= 1;
708
+ if (L.parts.length >= cap) { L.acc = Math.min(L.acc, 1); break; }
709
+ L.parts.push(this.spawn(L, rect, el));
710
+ }
711
+ }
712
+
713
+ spawn(L, rect, el) {
714
+ const c = L.cfg, r = L.rnd;
715
+ const between = (a, b) => a + (b - a) * r();
716
+ let x, y;
717
+ switch (c.from) {
718
+ case 'bottom': x = rect.left + r() * rect.width; y = rect.bottom; break;
719
+ case 'top': x = rect.left + r() * rect.width; y = rect.top; break;
720
+ case 'left': x = rect.left; y = rect.top + r() * rect.height; break;
721
+ case 'right': x = rect.right; y = rect.top + r() * rect.height; break;
722
+ case 'center': x = rect.left + r() * rect.width; y = rect.top + r() * rect.height; break;
723
+ case 'corners': { const k = (r() * 4) | 0; x = k & 1 ? rect.right : rect.left; y = k & 2 ? rect.bottom : rect.top; break; }
724
+ case 'thumb': {
725
+ // the thumb of a range input: where the value is
726
+ let p = 0.5;
727
+ try { const mn = Number(el.min || 0), mx = Number(el.max || 100); p = (Number(el.value) - mn) / (mx - mn || 1); } catch { /* not a range */ }
728
+ const pad = 10;
729
+ x = rect.left + pad + p * Math.max(0, rect.width - pad * 2); y = rect.top + rect.height / 2 + (r() - 0.5) * 6; break;
730
+ }
731
+ default: { // edge: a random point on the perimeter
732
+ const per = 2 * (rect.width + rect.height);
733
+ let d = r() * per;
734
+ if (d < rect.width) { x = rect.left + d; y = rect.top; }
735
+ else if ((d -= rect.width) < rect.height) { x = rect.right; y = rect.top + d; }
736
+ else if ((d -= rect.height) < rect.width) { x = rect.right - d; y = rect.bottom; }
737
+ else { d -= rect.width; x = rect.left; y = rect.bottom - d; }
738
+ }
739
+ }
740
+ let ang;
741
+ if (c.dir === 'out' || c.dir === 'in') {
742
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
743
+ ang = Math.atan2(y - cy, x - cx);
744
+ if (c.dir === 'in') ang += Math.PI;
745
+ } else ang = (c.dir * Math.PI) / 180;
746
+ ang += ((r() - 0.5) * c.spread * Math.PI) / 180;
747
+ const sp = between(c.speed[0], c.speed[1]);
748
+ return {
749
+ x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
750
+ age: 0, life: between(c.life[0], c.life[1]) / 1000,
751
+ size: between(c.size[0], c.size[1]), rot: r() * TAU, spin: (r() - 0.5) * 4, variant: (r() * 4) | 0, ph: r() * TAU,
752
+ };
753
+ }
754
+
755
+ stepAndDraw(ctx, L, dt, moving, hide) {
756
+ const c = L.cfg, parts = L.parts;
757
+ const additive = c.blend === 'add';
758
+ ctx.save();
759
+ if (hide) {
760
+ // clip the element's own box OUT, so the layer reads as sitting behind it
761
+ ctx.beginPath();
762
+ ctx.rect(-50, -50, this.vw + 100, this.vh + 100);
763
+ roundRectPath(ctx, hide.rect.left, hide.rect.top, hide.rect.width, hide.rect.height, hide.radius);
764
+ ctx.clip('evenodd');
765
+ }
766
+ ctx.globalCompositeOperation = additive ? 'lighter' : 'source-over';
767
+ const drag = Math.exp(-c.drag * dt);
768
+ for (let i = parts.length - 1; i >= 0; i--) {
769
+ const p = parts[i];
770
+ if (moving) {
771
+ p.age += dt;
772
+ if (p.age >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; }
773
+ p.vy += c.gravity * dt;
774
+ if (c.turbulence) { p.vx += Math.sin(p.ph + p.age * 7) * c.turbulence * dt; p.vy += Math.cos(p.ph * 1.3 + p.age * 5) * c.turbulence * 0.3 * dt; }
775
+ if (c.drag) { p.vx *= drag; p.vy *= drag; }
776
+ p.x += p.vx * dt; p.y += p.vy * dt;
777
+ p.rot += p.spin * dt;
778
+ }
779
+ const t = p.age / p.life;
780
+ const col = colorAt(c.stops, t);
781
+ const alpha = clamp01(col.a);
782
+ if (alpha <= 0.003) continue;
783
+ const size = p.size * lerp(1, c.grow, t);
784
+ this.drawParticle(ctx, c, p, col, alpha, size);
785
+ }
786
+ ctx.restore();
787
+ }
788
+
789
+ drawParticle(ctx, c, p, col, alpha, size) {
790
+ const shape = c.shape;
791
+ if (shape === 'spark' || shape === 'line') {
792
+ const len = Math.max(size, Math.hypot(p.vx, p.vy) * 0.06);
793
+ const sp = Math.hypot(p.vx, p.vy) || 1;
794
+ ctx.strokeStyle = `rgba(${col.r | 0},${col.g | 0},${col.b | 0},${alpha})`;
795
+ ctx.lineWidth = Math.max(1, size * 0.3);
796
+ ctx.lineCap = 'round';
797
+ ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - (p.vx / sp) * len, p.y - (p.vy / sp) * len); ctx.stroke();
798
+ if (c.glowing) {
799
+ const s = this.sprite(col, 'glow', c.tex, p.variant), d = size * 3;
800
+ ctx.globalAlpha = alpha * 0.5; ctx.drawImage(s, p.x - d / 2, p.y - d / 2, d, d); ctx.globalAlpha = 1;
801
+ }
802
+ return;
803
+ }
804
+ const kind = shape === 'circle' && c.glowing ? 'glow' : shape === 'flame' ? 'glow' : shape === 'smoke' ? 'glow' : shape;
805
+ const s = this.sprite(col, kind, c.tex, p.variant);
806
+ ctx.globalAlpha = alpha;
807
+ if (shape === 'flame') {
808
+ // a teardrop: stretched along its travel, narrower at the tip
809
+ const sp = Math.hypot(p.vx, p.vy) || 1;
810
+ const ang = Math.atan2(p.vy, p.vx) + Math.PI / 2;
811
+ ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(ang);
812
+ const w = size * 2.2, h = size * 3.4;
813
+ ctx.drawImage(s, -w / 2, -h * 0.65, w, h);
814
+ ctx.restore();
815
+ } else if (shape === 'ring' || shape === 'star' || shape === 'square') {
816
+ ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
817
+ const d = size * (shape === 'ring' ? 1.2 : 1.4);
818
+ ctx.drawImage(s, -d / 2, -d / 2, d, d);
819
+ ctx.restore();
820
+ } else {
821
+ const d = size * (c.glowing || shape === 'smoke' ? 2.6 : 1.2);
822
+ ctx.drawImage(s, p.x - d / 2, p.y - d / 2, d, d);
823
+ }
824
+ ctx.globalAlpha = 1;
825
+ }
826
+
827
+ /** for tests and tools: a readable snapshot, never needed in normal use */
828
+ debug() {
829
+ const out = [];
830
+ for (const [el, em] of this.emitters) out.push({ el, visible: em.visible, dying: em.dying, glow: em.glow.length, layers: em.layers.map((l) => ({ alive: l.parts.length, sample: l.parts.slice(0, 5).map((p) => ({ x: p.x, y: p.y, vx: p.vx, vy: p.vy, age: p.age })) })) });
831
+ return { stats: { ...this.stats }, running: this.running, paused: this.paused, canvas: !!this.canvas, emitters: out };
832
+ }
833
+ }
dssl-vscode/lib/engine/color.js ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: color.js
3
+ *
4
+ * One small colour library for everything that has to know what a colour
5
+ * actually IS: the contrast lint, aura particles painted on a canvas, texture
6
+ * tints baked into SVG. No dependencies, no DOM.
7
+ *
8
+ * Colours are { r, g, b, a } with r/g/b in 0..255 and a in 0..1.
9
+ *
10
+ * parseColor handles what the browser hands back as well as what people
11
+ * write: #rgb #rgba #rrggbb #rrggbbaa, rgb()/rgba(), hsl()/hsla(), oklab(),
12
+ * oklch(), color(srgb ...), color-mix(in <space> A p%, B), the CSS names, and
13
+ * transparent. Anything it cannot read returns null, and callers decide
14
+ * whether that is an error (a texture colour) or silence (a lint check).
15
+ * ========================================================================= */
16
+
17
+ const NAMED = {
18
+ aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
19
+ blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
20
+ chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', cyan: '#00ffff', darkblue: '#00008b',
21
+ darkcyan: '#008b8b', darkgoldenrod: '#b8860b', darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b', darkmagenta: '#8b008b',
22
+ darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f',
23
+ darkslateblue: '#483d8b', darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3', deeppink: '#ff1493',
24
+ deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969', dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
25
+ fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', gray: '#808080', green: '#008000',
26
+ greenyellow: '#adff2f', grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4', indianred: '#cd5c5c', indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c',
27
+ lavender: '#e6e6fa', lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', lightcoral: '#f08080',
28
+ lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3', lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1',
29
+ lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de',
30
+ lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32', linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
31
+ mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371', mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a',
32
+ mediumturquoise: '#48d1cc', mediumvioletred: '#c71585', midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
33
+ navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500',
34
+ orchid: '#da70d6', palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093', papayawhip: '#ffefd5',
35
+ peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb', plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399', red: '#ff0000',
36
+ rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513', salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee',
37
+ sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd', slategray: '#708090', slategrey: '#708090', snow: '#fffafa',
38
+ springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8', tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee',
39
+ wheat: '#f5deb3', white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32',
40
+ };
41
+
42
+ export const TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 };
43
+
44
+ const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
45
+ const round = (x) => Math.round(x);
46
+
47
+ /* ---------- colour-space maths ---------- */
48
+
49
+ const srgbToLinear = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
50
+ const linearToSrgb = (c) => { const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; return clamp(v, 0, 1) * 255; };
51
+
52
+ function oklabToSrgb(L, a, b) {
53
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
54
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
55
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
56
+ const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
57
+ return [
58
+ linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
59
+ linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
60
+ linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
61
+ ];
62
+ }
63
+
64
+ function srgbToOklab(r, g, b) {
65
+ const lr = srgbToLinear(r), lg = srgbToLinear(g), lb = srgbToLinear(b);
66
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
67
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
68
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
69
+ return [
70
+ 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
71
+ 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
72
+ 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
73
+ ];
74
+ }
75
+
76
+ function hslToRgb(h, s, l) {
77
+ h = ((h % 360) + 360) % 360; s = clamp(s, 0, 1); l = clamp(l, 0, 1);
78
+ const k = (n) => (n + h / 30) % 12;
79
+ const a = s * Math.min(l, 1 - l);
80
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
81
+ return [f(0) * 255, f(8) * 255, f(4) * 255];
82
+ }
83
+
84
+ /* ---------- parsing ---------- */
85
+
86
+ /** a number or a percentage; `scale` is what 100% means (255 for rgb, 1 for alpha...) */
87
+ function num(tok, scale = 1) {
88
+ if (tok === undefined || tok === null) return null;
89
+ const t = String(tok).trim();
90
+ if (t === 'none') return 0;
91
+ if (t.endsWith('%')) { const n = parseFloat(t); return Number.isNaN(n) ? null : (n / 100) * scale; }
92
+ const n = parseFloat(t);
93
+ return Number.isNaN(n) ? null : n;
94
+ }
95
+
96
+ function angle(tok) {
97
+ const t = String(tok).trim();
98
+ if (t === 'none') return 0;
99
+ const n = parseFloat(t);
100
+ if (Number.isNaN(n)) return null;
101
+ if (t.endsWith('turn')) return n * 360;
102
+ if (t.endsWith('rad')) return (n * 180) / Math.PI;
103
+ if (t.endsWith('grad')) return n * 0.9;
104
+ return n;
105
+ }
106
+
107
+ /** split "a b c / d" or "a, b, c, d" function arguments at the top level */
108
+ function splitArgs(body) {
109
+ const out = [];
110
+ let depth = 0, cur = '';
111
+ for (const ch of body) {
112
+ if (ch === '(') depth++;
113
+ if (ch === ')') depth--;
114
+ if (depth === 0 && (ch === ',' || ch === '/' || /\s/.test(ch))) {
115
+ if (cur) out.push(cur);
116
+ cur = '';
117
+ if (ch === '/') out.push('/');
118
+ continue;
119
+ }
120
+ cur += ch;
121
+ }
122
+ if (cur) out.push(cur);
123
+ return out;
124
+ }
125
+
126
+ /** parse any one CSS colour. Returns {r,g,b,a} or null. */
127
+ export function parseColor(input) {
128
+ if (input === null || input === undefined) return null;
129
+ let s = String(input).trim().toLowerCase();
130
+ if (!s) return null;
131
+ if (s === 'transparent') return { ...TRANSPARENT };
132
+ if (NAMED[s]) s = NAMED[s];
133
+
134
+ if (s[0] === '#') {
135
+ const h = s.slice(1);
136
+ if (!/^[0-9a-f]+$/.test(h)) return null;
137
+ if (h.length === 3 || h.length === 4) {
138
+ const [r, g, b, a] = [...h].map((c) => parseInt(c + c, 16));
139
+ return { r, g, b, a: h.length === 4 ? a / 255 : 1 };
140
+ }
141
+ if (h.length === 6 || h.length === 8) {
142
+ return {
143
+ r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16),
144
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
145
+ };
146
+ }
147
+ return null;
148
+ }
149
+
150
+ const fn = /^([a-z-]+)\((.*)\)$/s.exec(s);
151
+ if (!fn) return null;
152
+ const name = fn[1];
153
+ const body = fn[2];
154
+
155
+ if (name === 'color-mix') return parseColorMix(body);
156
+
157
+ const parts = splitArgs(body);
158
+ const slash = parts.indexOf('/');
159
+ const main = slash === -1 ? parts : parts.slice(0, slash);
160
+ let alpha = 1;
161
+ if (slash !== -1) { alpha = num(parts[slash + 1], 1); if (alpha === null) return null; }
162
+ else if ((name === 'rgba' || name === 'hsla') && main.length === 4) alpha = num(main.pop(), 1);
163
+ else if ((name === 'rgb' || name === 'hsl') && main.length === 4) alpha = num(main.pop(), 1);
164
+ alpha = clamp(alpha, 0, 1);
165
+
166
+ if (name === 'rgb' || name === 'rgba') {
167
+ if (main.length !== 3) return null;
168
+ const [r, g, b] = main.map((t) => num(t, 255));
169
+ if ([r, g, b].some((v) => v === null)) return null;
170
+ return { r: clamp(round(r), 0, 255), g: clamp(round(g), 0, 255), b: clamp(round(b), 0, 255), a: alpha };
171
+ }
172
+ if (name === 'hsl' || name === 'hsla') {
173
+ if (main.length !== 3) return null;
174
+ const h = angle(main[0]), sat = num(main[1], 1), l = num(main[2], 1);
175
+ if ([h, sat, l].some((v) => v === null)) return null;
176
+ const [r, g, b] = hslToRgb(h, sat, l);
177
+ return { r: round(r), g: round(g), b: round(b), a: alpha };
178
+ }
179
+ if (name === 'oklab') {
180
+ if (main.length !== 3) return null;
181
+ const L = num(main[0], 1), A = num(main[1], 0.4), B = num(main[2], 0.4);
182
+ if ([L, A, B].some((v) => v === null)) return null;
183
+ const [r, g, b] = oklabToSrgb(L, A, B);
184
+ return { r: round(r), g: round(g), b: round(b), a: alpha };
185
+ }
186
+ if (name === 'oklch') {
187
+ if (main.length !== 3) return null;
188
+ const L = num(main[0], 1), C = num(main[1], 0.4), H = angle(main[2]);
189
+ if ([L, C, H].some((v) => v === null)) return null;
190
+ const rad = (H * Math.PI) / 180;
191
+ const [r, g, b] = oklabToSrgb(L, C * Math.cos(rad), C * Math.sin(rad));
192
+ return { r: round(r), g: round(g), b: round(b), a: alpha };
193
+ }
194
+ if (name === 'color') {
195
+ // what getComputedStyle returns for colour-mix and wide-gamut colours
196
+ const space = main[0];
197
+ if (space !== 'srgb' && space !== 'srgb-linear' && space !== 'display-p3') return null;
198
+ const [r, g, b] = main.slice(1, 4).map((t) => num(t, 1));
199
+ if ([r, g, b].some((v) => v === null)) return null;
200
+ if (space === 'srgb-linear') return { r: round(linearToSrgb(r)), g: round(linearToSrgb(g)), b: round(linearToSrgb(b)), a: alpha };
201
+ // display-p3 is wider than sRGB; clipping to sRGB is fine for canvas and contrast purposes
202
+ return { r: round(clamp(r, 0, 1) * 255), g: round(clamp(g, 0, 1) * 255), b: round(clamp(b, 0, 1) * 255), a: alpha };
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /** color-mix(in oklab, red 30%, blue) / (in srgb, ...) , good enough for what DSSL emits */
208
+ function parseColorMix(body) {
209
+ const parts = splitTopLevelCommas(body);
210
+ if (parts.length < 3) return null;
211
+ const spaceM = /^in\s+([a-z-]+)/.exec(parts[0].trim());
212
+ if (!spaceM) return null;
213
+ const space = spaceM[1];
214
+ const one = (txt) => {
215
+ const m = /^(.*?)(?:\s+([\d.]+)%)?$/s.exec(txt.trim());
216
+ const color = parseColor(m[1].trim());
217
+ return color ? { color, pct: m[2] === undefined ? null : parseFloat(m[2]) } : null;
218
+ };
219
+ const A = one(parts[1]), B = one(parts[2]);
220
+ if (!A || !B) return null;
221
+ let pa = A.pct, pb = B.pct;
222
+ if (pa === null && pb === null) { pa = 50; pb = 50; }
223
+ else if (pa === null) pa = 100 - pb;
224
+ else if (pb === null) pb = 100 - pa;
225
+ const total = pa + pb;
226
+ if (total <= 0) return null;
227
+ const t = pb / total; // share of B
228
+ const alphaMul = Math.min(1, total / 100);
229
+ const mixed = mix(A.color, B.color, t, space === 'srgb' ? 'srgb' : 'oklab');
230
+ return { ...mixed, a: mixed.a * alphaMul };
231
+ }
232
+
233
+ function splitTopLevelCommas(body) {
234
+ const out = [];
235
+ let depth = 0, cur = '';
236
+ for (const ch of body) {
237
+ if (ch === '(') depth++;
238
+ if (ch === ')') depth--;
239
+ if (depth === 0 && ch === ',') { out.push(cur); cur = ''; continue; }
240
+ cur += ch;
241
+ }
242
+ if (cur.trim()) out.push(cur);
243
+ return out;
244
+ }
245
+
246
+ /* ---------- operations ---------- */
247
+
248
+ /** premultiplied mix of two colours; t=0 is a, t=1 is b */
249
+ export function mix(a, b, t, space = 'oklab') {
250
+ t = clamp(t, 0, 1);
251
+ const alpha = a.a * (1 - t) + b.a * t;
252
+ if (alpha <= 0) return { r: 0, g: 0, b: 0, a: 0 };
253
+ const wa = (a.a * (1 - t)) / alpha, wb = (b.a * t) / alpha;
254
+ if (space === 'srgb') {
255
+ return { r: round(a.r * wa + b.r * wb), g: round(a.g * wa + b.g * wb), b: round(a.b * wa + b.b * wb), a: alpha };
256
+ }
257
+ const A = srgbToOklab(a.r, a.g, a.b), B = srgbToOklab(b.r, b.g, b.b);
258
+ const [r, g, bl] = oklabToSrgb(A[0] * wa + B[0] * wb, A[1] * wa + B[1] * wb, A[2] * wa + B[2] * wb);
259
+ return { r: round(r), g: round(g), b: round(bl), a: alpha };
260
+ }
261
+
262
+ /** paint `fg` over an opaque-or-not `bg` */
263
+ export function over(fg, bg) {
264
+ const a = fg.a + bg.a * (1 - fg.a);
265
+ if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
266
+ const f = (x, y) => round((x * fg.a + y * bg.a * (1 - fg.a)) / a);
267
+ return { r: f(fg.r, bg.r), g: f(fg.g, bg.g), b: f(fg.b, bg.b), a };
268
+ }
269
+
270
+ export const withAlpha = (c, a) => ({ ...c, a: clamp(a, 0, 1) });
271
+
272
+ /** WCAG relative luminance */
273
+ export function luminance(c) {
274
+ return 0.2126 * srgbToLinear(c.r) + 0.7152 * srgbToLinear(c.g) + 0.0722 * srgbToLinear(c.b);
275
+ }
276
+
277
+ /** WCAG contrast ratio between two colours (alpha must already be composited) */
278
+ export function contrast(a, b) {
279
+ const L1 = luminance(a), L2 = luminance(b);
280
+ return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
281
+ }
282
+
283
+ export const toRgba = (c) => `rgba(${c.r}, ${c.g}, ${c.b}, ${+c.a.toFixed(3)})`;
284
+ export const toHex = (c) => '#' + [c.r, c.g, c.b].map((v) => round(v).toString(16).padStart(2, '0')).join('');
285
+
286
+ /**
287
+ * Evaluate a colour EXPRESSION as DSSL writes it, using a token lookup:
288
+ * color.ink a token (its value may itself be an expression)
289
+ * color.ink/40 a token with alpha
290
+ * #d1006f/50 a literal with alpha
291
+ * oklch(62% 0.19 35) anything parseColor reads
292
+ * `lookup(name)` returns the raw token string or undefined. Returns a colour
293
+ * or null when it cannot be known at compile time (a runtime-only var()).
294
+ */
295
+ export function evalColorExpr(expr, lookup, depth = 0) {
296
+ if (depth > 8 || expr === null || expr === undefined) return null;
297
+ const s = String(expr).trim();
298
+ if (!s) return null;
299
+ const direct = parseColor(s);
300
+ if (direct) return direct;
301
+ const m = /^(.*?)\/\s*([\d.]+%?)$/.exec(s);
302
+ if (m && !s.includes('(')) {
303
+ const base = evalColorExpr(m[1].trim(), lookup, depth + 1);
304
+ if (!base) return null;
305
+ const a = num(m[2], 1);
306
+ if (a === null) return null;
307
+ return withAlpha(base, (a > 1 && !m[2].endsWith('%') ? a / 100 : a) * base.a);
308
+ }
309
+ if (/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/i.test(s) && lookup) {
310
+ const raw = lookup(s);
311
+ return raw === undefined ? null : evalColorExpr(raw, lookup, depth + 1);
312
+ }
313
+ return null;
314
+ }
dssl-vscode/lib/engine/import.js ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: import.js
3
+ *
4
+ * dssl import styles.css -o styles.dssl
5
+ *
6
+ * Turns an existing CSS file into DSSL, and tells you honestly what it could
7
+ * not carry over. It is a translator for the mechanical differences, not a
8
+ * magic wand:
9
+ *
10
+ * semicolons dropped, bare numbers for pixel lengths (16px -> 16)
11
+ * transition: color .2s ease -> transition: 200 ease color
12
+ * font: italic 700 14px/1.5 Arial -> the five longhands (DSSL's font: means something else)
13
+ * @keyframes -> motion { from / at 40% / to }
14
+ * !important -> the same declaration inside pin { }
15
+ * @font-face, @supports, @import, @property ... -> one raw { } block, verbatim
16
+ * properties DSSL does not know -> commented out, and listed in the report
17
+ *
18
+ * Everything else is kept as written, because DSSL selectors and CSS values are
19
+ * CSS. The result is then run through the real compiler and any error it still
20
+ * has is in the report, so you know before you open the file.
21
+ *
22
+ * Pure: no DOM, no filesystem. importCss(text) -> { dssl, report }.
23
+ * ========================================================================= */
24
+
25
+ import { parse } from './parser.js';
26
+ import { resolve, KNOWN_PROPS, UNITLESS_PROPS, EASINGS } from './resolver.js';
27
+
28
+ const RAW_AT_RULES = new Set(['font-face', 'supports', 'import', 'property', 'page', 'charset', 'namespace', 'counter-style', 'font-feature-values', 'font-palette-values', 'view-transition', 'scope', 'starting-style', 'position-try']);
29
+ const SEMANTIC_NAMES = new Set(['size', 'place', 'center', 'cover', 'sticky', 'radius', 'shadow', 'circle', 'z', 'grow', 'shrink', 'basis', 'stack', 'fluid', 'texture', 'aura', 'slider', 'bend', 'fold', 'stretch', 'gradient', 'animate', 'stagger', 'overlap']);
30
+
31
+ /* ------------------------------------------------------------------ *
32
+ * 1. a small CSS parser
33
+ * ------------------------------------------------------------------ */
34
+
35
+ /**
36
+ * Parse CSS into a tree. Handles comments, strings, url(), parentheses and
37
+ * nesting. Nodes:
38
+ * { type:'decl', prop, value, important, line }
39
+ * { type:'rule', sel, body:[nodes], line }
40
+ * { type:'at', name, prelude, body:[nodes]|null, raw, line }
41
+ * { type:'comment', text, line }
42
+ */
43
+ export function parseCss(src) {
44
+ let i = 0;
45
+ const n = src.length;
46
+ let line = 1;
47
+ const lineAt = (idx) => { let l = 1; for (let k = 0; k < idx && k < n; k++) if (src.charCodeAt(k) === 10) l++; return l; };
48
+
49
+ function skipString(q) {
50
+ i++;
51
+ while (i < n && src[i] !== q) { if (src[i] === '\\') i++; i++; }
52
+ i++;
53
+ }
54
+
55
+ function parseBody(inBlock) {
56
+ const nodes = [];
57
+ let buf = '';
58
+ let bufStart = i;
59
+ let paren = 0;
60
+ const flushDecl = (end) => {
61
+ const text = buf.trim();
62
+ buf = '';
63
+ if (!text) return;
64
+ const colon = text.indexOf(':');
65
+ if (colon === -1) { nodes.push({ type: 'junk', text, line: lineAt(end) }); return; }
66
+ const prop = text.slice(0, colon).trim();
67
+ let value = text.slice(colon + 1).trim();
68
+ let important = false;
69
+ const im = /\s*!\s*important\s*$/i.exec(value);
70
+ if (im) { important = true; value = value.slice(0, im.index).trim(); }
71
+ nodes.push({ type: 'decl', prop, value, important, line: lineAt(bufStart) });
72
+ };
73
+ while (i < n) {
74
+ const c = src[i];
75
+ if (c === '/' && src[i + 1] === '*') {
76
+ const end = src.indexOf('*/', i + 2);
77
+ const stop = end === -1 ? n : end + 2;
78
+ if (!buf.trim()) nodes.push({ type: 'comment', text: src.slice(i + 2, end === -1 ? n : end).trim(), line: lineAt(i) });
79
+ i = stop;
80
+ continue;
81
+ }
82
+ if (c === '"' || c === "'") { const s = i; skipString(c); buf += src.slice(s, i); continue; }
83
+ if (c === '(') { paren++; buf += c; i++; continue; }
84
+ if (c === ')') { paren--; buf += c; i++; continue; }
85
+ if (paren > 0) { buf += c; i++; continue; }
86
+ if (c === ';') { const at = i; i++; flushAt(at); continue; }
87
+ if (c === '{') {
88
+ const prelude = buf.trim();
89
+ const startIdx = bufStart;
90
+ buf = '';
91
+ const startLine = lineAt(startIdx);
92
+ i++;
93
+ const body = parseBody(true);
94
+ const node = { line: startLine };
95
+ const atm = prelude.startsWith('@') ? /^@([\w-]+)\s*([\s\S]*)$/.exec(prelude) : null;
96
+ if (atm) {
97
+ Object.assign(node, { type: 'at', name: atm[1].toLowerCase(), prelude: atm[2].trim(), body, raw: src.slice(startIdx, i).trim() });
98
+ } else if (prelude && !prelude.startsWith('@')) {
99
+ Object.assign(node, { type: 'rule', sel: prelude, body });
100
+ } else {
101
+ // "@@@ {" or a block with no selector at all: nothing sensible to convert
102
+ Object.assign(node, { type: 'junk', text: prelude || '{ ... }' });
103
+ }
104
+ nodes.push(node);
105
+ bufStart = i;
106
+ continue;
107
+ }
108
+ if (c === '}') {
109
+ if (inBlock) { flushDecl(i); i++; return nodes; }
110
+ i++; continue; // a stray brace at the top level
111
+ }
112
+ if (!buf.trim()) bufStart = i;
113
+ buf += c; i++;
114
+ }
115
+ flushDecl(i);
116
+ return nodes;
117
+
118
+ // a ";" ended something: a declaration, or an at-rule with no block (@import ...;)
119
+ function flushAt(at) {
120
+ const text = buf.trim();
121
+ const m = text.startsWith('@') ? /^@([\w-]+)\s*([\s\S]*)$/.exec(text) : null;
122
+ if (text.startsWith('@') && !m) { nodes.push({ type: 'junk', text, line: lineAt(bufStart) }); buf = ''; }
123
+ else if (m) {
124
+ nodes.push({ type: 'at', name: m[1].toLowerCase(), prelude: m[2].trim(), body: null, raw: text + ';', line: lineAt(bufStart) });
125
+ buf = '';
126
+ } else flushDecl(at);
127
+ bufStart = i;
128
+ }
129
+ }
130
+ return parseBody(false);
131
+ }
132
+
133
+ /* ------------------------------------------------------------------ *
134
+ * 2. value conversion
135
+ * ------------------------------------------------------------------ */
136
+
137
+ /** split on top-level whitespace or commas, keeping rgb(1 2 3), url(a b) etc. whole */
138
+ function topSplit(s, sep) {
139
+ const out = []; let depth = 0, cur = '', q = null;
140
+ for (const ch of s) {
141
+ if (q) { cur += ch; if (ch === q) q = null; continue; }
142
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
143
+ if (ch === '(') depth++;
144
+ if (ch === ')') depth--;
145
+ const hit = sep === ',' ? ch === ',' : /\s/.test(ch);
146
+ if (hit && depth === 0) { if (cur.trim()) out.push(cur.trim()); cur = ''; continue; }
147
+ cur += ch;
148
+ }
149
+ if (cur.trim()) out.push(cur.trim());
150
+ return out;
151
+ }
152
+
153
+ const PX = /^(-?\d*\.?\d+)px$/;
154
+
155
+ function stripPx(prop, value) {
156
+ if (prop.startsWith('--') || UNITLESS_PROPS.has(prop)) return value;
157
+ // only top-level words: a px inside calc() or min() is left as written
158
+ return topSplit(value, ' ').map((w) => (PX.test(w) ? w.replace(PX, '$1') : w)).join(' ');
159
+ }
160
+
161
+ const msOf = (t) => {
162
+ const m = /^(\d*\.?\d+)(ms|s)$/.exec(t);
163
+ if (!m) return null;
164
+ return m[2] === 's' ? Math.round(parseFloat(m[1]) * 1000) : Math.round(parseFloat(m[1]));
165
+ };
166
+
167
+ function convertTransition(value, note) {
168
+ if (value.trim() === 'none') return 'none';
169
+ const out = [];
170
+ for (const seg of topSplit(value, ',')) {
171
+ const atoms = topSplit(seg, ' ');
172
+ const times = atoms.filter((a) => msOf(a) !== null);
173
+ const ease = atoms.find((a) => EASINGS[a] || /^(cubic-bezier|steps)\(/.test(a));
174
+ const props = atoms.filter((a) => msOf(a) === null && a !== ease);
175
+ if (!times.length) { note(`transition "${seg}" has no duration, so it was dropped`); continue; }
176
+ if (times.length > 1) note('a transition delay has no DSSL form here, so it was dropped (use transition-delay)');
177
+ const prop = props.join(' ');
178
+ out.push([String(msOf(times[0])), ease, prop && prop !== 'all' ? prop : ''].filter(Boolean).join(' '));
179
+ }
180
+ return out.join(', ');
181
+ }
182
+
183
+ /** font: [style] [weight] size[/line-height] family -> longhands */
184
+ function convertFont(value, note) {
185
+ const SYSTEM = /^(caption|icon|menu|message-box|small-caption|status-bar|inherit|initial|unset)$/;
186
+ if (SYSTEM.test(value.trim())) { note(`font: ${value} is a system font keyword with no DSSL equivalent`); return null; }
187
+ const atoms = topSplit(value, ' ');
188
+ const sizeIdx = atoms.findIndex((a) => /^(-?\d*\.?\d+)(px|em|rem|%|pt|vw|vh)?(\/.+)?$/.test(a) && !/^\d{3}$/.test(a) && !/^\d00$/.test(a)
189
+ || /^(xx-small|x-small|small|medium|large|x-large|xx-large)(\/.+)?$/.test(a));
190
+ if (sizeIdx === -1) { note(`font: ${value} has no size, so it could not be split`); return null; }
191
+ const out = [];
192
+ for (const a of atoms.slice(0, sizeIdx)) {
193
+ if (/^(italic|oblique)$/.test(a)) out.push(['font-style', a]);
194
+ else if (/^(\d{3}|bold|bolder|lighter)$/.test(a)) out.push(['font-weight', a]);
195
+ else if (a === 'small-caps') out.push(['font-variant', a]);
196
+ }
197
+ const [size, lh] = atoms[sizeIdx].split('/');
198
+ out.push(['font-size', size]);
199
+ if (lh) out.push(['line-height', lh]);
200
+ const fam = atoms.slice(sizeIdx + 1).join(' ');
201
+ if (fam) out.push(['font-family', fam]);
202
+ return out;
203
+ }
204
+
205
+ /* ------------------------------------------------------------------ *
206
+ * 3. the converter
207
+ * ------------------------------------------------------------------ */
208
+
209
+ export function importCss(css, options = {}) {
210
+ const tree = parseCss(String(css));
211
+ const report = { rules: 0, declarations: 0, converted: 0, pinned: 0, raw: 0, motions: 0, dropped: [], notes: [], compile: null };
212
+ const rawChunks = [];
213
+ const pinBlocks = [];
214
+ const note = (line, message, hint) => report.notes.push({ line, message, hint });
215
+ const pad = (d) => ' '.repeat(d);
216
+
217
+ const emitDecl = (d, depth, out, collectPin) => {
218
+ report.declarations++;
219
+ const line = d.line;
220
+ const prop = d.prop.toLowerCase();
221
+ if (!prop.startsWith('--') && !KNOWN_PROPS.has(prop) && prop !== 'transition' && prop !== 'font' && prop !== 'animation' && !SEMANTIC_NAMES.has(prop)) {
222
+ out.push(`${pad(depth)}// dssl import dropped (unknown to DSSL): ${d.prop}: ${d.value}`);
223
+ report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'DSSL does not know this property' });
224
+ return;
225
+ }
226
+ if (SEMANTIC_NAMES.has(prop) && prop !== 'font') {
227
+ note(line, `"${d.prop}" is also a DSSL shorthand with a different meaning`, 'check this line after import');
228
+ }
229
+ if (d.important) {
230
+ collectPin.push({ prop, value: d.value, line });
231
+ report.pinned++;
232
+ return;
233
+ }
234
+ let value = d.value;
235
+ if (prop === 'transition') {
236
+ value = convertTransition(value, (m) => note(line, m));
237
+ if (!value) { report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'no usable duration' }); out.push(`${pad(depth)}// dssl import dropped: ${d.prop}: ${d.value}`); return; }
238
+ report.converted++;
239
+ out.push(`${pad(depth)}transition: ${value}`);
240
+ return;
241
+ }
242
+ if (prop === 'font') {
243
+ const parts = convertFont(value, (m) => note(line, m));
244
+ if (!parts) { report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'font shorthand could not be split' }); out.push(`${pad(depth)}// dssl import dropped: ${d.prop}: ${d.value}`); return; }
245
+ report.converted++;
246
+ for (const [p, v] of parts) out.push(`${pad(depth)}${p}: ${stripPx(p, v)}`);
247
+ return;
248
+ }
249
+ if (prop === 'grid') note(line, 'grid: is a CSS shorthand here but a different sugar in DSSL', 'it was kept as written; rewrite it with grid-template-* if the compiler complains');
250
+ const next = stripPx(prop, value);
251
+ if (next !== value) report.converted++;
252
+ out.push(`${pad(depth)}${d.prop}: ${next}`);
253
+ };
254
+
255
+ const emitNodes = (nodes, depth, out) => {
256
+ for (const node of nodes) {
257
+ if (node.type === 'comment') { if (depth === 0 || true) out.push(`${pad(depth)}// ${node.text.replace(/\n\s*/g, ' ')}`); continue; }
258
+ if (node.type === 'junk') { note(node.line, `could not read "${node.text.slice(0, 40)}"`); continue; }
259
+ if (node.type === 'decl') { emitDecl(node, depth, out, out.__pin || (out.__pin = [])); continue; }
260
+ if (node.type === 'rule') {
261
+ report.rules++;
262
+ const lines = [];
263
+ lines.__pin = [];
264
+ emitNodes(node.body, depth + 1, lines);
265
+ out.push(`${pad(depth)}${node.sel} {`, ...lines, `${pad(depth)}}`);
266
+ if (lines.__pin.length) pinBlocks.push({ sel: node.sel, decls: lines.__pin, depth });
267
+ continue;
268
+ }
269
+ if (node.type === 'at') {
270
+ const name = node.name;
271
+ if (name === 'keyframes' || name.endsWith('-keyframes')) {
272
+ if (name !== 'keyframes') { note(node.line, `@${name} is a vendor copy; the plain @keyframes is used`); continue; }
273
+ report.motions++;
274
+ out.push(`${pad(depth)}motion ${node.prelude} {`);
275
+ for (const fr of node.body || []) {
276
+ if (fr.type !== 'rule') continue;
277
+ for (const label of fr.sel.split(',').map((s) => s.trim())) {
278
+ const key = label === '0%' ? 'from' : label === '100%' ? 'to' : /%$/.test(label) ? `at ${label}` : label;
279
+ const inner = [];
280
+ inner.__pin = [];
281
+ emitNodes(fr.body, depth + 2, inner);
282
+ out.push(`${pad(depth + 1)}${key} {`, ...inner, `${pad(depth + 1)}}`);
283
+ }
284
+ }
285
+ out.push(`${pad(depth)}}`);
286
+ continue;
287
+ }
288
+ if (RAW_AT_RULES.has(name)) {
289
+ report.raw++;
290
+ rawChunks.push(node.raw);
291
+ if (name === 'import') note(node.line, '@import was moved into raw { }', 'load other stylesheets with <link rel="stylesheet/dssl"> or <link rel="stylesheet"> instead');
292
+ continue;
293
+ }
294
+ if (name === 'media' || name === 'container') {
295
+ const lines = [];
296
+ lines.__pin = [];
297
+ emitNodes(node.body || [], depth + 1, lines);
298
+ out.push(`${pad(depth)}@${name} ${node.prelude} {`, ...lines, `${pad(depth)}}`);
299
+ if (lines.__pin.length) note(node.line, `!important inside @${name} was moved to pin { } and loses the media condition`, 'wrap it in @media inside pin by hand');
300
+ continue;
301
+ }
302
+ if (name === 'layer') {
303
+ if (!node.body) { note(node.line, `@layer ${node.prelude}; is dropped: DSSL's layers are fixed (tokens, base, components, utilities, pin)`); continue; }
304
+ const lines = [];
305
+ lines.__pin = [];
306
+ emitNodes(node.body, depth + 1, lines);
307
+ out.push(`${pad(depth)}@layer ${node.prelude} {`, ...lines, `${pad(depth)}}`);
308
+ continue;
309
+ }
310
+ report.raw++;
311
+ rawChunks.push(node.raw);
312
+ note(node.line, `@${name} was kept verbatim in raw { }`);
313
+ }
314
+ }
315
+ };
316
+
317
+ const lines = [];
318
+ lines.__pin = [];
319
+ emitNodes(tree, 0, lines);
320
+
321
+ let dssl = lines.join('\n');
322
+ if (pinBlocks.length) {
323
+ dssl += `\n\n// !important in CSS is pin { } in DSSL: the layer that always wins\npin {\n`;
324
+ for (const b of pinBlocks) {
325
+ dssl += ` ${b.sel} {\n${b.decls.map((d) => ` ${d.prop}: ${stripPx(d.prop, d.value)}`).join('\n')}\n }\n`;
326
+ }
327
+ dssl += `}`;
328
+ }
329
+ if (rawChunks.length) {
330
+ dssl = `// raw { } is real CSS, so semicolons are required inside it\nraw {\n${rawChunks.map((c) => ' ' + c.replace(/\n/g, '\n ')).join('\n')}\n}\n\n` + dssl;
331
+ }
332
+ dssl = dssl.trim() + '\n';
333
+
334
+ // Run the real compiler on the result. A declaration it rejects (valid CSS that DSSL's
335
+ // stricter value checking does not accept, like scroll-snap-type: x mandatory) is commented
336
+ // out and listed, so the file you get always compiles.
337
+ const check = () => {
338
+ try {
339
+ const { ast, errors: parseErrors } = parse(dssl, 'imported.dssl');
340
+ const doc = resolve(ast, 'imported.dssl');
341
+ return { errors: [...parseErrors, ...doc.errors], warnings: doc.warnings.length };
342
+ } catch (e) {
343
+ return { errors: [{ line: 0, message: `the compiler threw: ${e.message}` }], warnings: 0 };
344
+ }
345
+ };
346
+ let result = check();
347
+ for (let pass = 0; pass < 4 && result.errors.length; pass++) {
348
+ const lines = dssl.split('\n');
349
+ const seen = new Set();
350
+ let changed = false;
351
+ for (const e of result.errors) {
352
+ const idx = (e.line || 0) - 1;
353
+ if (idx < 0 || idx >= lines.length || seen.has(idx)) continue;
354
+ const text = lines[idx];
355
+ if (!/^\s*[-\w]+\s*:/.test(text) || /^\s*\/\//.test(text)) continue; // only declaration lines
356
+ seen.add(idx);
357
+ const indent = text.match(/^\s*/)[0];
358
+ lines[idx] = `${indent}// dssl import dropped (the compiler rejected it: ${e.message}): ${text.trim()}`;
359
+ report.dropped.push({ line: e.line, text: text.trim(), reason: e.message });
360
+ changed = true;
361
+ }
362
+ if (!changed) break;
363
+ dssl = lines.join('\n');
364
+ result = check();
365
+ }
366
+ report.compile = {
367
+ ok: result.errors.length === 0,
368
+ errors: result.errors.map((e) => ({ line: e.line, message: e.message, hint: e.hint })),
369
+ warnings: result.warnings,
370
+ };
371
+ return { dssl, report };
372
+ }
dssl-vscode/lib/engine/lint.js ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: lint.js
3
+ *
4
+ * Warnings for things that compile and run and are still wrong for the people
5
+ * using the page. They fit the language's promise (a mistake should be loud),
6
+ * and they never stop a build unless you ask for `dssl check --strict`.
7
+ *
8
+ * contrast text colour against its own background, in the same rule
9
+ * focus an outline removed from something you can tab to, with nothing in its place
10
+ * target a button or link with an explicit size smaller than a thumb can hit
11
+ *
12
+ * Only unconditional rules are checked. A rule inside @media or @signal might be
13
+ * the dark theme, and judging it against the light tokens would be wrong.
14
+ *
15
+ * Pure: no DOM. Called by resolve() with the rules it just built.
16
+ * ========================================================================= */
17
+
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) {
25
+ if (css === undefined || css === null || depth > 6) return null;
26
+ const s = String(css).trim();
27
+ const direct = parseColor(s);
28
+ if (direct) return direct;
29
+ if (!s.includes('var(')) return null;
30
+ let unresolved = false;
31
+ const expanded = s.replace(/var\(--dssl-([a-z0-9-]+)\)/g, (m, name) => {
32
+ const key = name.replace('-', '.'); // namespace is the first segment: color-code-bg is color.code-bg
33
+ const raw = lookup(key);
34
+ if (raw === undefined) { unresolved = true; return m; }
35
+ const c = evalColorExpr(raw, lookup);
36
+ if (!c) { unresolved = true; return m; }
37
+ return toRgba(c);
38
+ });
39
+ if (unresolved) return null;
40
+ return cssToColor(expanded, depth + 1);
41
+ };
42
+ }
43
+
44
+ const lastDecl = (decls, ...props) => { for (let i = decls.length - 1; i >= 0; i--) if (props.includes(decls[i].prop)) return decls[i]; return null; };
45
+ const pxOf = (v) => { const m = /^(-?[\d.]+)px$/.exec(String(v || '').trim()); return m ? parseFloat(m[1]) : null; };
46
+ const finalCompound = (sel) => String(sel).trim().split(/[\s>+~]+/).pop() || '';
47
+ const INTERACTIVE = /^(a|button|input|select|textarea|summary)\b|\[tabindex|\[role=["']?(button|link|tab|menuitem)|^\*$/;
48
+
49
+ /**
50
+ * @param {object} p { rules, tokenMap, file }
51
+ * @returns {Array} warnings in the compiler's {file, line, message, hint} shape
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 = [];
59
+ const focusBases = new Set();
60
+
61
+ for (const r of rules) {
62
+ if (r.pseudo) continue;
63
+ const conditional = (r.media && r.media.length) || (r.signals && r.signals.length) || (r.containers && r.containers.length);
64
+ const states = r.states || new Set();
65
+ const hasFocus = states.has('focus') || states.has('focus-visible');
66
+
67
+ // ---- contrast
68
+ if (!conditional) {
69
+ const fgD = lastDecl(r.decls, 'color');
70
+ const bgD = lastDecl(r.decls, 'background-color', 'background');
71
+ if (fgD && bgD && typeof fgD.value === 'string' && typeof bgD.value === 'string') {
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;
82
+ const large = size !== null && (size >= 24 || (size >= 18.66 && weight >= 700));
83
+ const need = large ? 3 : 4.5;
84
+ if (ratio < need) {
85
+ warnings.push({
86
+ file, line: fgD.line,
87
+ message: `text contrast is ${ratio.toFixed(1)}:1 (${toHex(fgSolid)} on ${toHex(bg)}), below the ${need}:1 that readers need${large ? ' for large text' : ''}`,
88
+ hint: 'pick a darker or lighter text colour (WCAG AA). Contrast is only checked for a colour and a background written in the same rule',
89
+ });
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ // ---- focus
97
+ if (hasFocus) focusBases.add(r.sel.replace(/:(focus-visible|focus)\b/g, '').trim());
98
+ if (!hasFocus && INTERACTIVE.test(finalCompound(r.sel).replace(/[.#:][\w-]+.*$/, (m) => m.startsWith('[') ? m : '') || finalCompound(r.sel))) {
99
+ for (const d of r.decls) {
100
+ const v = String(d.value).trim();
101
+ const killed = (d.prop === 'outline' && /^(none|0|0px)(\s|$)/.test(v)) || (d.prop === 'outline-style' && v === 'none') || (d.prop === 'outline-width' && /^0(px)?$/.test(v));
102
+ if (killed) { killedFocus.push({ sel: r.sel, line: d.line }); break; }
103
+ }
104
+ }
105
+
106
+ // ---- tap target
107
+ const tag = finalCompound(r.sel);
108
+ if (!conditional && /^(button|a)\b|\[role=["']?button/.test(tag)) {
109
+ for (const dim of ['width', 'height', 'min-width', 'min-height']) {
110
+ const d = lastDecl(r.decls, dim);
111
+ const px = d && pxOf(d.value);
112
+ if (px !== null && px !== undefined && px < 24 && px > 0) {
113
+ warnings.push({
114
+ file, line: d.line,
115
+ message: `${dim}: ${px}px on "${r.sel}" is below 24px, the smallest target WCAG 2.2 allows`,
116
+ hint: 'give buttons and links at least 24px (44px is comfortable for a thumb), or add padding instead of shrinking the box',
117
+ });
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ for (const k of killedFocus) {
125
+ if (focusBases.has(k.sel)) continue;
126
+ warnings.push({
127
+ file, line: k.line,
128
+ message: `the focus outline is removed from "${k.sel}" and nothing replaces it, so keyboard users lose their place`,
129
+ hint: 'add a focus-visible { outline: 2 solid color.accent } block, or `use focus-ring` if you have that behavior',
130
+ });
131
+ }
132
+ return warnings;
133
+ }
dssl-vscode/lib/engine/parser.js CHANGED
@@ -1,6 +1,6 @@
1
  /* =========================================================================
2
- * DSSL — Domain Specific Style Language
3
- * parser.js — tokenizer + parser for the braces syntax
4
  *
5
  * Line-oriented parser:
6
  * - blocks open with `header {` and close with `}`
@@ -18,27 +18,27 @@ export const STATE_WORDS = new Set([
18
  'placeholder-shown', 'read-only', 'read-write', 'enabled',
19
  'indeterminate', 'first-child', 'last-child', 'only-child',
20
  'first-of-type', 'last-of-type', 'link', 'visited',
21
- 'seen', // v0.4.0 — engine-native: fires once when the element enters the viewport
22
  ]);
23
 
24
- // v0.5.0 — exported so the resolver can validate the `::name` half whenever a
25
  // full selector (.card::before) carries a pseudo-element. The resolver splits
26
- // those into element + pseudo and routes them into shadow CSS — previously a
27
  // top-level `.card::before { }` compiled ok and then matched NOTHING.
28
  export const PSEUDO_ELEMENTS = new Set([
29
  'before', 'after', 'placeholder', 'selection', 'marker',
30
  'first-line', 'first-letter', 'backdrop', 'file-selector-button',
31
- // v0.7.2 — ::part() rides the shadow-CSS pipeline (a document stylesheet
32
  // CAN style shadow parts of custom elements). ::slotted() is accepted by
33
  // the parser but rejected by the resolver with a loud explanation: it only
34
  // exists inside a shadow tree, which is not a place DSSL styles.
35
  'part', 'slotted',
36
  ]);
37
 
38
- // v0.4.2 — vendor pseudo-elements pass through verbatim (::-webkit-scrollbar
39
  // and friends). The DOM gives you no other way to reach native scrollbars,
40
  // so they ride the same shadow-CSS exception as ::before/::after. The name
41
- // may carry trailing state (::hover, :horizontal) — it is emitted as-is.
42
  export const VENDOR_PSEUDO = /^-(webkit|moz|ms)-/;
43
 
44
  /** find index of first top-level occurrence of `ch` (skips strings + parens) */
@@ -113,7 +113,7 @@ function stripComments(src) {
113
  if (c === '"' || c === "'") { q = c; out += c; continue; }
114
  if (c === '/' && n === '/') {
115
  // v0.7.1 bugfix: this handler used to emit its own '\n' AND rewind so
116
- // the main loop emitted the same newline again — every // comment
117
  // doubled a line, and every line number after the first comment
118
  // drifted +1 (diagnostics pointed one line late). The newline is now
119
  // emitted exactly once by the main loop.
@@ -156,13 +156,13 @@ export function parse(source, file = 'inline.dssl') {
156
  if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
157
  if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
158
  if (h.startsWith('@')) {
159
- // v0.8.0 — unknown at-rules are skipped WHOLE (balanced braces). They
160
  // used to error and then keep parsing inside their block, so the inner
161
  // rules leaked out unconditionally: `@supports (display:grid) { .a { … } }
162
- // applied .a everywhere. The block is now dropped in its entirety —
163
  // and raw { } is the documented escape hatch for CSS DSSL has no sugar
164
  // for yet (@font-face, @keyframes, @supports, @import…).
165
- err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'the whole block is skipped — for verbatim CSS like @font-face / @keyframes / @supports, wrap it in raw { }');
166
  return { kind: 'bogus' };
167
  }
168
  if (h.startsWith('::')) {
@@ -198,7 +198,7 @@ export function parse(source, file = 'inline.dssl') {
198
 
199
  if (open !== -1) {
200
  const header = rest.slice(0, open).trim();
201
- // v0.7.2 — declarations and a nested block sharing one line:
202
  // `.card { background: red; hover { ... } }`. Everything before the
203
  // header used to be swallowed whole into it, so `background: red`
204
  // silently became part of a selector named "background: red; hover"
@@ -234,9 +234,9 @@ export function parse(source, file = 'inline.dssl') {
234
  }
235
  };
236
 
237
- /** v0.8.0 — what happens right after a block header opens.
238
  * - raw { } (top level): capture the block body VERBATIM as node.css and
239
- * return the remainder of the chunk — or enter multi-line capture mode
240
  * (returns '' with rawCapture armed).
241
  * - bogus (unknown at-rule / pseudo-element): skip the whole balanced
242
  * block so its content can never leak into the parent.
@@ -246,7 +246,7 @@ export function parse(source, file = 'inline.dssl') {
246
  const openBlock = (meta, node, rest, open, line) => {
247
  if (meta.kind === 'raw') {
248
  if (stack.length > 1) {
249
- err(line, 'raw { } must be top-level — it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
250
  const close = matchBrace(rest, open + 1);
251
  return close === -1 ? '' : rest.slice(close + 1);
252
  }
@@ -303,13 +303,13 @@ export function parse(source, file = 'inline.dssl') {
303
  if (!part) continue;
304
  const colon = scanFor(part, ':');
305
  if (colon === -1) {
306
- // `use behavior-name` — keyword declaration with a name argument
307
  const useMatch = part.match(/^use\s+([A-Za-z][\w-]*)$/);
308
  if (useMatch) {
309
  stack[stack.length - 1].children.push({ kind: 'decl', prop: 'use', value: useMatch[1], line });
310
  continue;
311
  }
312
- // valueless semantic property (e.g. `truncate`) — resolver decides if legal
313
  if (/^[a-z][a-z0-9-]*$/i.test(part)) {
314
  stack[stack.length - 1].children.push({ kind: 'decl', prop: part.toLowerCase(), value: '', line });
315
  } else {
@@ -320,9 +320,9 @@ export function parse(source, file = 'inline.dssl') {
320
  const prop = part.slice(0, colon).trim().toLowerCase();
321
  const value = part.slice(colon + 1).trim();
322
  if (!prop) { err(line, 'declaration is missing a property name'); continue; }
323
- // v0.7.0 — numeric token keys. `tokens { space { 9: 96 } }` is legal:
324
  // the built-in spacing scale stops at 8, and real pages need 9, 10, 12…
325
- // Numbers are keys ONLY inside a tokens subtree — anywhere else a bare
326
  // numeric property is a typo, and the error now says exactly that
327
  // instead of the old "not a valid property name" shrug.
328
  if (/^[0-9]+(\.[0-9]+)?$/.test(prop)) {
@@ -331,10 +331,10 @@ export function parse(source, file = 'inline.dssl') {
331
  continue;
332
  }
333
  err(line, `"${prop}" is not a valid property name`,
334
- 'numeric names are token keys only inside tokens { } — e.g. tokens { space { 9: 96 } } — and then referenced as space.9; property names must start with a letter');
335
  continue;
336
  }
337
- // v0.4.0: dotted custom properties are legal — `--color.accent: …` is
338
  // scoped-token sugar (resolver maps it to the engine's token variable)
339
  if (!/^(-{1,2}[a-z][a-z0-9.-]*|[a-z][a-z0-9-]*)$/i.test(prop)) {
340
  err(line, `"${prop}" is not a valid property name`,
@@ -380,7 +380,7 @@ export function parse(source, file = 'inline.dssl') {
380
  });
381
  if (buf.trim()) processChunk(buf, bufLine);
382
  if (rawCapture) {
383
- err(lines.length, 'the raw { } block was never closed with "}"', 'every "{" needs a matching "}" — the CSS captured so far is discarded');
384
  }
385
 
386
  if (stack.length > 1) {
 
1
  /* =========================================================================
2
+ * DSSL - Domain Specific Style Language
3
+ * parser.js - tokenizer + parser for the braces syntax
4
  *
5
  * Line-oriented parser:
6
  * - blocks open with `header {` and close with `}`
 
18
  'placeholder-shown', 'read-only', 'read-write', 'enabled',
19
  'indeterminate', 'first-child', 'last-child', 'only-child',
20
  'first-of-type', 'last-of-type', 'link', 'visited',
21
+ 'seen', // v0.4.0 - engine-native: fires once when the element enters the viewport
22
  ]);
23
 
24
+ // v0.5.0 - exported so the resolver can validate the `::name` half whenever a
25
  // full selector (.card::before) carries a pseudo-element. The resolver splits
26
+ // those into element + pseudo and routes them into shadow CSS - previously a
27
  // top-level `.card::before { }` compiled ok and then matched NOTHING.
28
  export const PSEUDO_ELEMENTS = new Set([
29
  'before', 'after', 'placeholder', 'selection', 'marker',
30
  'first-line', 'first-letter', 'backdrop', 'file-selector-button',
31
+ // v0.7.2 - ::part() rides the shadow-CSS pipeline (a document stylesheet
32
  // CAN style shadow parts of custom elements). ::slotted() is accepted by
33
  // the parser but rejected by the resolver with a loud explanation: it only
34
  // exists inside a shadow tree, which is not a place DSSL styles.
35
  'part', 'slotted',
36
  ]);
37
 
38
+ // v0.4.2 - vendor pseudo-elements pass through verbatim (::-webkit-scrollbar
39
  // and friends). The DOM gives you no other way to reach native scrollbars,
40
  // so they ride the same shadow-CSS exception as ::before/::after. The name
41
+ // may carry trailing state (::hover, :horizontal) - it is emitted as-is.
42
  export const VENDOR_PSEUDO = /^-(webkit|moz|ms)-/;
43
 
44
  /** find index of first top-level occurrence of `ch` (skips strings + parens) */
 
113
  if (c === '"' || c === "'") { q = c; out += c; continue; }
114
  if (c === '/' && n === '/') {
115
  // v0.7.1 bugfix: this handler used to emit its own '\n' AND rewind so
116
+ // the main loop emitted the same newline again - every // comment
117
  // doubled a line, and every line number after the first comment
118
  // drifted +1 (diagnostics pointed one line late). The newline is now
119
  // emitted exactly once by the main loop.
 
156
  if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
157
  if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
158
  if (h.startsWith('@')) {
159
+ // v0.8.0 - unknown at-rules are skipped WHOLE (balanced braces). They
160
  // used to error and then keep parsing inside their block, so the inner
161
  // rules leaked out unconditionally: `@supports (display:grid) { .a { … } }
162
+ // applied .a everywhere. The block is now dropped in its entirety -
163
  // and raw { } is the documented escape hatch for CSS DSSL has no sugar
164
  // for yet (@font-face, @keyframes, @supports, @import…).
165
+ err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'the whole block is skipped - for verbatim CSS like @font-face / @keyframes / @supports, wrap it in raw { }');
166
  return { kind: 'bogus' };
167
  }
168
  if (h.startsWith('::')) {
 
198
 
199
  if (open !== -1) {
200
  const header = rest.slice(0, open).trim();
201
+ // v0.7.2 - declarations and a nested block sharing one line:
202
  // `.card { background: red; hover { ... } }`. Everything before the
203
  // header used to be swallowed whole into it, so `background: red`
204
  // silently became part of a selector named "background: red; hover"
 
234
  }
235
  };
236
 
237
+ /** v0.8.0 - what happens right after a block header opens.
238
  * - raw { } (top level): capture the block body VERBATIM as node.css and
239
+ * return the remainder of the chunk - or enter multi-line capture mode
240
  * (returns '' with rawCapture armed).
241
  * - bogus (unknown at-rule / pseudo-element): skip the whole balanced
242
  * block so its content can never leak into the parent.
 
246
  const openBlock = (meta, node, rest, open, line) => {
247
  if (meta.kind === 'raw') {
248
  if (stack.length > 1) {
249
+ err(line, 'raw { } must be top-level - it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
250
  const close = matchBrace(rest, open + 1);
251
  return close === -1 ? '' : rest.slice(close + 1);
252
  }
 
303
  if (!part) continue;
304
  const colon = scanFor(part, ':');
305
  if (colon === -1) {
306
+ // `use behavior-name` - keyword declaration with a name argument
307
  const useMatch = part.match(/^use\s+([A-Za-z][\w-]*)$/);
308
  if (useMatch) {
309
  stack[stack.length - 1].children.push({ kind: 'decl', prop: 'use', value: useMatch[1], line });
310
  continue;
311
  }
312
+ // valueless semantic property (e.g. `truncate`) - resolver decides if legal
313
  if (/^[a-z][a-z0-9-]*$/i.test(part)) {
314
  stack[stack.length - 1].children.push({ kind: 'decl', prop: part.toLowerCase(), value: '', line });
315
  } else {
 
320
  const prop = part.slice(0, colon).trim().toLowerCase();
321
  const value = part.slice(colon + 1).trim();
322
  if (!prop) { err(line, 'declaration is missing a property name'); continue; }
323
+ // v0.7.0 - numeric token keys. `tokens { space { 9: 96 } }` is legal:
324
  // the built-in spacing scale stops at 8, and real pages need 9, 10, 12…
325
+ // Numbers are keys ONLY inside a tokens subtree - anywhere else a bare
326
  // numeric property is a typo, and the error now says exactly that
327
  // instead of the old "not a valid property name" shrug.
328
  if (/^[0-9]+(\.[0-9]+)?$/.test(prop)) {
 
331
  continue;
332
  }
333
  err(line, `"${prop}" is not a valid property name`,
334
+ 'numeric names are token keys only inside tokens { } - e.g. tokens { space { 9: 96 } } - and then referenced as space.9; property names must start with a letter');
335
  continue;
336
  }
337
+ // v0.4.0: dotted custom properties are legal - `--color.accent: …` is
338
  // scoped-token sugar (resolver maps it to the engine's token variable)
339
  if (!/^(-{1,2}[a-z][a-z0-9.-]*|[a-z][a-z0-9-]*)$/i.test(prop)) {
340
  err(line, `"${prop}" is not a valid property name`,
 
380
  });
381
  if (buf.trim()) processChunk(buf, bufLine);
382
  if (rawCapture) {
383
+ err(lines.length, 'the raw { } block was never closed with "}"', 'every "{" needs a matching "}" - the CSS captured so far is discarded');
384
  }
385
 
386
  if (stack.length > 1) {
dssl-vscode/lib/engine/resolver.js CHANGED
@@ -1,5 +1,5 @@
1
  /* =========================================================================
2
- * DSSL — resolver.js
3
  * Walks the parsed tree and produces flat, engine-ready rules:
4
  *
5
  * rule = {
@@ -7,7 +7,7 @@
7
  * media: [query], // all must match
8
  * signals: [{name, op, value}], // JS-driven conditions
9
  * containers: [query], // container-query conditions
10
- * layer, order, // cascade: (layer, order) — no specificity
11
  * decls: [{prop, value} | special],
12
  * file, line
13
  * }
@@ -18,7 +18,13 @@
18
  * ========================================================================= */
19
 
20
  import { levenshtein, didYouMean, composeDeform } from './utils.js';
21
- // v0.5.0 — the resolver validates the `::name` half of full selectors so a
 
 
 
 
 
 
22
  // typo like .card::befor is a LOUD error, never a silently dead rule.
23
  import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
24
 
@@ -45,7 +51,7 @@ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,
45
  'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
46
  'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
47
  'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
48
- // v0.8.0 — the background repeat/attachment words the url() fix exposed:
49
  // `background: url(a.png) no-repeat` was a false error because "no-repeat"
50
  // was missing while "repeat" existed
51
  'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' +
@@ -63,12 +69,12 @@ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,
63
  'isolate,plaintext,sideways,upright,mixed,ltr,rtl,transform,auto,' +
64
  '-webkit-box,-webkit-inline-box,' +
65
  'thin,thick,medium,separate,collapse,vertical,horizontal,balance,pretty,orphans,' +
66
- // v0.5.0 — hyphens: manual used to be rejected ("unknown value") even though
67
  // the property existed; plus the rendering keywords people actually type.
68
  'manual,pixelated,crisp-edges,optimizeSpeed,optimizeLegibility,geometricPrecision,' +
69
  'flex-start,flex-end,self-start,self-end,snap-block,snap-inline').split(','));
70
 
71
- const UNITLESS_PROPS = new Set([
72
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
73
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
74
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
@@ -77,20 +83,20 @@ const UNITLESS_PROPS = new Set([
77
  'grid-row', 'grid-column', 'grid-area',
78
  ]);
79
 
80
- // v0.7.2 — time properties: a bare number is milliseconds, never px, and
81
  // never unitless (both used to emit values the browser silently rejected).
82
  // transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is
83
  // why `transition-delay: 200` shipped as invalid unitless 200.
84
  const TIME_PROPS = new Set([
85
  'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
86
- // v0.8.0 — the animation shorthand takes bare numbers as ms too:
87
  // `animation: spin 1s infinite` is fine, but `animation: ghost 400` used
88
  // to become "ghost 400px" (invalid). 400 → 400ms, matching animate:.
89
  'animation',
90
  ]);
91
 
92
  // A practical whitelist of standard CSS properties (loud errors beat silent ones)
93
- const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
94
  'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
95
  'block-size,border,border-block,border-block-color,border-block-end,border-block-end-color,border-block-end-style,border-block-end-width,border-block-start,border-block-start-color,border-block-start-style,border-block-start-width,border-block-style,border-block-width,' +
96
  'border-bottom,border-bottom-color,border-bottom-left-radius,border-bottom-right-radius,border-bottom-style,border-bottom-width,' +
@@ -129,7 +135,7 @@ export const DEFAULT_TOKENS = {
129
  'font.mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
130
  };
131
 
132
- const EASINGS = {
133
  snap: 'cubic-bezier(0.2, 0.9, 0.3, 1)',
134
  smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
135
  bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
@@ -153,7 +159,7 @@ const PLACE_WORDS = {
153
 
154
  const IDENT_RE = /^-?(?:[A-Za-z_]|--[A-Za-z_])[A-Za-z0-9_.\\-]*/;
155
 
156
- // functions whose numeric arguments are NOT lengths — numbers stay bare
157
  const UNITLESS_FN = new Set(('oklch,oklab,lab,lch,hsl,hsla,rgb,rgba,hwb,color,color-mix,light-dark,' +
158
  'brightness,saturate,contrast,grayscale,sepia,invert,opacity,' +
159
  'scale,scalex,scaley,scalez,scale3d,steps,cubic-bezier,linear,radial,conic').split(','));
@@ -166,6 +172,19 @@ const LENGTH_FN = new Set('calc,min,max,clamp,round,mod,rem,abs,sign,hypot,pow,s
166
  * - token references become var(--dssl-…), `token/NN` becomes color-mix
167
  * - unknown identifiers are errors (with did-you-mean)
168
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  function resolveValue(raw, ctx) {
170
  let out = '';
171
  let i = 0;
@@ -210,7 +229,7 @@ function resolveValue(raw, ctx) {
210
 
211
  if (ident.startsWith('--')) { out += ident; continue; } // custom property
212
 
213
- // function call — resolve the inside
214
  if (raw[i] === '(') {
215
  let depth = 1, j = i + 1;
216
  while (j < n && depth > 0) {
@@ -224,7 +243,7 @@ function resolveValue(raw, ctx) {
224
  }
225
  const inner = raw.slice(i + 1, j - 1);
226
  const fname = ident.toLowerCase();
227
- // v0.8.0 — url() is atomic. The validator used to tokenize INSIDE it,
228
  // so `url(a.png)` raised "unknown value a.png" (quoted urls worked
229
  // only because strings were already consumed whole). CSS allows the
230
  // unquoted form; DSSL passes it through untouched now.
@@ -233,7 +252,7 @@ function resolveValue(raw, ctx) {
233
  i = j;
234
  continue;
235
  }
236
- // v0.3.2 fix: repeat(COUNT, tracks) — the leading count is an integer,
237
  // never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
238
  // which the browser rejected and the whole grid silently collapsed.
239
  if (fname === 'repeat') {
@@ -243,7 +262,7 @@ function resolveValue(raw, ctx) {
243
  i = j;
244
  continue;
245
  }
246
- // auto-fill / auto-fit / nested weirdness — fall through to generic
247
  }
248
  // color-ish functions keep bare numbers bare; calc-family gets px
249
  const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
@@ -274,7 +293,7 @@ function resolveValue(raw, ctx) {
274
  continue;
275
  }
276
 
277
- // v0.5.0 — font names are proper nouns: JetBrains Mono, IBM Plex Serif.
278
  // In font-family values and font.* tokens, bare multi-word names pass
279
  // through instead of demanding quotes. Everywhere else stays loud.
280
  if (ctx.bareIdents && /^[A-Za-z][A-Za-z0-9_-]*$/.test(ident)) {
@@ -295,8 +314,8 @@ function resolveValue(raw, ctx) {
295
  const hex = raw.slice(i, j);
296
  out += hex;
297
  i = j;
298
- // v0.7.2 — alpha on a raw hex color: #ff0000/50 used to become
299
- // #ff0000/50px (the bare number got the generic px treatment) — invalid
300
  // CSS, silently dropped by the browser. Tokens always had /NN via
301
  // color-mix; raw hex now gets the same treatment.
302
  const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
@@ -312,7 +331,7 @@ function resolveValue(raw, ctx) {
312
  return out;
313
  }
314
 
315
- /** paren-aware split on the `->` arrow — gradient stops use it as a separator */
316
  function splitArrow(s) {
317
  const out = [];
318
  let cur = '', depth = 0, q = null;
@@ -329,7 +348,7 @@ function splitArrow(s) {
329
  return out;
330
  }
331
 
332
- /** split on a top-level separator (parens-aware) — commas inside fn() survive */
333
  function topSplit(str, sep) {
334
  const out = [];
335
  let cur = '', depth = 0;
@@ -345,12 +364,12 @@ function topSplit(str, sep) {
345
 
346
  function findToken(tokens, ident) {
347
  if (tokens.has(ident)) return ident;
348
- // try progressive dot paths (a.b.c defined, user typed a.b.c — exact only, plus flat fallback)
349
  if (ident.includes('.')) {
350
  const parts = ident.split('.');
351
  for (let k = parts.length - 1; k > 0; k--) {
352
  const key = parts.slice(0, k).join('.');
353
- if (tokens.has(key)) return key; // hmm — partial match would change meaning; require exact
354
  }
355
  }
356
  return null;
@@ -363,7 +382,7 @@ function findToken(tokens, ident) {
363
  const len = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: false });
364
  const num = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: true });
365
 
366
- // v0.6.0 — the deform family speaks degrees, not lengths: bare numbers are
367
  // angles and `20deg` is accepted for people with CSS muscle memory.
368
  const degNum = (raw, what, ctx) => {
369
  const t = String(raw).trim();
@@ -372,16 +391,16 @@ const degNum = (raw, what, ctx) => {
372
  ctx.errors.push({
373
  file: ctx.file, line: ctx.line,
374
  message: `${what} takes a bare number of degrees`,
375
- hint: `try ${what}: 20 or ${what}: -12 — bare numbers are degrees here`,
376
  });
377
  return null;
378
  }
379
  return parseFloat(m[1]);
380
  };
381
 
382
- /** v0.6.0 — shared grammar for gradient / gradient-text: kind · prefix · stops.
383
  * Stops may be arrow-joined (`a -> b -> c`) or comma-joined (`linear 135, a, b`);
384
- * the prefix is whatever the grammar calls direction/shape — everything else is a stop. */
385
  // Stops are colors by construction, so a bare word that is not a color name is
386
  // a typo the browser would only mutter about at apply time. Say it NOW instead.
387
  const COLOR_WORDS = new Set(('black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
@@ -431,7 +450,7 @@ function buildGradient(v, ctx) {
431
  ctx.errors.push({
432
  file: ctx.file, line: ctx.line,
433
  message: `gradient needs at least two stops`,
434
- hint: 'try gradient: linear 135, #ff0055 -> #00d4ff — commas work too: linear 135, #ff0055, #00d4ff',
435
  });
436
  return null;
437
  }
@@ -472,7 +491,7 @@ function buildGradient(v, ctx) {
472
  ctx.errors.push({
473
  file: ctx.file, line: ctx.line,
474
  message: `"${head}" is not a conic gradient prefix`,
475
- hint: 'use from <angle> and/or at <position> — try conic from 45 at center, …',
476
  });
477
  return null;
478
  }
@@ -504,9 +523,9 @@ function buildGradient(v, ctx) {
504
 
505
  function parseFlexWords(value, ctx, dir) {
506
  const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
507
- // v0.8.0 — axis roles. Every place word used to write BOTH axes, so
508
  // `row: gap 8 between center` (the guide's own example) compiled to
509
- // justify-content: space-between followed by justify-content: center —
510
  // and center won, bunching everything in the middle. Words now declare
511
  // the axis they are actually about: distribution words (between/around/
512
  // evenly) own the main axis, stretch/baseline own the cross axis, and
@@ -537,8 +556,8 @@ function parseFlexWords(value, ctx, dir) {
537
  const axisWarn = (prop, prev) => {
538
  ctx.warnings = ctx.warnings || [];
539
  ctx.warnings.push(prev === a
540
- ? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir} — the second copy wins`, hint: `drop the duplicate; one distribution word + one alignment word is the whole grammar` }
541
- : { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win — drop one, or reorder so the distribution word comes first` });
542
  };
543
  let wrote = false;
544
  if (role === 'main' || (role === 'both' && !mainSet)) {
@@ -556,9 +575,9 @@ function parseFlexWords(value, ctx, dir) {
556
  ctx.warnings.push({
557
  file: ctx.file, line: ctx.line,
558
  message: mainSet === a || crossSet === a
559
- ? `"${a}" is written twice in ${dir} — both axes already came from it`
560
  : `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`,
561
- hint: 'one distribution word + one alignment word is the whole grammar — the extra word is dropped',
562
  });
563
  }
564
  continue;
@@ -568,7 +587,7 @@ function parseFlexWords(value, ctx, dir) {
568
  return out;
569
  }
570
 
571
- // v0.6.0 — deform members all land in the transform channel, so asking for
572
  // `transition: 200 snap bend` should just work and mean transform.
573
  const DEFORM_TO_TRANSFORM = {
574
  bend: 'transform', 'bend-y': 'transform', 'bend-up': 'transform',
@@ -586,7 +605,7 @@ const SEMANTIC = {
586
  column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
587
  grid(v, ctx) {
588
  const atoms = v.split(/\s+/).filter(Boolean);
589
- // v0.7.2 — `grid: 3 gap 12` (the documented form) used to die: the gap
590
  // atoms were fed into grid-template-columns → "3px gap 12px", invalid.
591
  // gap and rows are now first-class words of the shorthand.
592
  let gap = null, rows = null;
@@ -598,7 +617,7 @@ const SEMANTIC = {
598
  }
599
  let cols;
600
  if (!rest.length) {
601
- ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'grid needs columns', hint: 'grid: 3 · grid: auto-fill 200 · grid: 200px 1fr — optionally with gap N and rows N' });
602
  cols = 'none';
603
  } else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) {
604
  cols = `repeat(${rest[0]}, 1fr)`;
@@ -629,7 +648,7 @@ const SEMANTIC = {
629
  }
630
  return [{ prop: 'place-items', value: `${p[0]} ${p[1]}` }, { prop: 'place-content', value: `${p[0]} ${p[1]}` }];
631
  },
632
- // v0.3.4 — the one-word centering primitive, because "how do I center this
633
  // thing" is the first question everyone asks. Emits the alignment pair only;
634
  // the ENGINE guarantees a formatting context for alignment declarations
635
  // (inline elements → inline-grid, blocks → grid), so `center:` can never
@@ -716,7 +735,7 @@ const SEMANTIC = {
716
  { prop: 'white-space', value: 'nowrap' },
717
  ];
718
  },
719
- // v0.4.0 — the accessibility classic: visually hidden, still announced by
720
  // screen readers. One word instead of nine lines of clip folklore.
721
  'visually-hidden'() {
722
  return [
@@ -731,7 +750,7 @@ const SEMANTIC = {
731
  { prop: 'border', value: '0' },
732
  ];
733
  },
734
- // v0.4.0 — transition grew per-property specs. Each comma part is
735
  // duration [easing] [property] (property omitted → all)
736
  // The one-value form keeps working exactly as before:
737
  // transition: 200 snap
@@ -745,7 +764,7 @@ const SEMANTIC = {
745
  const atoms = part.split(/\s+/);
746
  const dur = atoms[0];
747
  if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
748
- // v0.8.0 — the CSS habit is property-first (`transition: color .2s
749
  // ease`). That used to be a generic "not a time" error and the part
750
  // silently dropped. The error now REWRITES the value into the DSSL
751
  // order (time · easing · property) so the fix is a copy-paste.
@@ -757,11 +776,11 @@ const SEMANTIC = {
757
  ctx.errors.push({
758
  file: ctx.file, line: ctx.line,
759
  message: `transition: the CSS form (property first) is not DSSL`,
760
- hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''} — time first, easing, property last`,
761
  });
762
  continue;
763
  }
764
- ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration · easing · property — try 200 or 0.2s' });
765
  continue;
766
  }
767
  const ms = /^\d+$/.test(dur) ? dur + 'ms' : dur;
@@ -801,15 +820,15 @@ const SEMANTIC = {
801
  }
802
  if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
803
  if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
804
- // v0.5.0 — looping: `infinite` runs forever, `alternate` ping-pongs.
805
- // animate: pulse 1600 infinite alternate — was impossible before.
806
  if (a === 'infinite') { spec.iterations = Infinity; continue; }
807
  if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
808
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate'])) });
809
  }
810
  return [{ prop: '@animate', value: spec }];
811
  },
812
- // v0.4.0 — choreography: a parent with `stagger: 60` offsets each element
813
  // child's animation delay by its DOM position. Pairs with `seen` + `animate`
814
  // on the children: grids and lists cascade in as they scroll into view.
815
  stagger(v, ctx) {
@@ -822,7 +841,7 @@ const SEMANTIC = {
822
  const ms = t.endsWith('s') && !t.endsWith('ms') ? parseFloat(t) * 1000 : parseFloat(t);
823
  return [{ prop: '@stagger', value: ms }];
824
  },
825
- // v0.5.0 — overlap policy. CSS can stack transparent things all day and say
826
  // nothing; DSSL refuses. `deny` makes the element always yield out of any
827
  // overlap; `auto` yields only while the element sits below 100% opacity.
828
  // Both inherit down the tree, so `body { overlap: auto }` is the site-wide
@@ -842,7 +861,7 @@ const SEMANTIC = {
842
  },
843
 
844
  /* ---------------------------------------------------------------- *
845
- * v0.6.0 — the deform family: bend, bend-y, bend-up, fold, stretch,
846
  * bend-around. All are engine-native (@-props) that compose into the
847
  * transform channel, so `transition: 200 snap transform` tweens them
848
  * and `:active { bend: 14 }` makes a button visibly flex under the
@@ -859,7 +878,7 @@ const SEMANTIC = {
859
  if (Math.abs(n) > 170) {
860
  ctx.errors.push({
861
  file: ctx.file, line: ctx.line,
862
- message: `bend-around caps at ±170° — past that the arc folds back on itself`,
863
  hint: 'try 30..120 for a visible arc',
864
  });
865
  return [];
@@ -882,7 +901,7 @@ const SEMANTIC = {
882
  ctx.errors.push({
883
  file: ctx.file, line: ctx.line,
884
  message: `stretch: ${m[1]} would flatten the element to nothing`,
885
- hint: 'use a factor above 0 — 1.2 is a gentle pull, 2 is cartoonish',
886
  });
887
  return [];
888
  }
@@ -891,11 +910,11 @@ const SEMANTIC = {
891
  return [{ prop: '@stretch', value: { factor: f, axis } }];
892
  },
893
 
894
- /* v0.6.0 — gradient tools. `gradient` replaces the CSS function soup with
895
  * kind · prefix · stop -> stop; stops may be colors, tokens, alpha mixes
896
  * (ink/40) and carry a trailing % position. gradient-text clips the same
897
  * gradient into the glyphs. gradient-flow loops background-position so a
898
- * gradient drifts forever — one line instead of keyframe folklore. */
899
  gradient(v, ctx) {
900
  const img = buildGradient(v, ctx);
901
  return img ? [{ prop: 'background-image', value: img }] : [];
@@ -940,10 +959,10 @@ const SEMANTIC = {
940
  },
941
  };
942
 
943
- /* v0.8.0 — fluid sizing sugar. `fluid: 16, 24` keeps working (font-size),
944
  * and now any length-ish property takes it inline: `font-size: fluid 16 24`.
945
  * Both compile to a clamp() that scales linearly between the viewport bounds
946
- * (360px and 1200px by default) — the formula everyone writes by hand and
947
  * gets wrong. */
948
  const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' +
949
  'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left,' +
@@ -989,14 +1008,59 @@ function buildFluidParts(parts, ctx, prop = 'font-size') {
989
  }
990
 
991
  /* ------------------------------------------------------------------ *
992
- * resolveDecl — one declaration → zero or more engine decls
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
993
  * ------------------------------------------------------------------ */
994
 
995
  function resolveDecl(prop, value, ctx) {
996
  const errors = ctx.errors;
997
  const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings };
998
 
999
- // v0.8.0 — !important does not exist in DSSL. It used to fall through as a
1000
  // value: the compiler said "unknown value important" AND still emitted
1001
  // `red !important`, which the browser then silently dropped as an invalid
1002
  // inline value. Now: one loud error, nothing emitted, and the pin { }
@@ -1004,14 +1068,14 @@ function resolveDecl(prop, value, ctx) {
1004
  if (hasImportant(value)) {
1005
  errors.push({
1006
  file: ctx.file, line: ctx.line,
1007
- message: `!important does not exist in DSSL — "${prop}: ${String(value).replace(/\s*!\s*important\s*/i, '')}" was NOT applied`,
1008
- hint: 'the cascade is (layer, order) and later wins — put the rule in pin { } when it genuinely must win',
1009
  });
1010
  return [];
1011
  }
1012
 
1013
  if (prop.startsWith('--')) {
1014
- // v0.4.0 — scoped tokens. Dotted names are token re-declarations:
1015
  // .danger { --color.accent: color.err }
1016
  // sets --dssl-color-accent ON the element, and custom properties inherit,
1017
  // so every var() reference in the subtree re-resolves. Plain names
@@ -1025,7 +1089,7 @@ function resolveDecl(prop, value, ctx) {
1025
  const sem = SEMANTIC[prop];
1026
  if (sem) return sem(value, ctx2);
1027
 
1028
- // v0.8.0 — the animation shorthand passes through (it is legal CSS), but
1029
  // bare motion names must not pay the generic unknown-value tax: "spin"
1030
  // used to error with the surreal suggestion "did you mean span?". Motion
1031
  // names pass silently here and a dedicated warning in the final loop says
@@ -1034,7 +1098,7 @@ function resolveDecl(prop, value, ctx) {
1034
  return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }];
1035
  }
1036
 
1037
- // v0.8.0 — inline fluid sugar on any length-ish property:
1038
  // font-size: fluid 16 24 → clamp(16px, calc(16px + 0.952vw), 24px)
1039
  const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim());
1040
  if (fluidM && FLUID_PROPS.has(prop)) {
@@ -1052,7 +1116,7 @@ function resolveDecl(prop, value, ctx) {
1052
  return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
1053
  }
1054
 
1055
- /** string-aware !important scan — quoted content: "!important" must not trip it */
1056
  function hasImportant(v) {
1057
  const s = String(v);
1058
  let q = null;
@@ -1115,11 +1179,11 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1115
  const errors = [];
1116
  const rules = [];
1117
  const shadowRules = [];
1118
- const raws = []; // v0.8.0 — raw { } / @css { } verbatim passthrough blocks
1119
  const motions = {};
1120
  const behaviors = {};
1121
- // v0.7.0 — compile-time warnings: things that compile and run but are
1122
- // probably not what the author meant. Separate from errors — ok stays ok —
1123
  // but the engine prints them loudly so nobody debugs cascade order by hand
1124
  // ever again.
1125
  const warnings = [];
@@ -1155,7 +1219,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1155
  } else if (node.kind === 'motion') {
1156
  motions[node.name] = node;
1157
  } else {
1158
- // v0.8.0 — defensive `|| []`: a malformed tree can throw from here
1159
  // and take the whole stylesheet down (the @font-face crash report).
1160
  // The parser now skips unknown at-rules whole, but the guard stays.
1161
  for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]);
@@ -1194,7 +1258,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1194
  continue;
1195
  }
1196
  const props = {};
1197
- const deform = {}; // v0.6.0 — bend/stretch/fold in keyframes compose into transform
1198
  const DEFORM_KEY = { '@bend': 'bend', '@bend-y': 'bendY', '@fold': 'fold', '@bend-up': 'bendUp' };
1199
  for (const d of child.children) {
1200
  if (d.kind !== 'decl') continue;
@@ -1216,7 +1280,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1216
  deform.fold !== undefined || deform.bendUp !== undefined) {
1217
  // no box exists at resolve time, so edge-pinned members ride a
1218
  // transformOrigin keyframe property instead of baked translates.
1219
- // Presence (not truthiness) — `bend: 0` in a frame is a real reset.
1220
  const c = composeDeform(deform, props.transform || '', null);
1221
  props.transform = c.transform;
1222
  if (c.origin) props.transformOrigin = c.origin;
@@ -1238,7 +1302,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1238
  for (const key of keys) {
1239
  const raw = tokenMap.get(key);
1240
  const prop = '--dssl-' + key.replace(/\./g, '-');
1241
- // font.* tokens hold font stacks — proper nouns, not keywords (v0.5.0)
1242
  decls.push({ prop, value: resolveValue(raw, { ...ctxBase, prop: key, bareIdents: /^font/.test(key) }) });
1243
  }
1244
  if (!decls.length) return null;
@@ -1257,11 +1321,11 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1257
  /* ---- main recursive walk ---- */
1258
 
1259
  const walk = (children, ctx, ctxRule) => {
1260
- // v0.7.2 — parent-first flush. Two real bugs lived here. (1) A nested
1261
  // block (hover { }, @media, a nested selector) was emitted BEFORE the
1262
  // parent's own declarations got their cascade order, so `background: red`
1263
  // on the parent always beat `hover { background: red }` nested inside it
1264
- // — the exact pattern the docs taught, and the external review's headline
1265
  // finding. (2) media/container/signal/layer blocks flushed with the
1266
  // PARENT context, dropping the condition or layer entirely: a nested
1267
  // `@media dark` was silently unconditional AND sorted before base.
@@ -1291,7 +1355,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1291
  }
1292
  const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
1293
  for (const r of resolved) {
1294
- // v0.7.0 — remember WHICH source property wrote a display value,
1295
  // so the ordering linter can tell an explicit "display:" from a
1296
  // shorthand that expanded into one (column: → display: flex).
1297
  if (r.prop === 'display' && node.prop !== 'display') r.via = node.prop;
@@ -1302,7 +1366,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1302
  case 'selector': {
1303
  if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) {
1304
  const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]);
1305
- if (near) warnings.push({ file, line: node.line, message: `"${node.sel}" compiled as a descendant selector ("${combine(ctx.sel, node.sel)}")`, hint: `if you meant the state, it is spelled "${near}" — this block currently styles descendants called <${node.sel}>` });
1306
  }
1307
  flushPending(node.line);
1308
  const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
@@ -1319,14 +1383,14 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1319
  }
1320
  flushPending(node.line);
1321
  const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
1322
- // v0.7.2 — the arrow is motion/gradient-stop syntax; inside a seen
1323
  // block it compiled into an inline style the browser rejects, so the
1324
  // element just sat at its base style forever. Say so, loudly, and
1325
  // point at the two real patterns (final value, or a motion).
1326
  if (node.name === 'seen') {
1327
  for (const d of node.children) {
1328
  if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) {
1329
- errors.push({ file, line: d.line, message: `"${d.prop}: ${d.value}" — the -> arrow is motion syntax and does nothing inside seen { }`, hint: 'write the final style (seen { opacity: 1 }) or attach a motion (seen { animate: rise-in 400 })' });
1330
  }
1331
  }
1332
  }
@@ -1383,13 +1447,13 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1383
  if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1384
  if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1385
  const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1386
- // v0.5.0 — engine-native properties have no pseudo-element story:
1387
  // there is no DOM handle to nudge, animate, stack or stagger. They
1388
  // used to leak into the shadow sheet as junk CSS the browser
1389
- // silently dropped — exactly the silence this language refuses.
1390
  for (const r of resolved) {
1391
  if (r.prop.startsWith('@')) {
1392
- errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name} — a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo block' });
1393
  continue;
1394
  }
1395
  srule.decls.push({ ...r, line: d.line });
@@ -1409,7 +1473,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1409
  break;
1410
  }
1411
  case 'raw': {
1412
- // v0.8.0 — verbatim CSS escape hatch (@font-face, @keyframes,
1413
  // @supports, @import, @property…). The parser captured the body
1414
  // untouched; it rides the same document stylesheet as the
1415
  // pseudo-element rules and survives `dssl build` verbatim.
@@ -1445,23 +1509,23 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1445
  function flush(sub, ctx, line) {
1446
  if (!sub.decls.length) return;
1447
  if (!ctx.sel) {
1448
- errors.push({ file, line, message: 'declarations must live inside a selector', hint: 'top-level blocks are tokens, behavior, motion, @layer, @media… — or a selector like .card' });
1449
  return;
1450
  }
1451
- // v0.5.0 — the pseudo-element trap fix. A full selector like `.card::before`
1452
  // used to compile ok and then match NOTHING: inline styles can never reach
1453
  // pseudo-elements. Now the selector is split into element + pseudo halves
1454
- // and the rule rides the shadow-CSS pipeline — exactly what the nested
1455
  // form (`.card { ::before { } }`) always did. Both forms are now one thing.
1456
  const ps = analyzePseudo(ctx.sel);
1457
  if (ps.mode === 'all') {
1458
  // parity with the nested path: bare `content:` becomes content: ""
1459
- // v0.5.0 — and engine-native properties fail loud here too (see the
1460
  // nested pseudo path): no DOM handle means no nudge, animate or stack.
1461
  const decls = [];
1462
  for (const d of sub.decls) {
1463
  if (d.prop && d.prop.startsWith('@')) {
1464
- errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]} — a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo rule' });
1465
  continue;
1466
  }
1467
  decls.push(d.prop === 'content' && d.value === '' ? { ...d, value: '""' } : d);
@@ -1480,7 +1544,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1480
  errors.push({
1481
  file, line,
1482
  message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM',
1483
- hint: 'style the slotted element itself with a normal selector — it IS reachable from the page',
1484
  });
1485
  continue;
1486
  }
@@ -1492,7 +1556,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1492
  });
1493
  continue;
1494
  }
1495
- // v0.5.0 — parity with the nested path: if every declaration was
1496
  // rejected (engine-native props, bad values), emit NOTHING rather
1497
  // than an empty ghost rule that matches and styles zero things.
1498
  if (!decls.length) continue;
@@ -1505,9 +1569,25 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1505
  return;
1506
  }
1507
  if (ps.mode === 'mixed') {
1508
- errors.push({ file, line, message: `selector list mixes pseudo-element and plain selectors: "${ctx.sel}"`, hint: 'split it into two rules — one for the pseudo-element, one for the rest' });
1509
  return;
1510
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1511
  warnDisplayClash(sub.decls);
1512
  rules.push({
1513
  id: ruleId++, sel: ctx.sel, media: ctx.media, signals: ctx.signals,
@@ -1517,15 +1597,15 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1517
  });
1518
  }
1519
 
1520
- /* v0.7.0 — the ordering linter. Within one rule, later wins; that is the
1521
  * whole cascade model. But "column: gap 2" quietly expanding into
1522
  * display: flex after an explicit "display: none" reads like a menu
1523
- * toggle and behaves like a bug report — a real reviewer lost an evening
1524
  * to exactly this ("their own source comments warn about this, but it
1525
  * should be a compiler warning, not a comment"). Agreed. When a
1526
  * shorthand's expansion clobbers an explicit display written earlier in
1527
  * the SAME rule, say so at compile time. Cross-rule overrides are the
1528
- * documented cascade at work, not an accident — no warning there. */
1529
  function warnDisplayClash(decls) {
1530
  let explicit = null;
1531
  for (const d of decls) {
@@ -1534,7 +1614,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1534
  if (explicit && explicit.value !== d.value && warnings.length < 400) {
1535
  warnings.push({
1536
  file, line: d.line,
1537
- message: `"${d.via}:" expands to display: ${d.value}, which overrides the earlier "display: ${explicit.value}" (line ${explicit.line}) in the same rule — later wins, so the earlier one is dead`,
1538
  hint: `if the override is intended, delete the dead line; otherwise put "${d.via}:" first, or move the display you want to win AFTER it`,
1539
  });
1540
  }
@@ -1557,7 +1637,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1557
  errors.push({
1558
  file, line: d.line,
1559
  message: `unknown motion "${d.value.motion}"`,
1560
- hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } } — then "animate: ${d.value.motion} 400" runs it`,
1561
  });
1562
  d.value.broken = true;
1563
  } else {
@@ -1565,7 +1645,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1565
  d.value.essential = m.essential;
1566
  }
1567
  }
1568
- // v0.8.0 — the animation shorthand is CSS passthrough, so a motion
1569
  // name that matches nothing used to be a SILENT no-op (no @keyframes
1570
  // can exist in DSSL). Warn with the whole story; if the keyframes come
1571
  // from raw { } or another stylesheet, the warning says it can be ignored.
@@ -1575,15 +1655,15 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1575
  if (words.length && !named) {
1576
  warnings.push({
1577
  file, line: d.line,
1578
- message: `animation "${d.value}" references no motion defined in this sheet — it will not run`,
1579
- hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400 — @keyframes does not exist in DSSL; raw { } can carry verbatim keyframes`,
1580
  });
1581
  }
1582
  }
1583
  }
1584
  }
1585
 
1586
- // v0.8.0 — pre-parse EVERY motion (not just referenced ones) so the static
1587
  // emitter can turn the whole motion vocabulary into real @keyframes.
1588
  const motionFrames = {};
1589
  for (const name of Object.keys(motions)) {
@@ -1591,6 +1671,9 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
1591
  if (m && m.frames.length) motionFrames[name] = m;
1592
  }
1593
 
 
 
 
1594
  const mediaQueries = new Set();
1595
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1596
 
@@ -1603,10 +1686,10 @@ function combine(parent, child) {
1603
  return parent + ' ' + child;
1604
  }
1605
 
1606
- /* v0.5.0 — where does a selector carry a pseudo-element?
1607
- * 'none' — plain selector, inline-style path
1608
- * 'all' — every comma-part has one: { parts: [{ el, pseudo }] } → shadow CSS
1609
- * 'mixed' — some parts have one, some don't → loud error (split the list)
1610
  * The `::` scan is string- and paren-aware so [data-x="::a"] can't false-hit. */
1611
  function analyzePseudo(sel) {
1612
  // top-level comma split
 
1
  /* =========================================================================
2
+ * DSSL - resolver.js
3
  * Walks the parsed tree and produces flat, engine-ready rules:
4
  *
5
  * rule = {
 
7
  * media: [query], // all must match
8
  * signals: [{name, op, value}], // JS-driven conditions
9
  * containers: [query], // container-query conditions
10
+ * layer, order, // cascade: (layer, order) - no specificity
11
  * decls: [{prop, value} | special],
12
  * file, line
13
  * }
 
18
  * ========================================================================= */
19
 
20
  import { levenshtein, didYouMean, composeDeform } from './utils.js';
21
+ // 0.9.0: the three new styling families. Each is a pure module, so the compiler
22
+ // can validate their words (with did-you-mean) without a DOM.
23
+ import { parseTexture } from './texture.js';
24
+ import { parseAura } from './aura.js';
25
+ import { parseSlider, sliderCss } from './slider.js';
26
+ import { lintRules } from './lint.js';
27
+ // v0.5.0 - the resolver validates the `::name` half of full selectors so a
28
  // typo like .card::befor is a LOUD error, never a silently dead rule.
29
  import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
30
 
 
51
  'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
52
  'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
53
  'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
54
+ // v0.8.0 - the background repeat/attachment words the url() fix exposed:
55
  // `background: url(a.png) no-repeat` was a false error because "no-repeat"
56
  // was missing while "repeat" existed
57
  'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' +
 
69
  'isolate,plaintext,sideways,upright,mixed,ltr,rtl,transform,auto,' +
70
  '-webkit-box,-webkit-inline-box,' +
71
  'thin,thick,medium,separate,collapse,vertical,horizontal,balance,pretty,orphans,' +
72
+ // v0.5.0 - hyphens: manual used to be rejected ("unknown value") even though
73
  // the property existed; plus the rendering keywords people actually type.
74
  'manual,pixelated,crisp-edges,optimizeSpeed,optimizeLegibility,geometricPrecision,' +
75
  'flex-start,flex-end,self-start,self-end,snap-block,snap-inline').split(','));
76
 
77
+ export const UNITLESS_PROPS = new Set([
78
  'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
79
  'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
80
  'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
 
83
  'grid-row', 'grid-column', 'grid-area',
84
  ]);
85
 
86
+ // v0.7.2 - time properties: a bare number is milliseconds, never px, and
87
  // never unitless (both used to emit values the browser silently rejected).
88
  // transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is
89
  // why `transition-delay: 200` shipped as invalid unitless 200.
90
  const TIME_PROPS = new Set([
91
  'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
92
+ // v0.8.0 - the animation shorthand takes bare numbers as ms too:
93
  // `animation: spin 1s infinite` is fine, but `animation: ghost 400` used
94
  // to become "ghost 400px" (invalid). 400 → 400ms, matching animate:.
95
  'animation',
96
  ]);
97
 
98
  // A practical whitelist of standard CSS properties (loud errors beat silent ones)
99
+ export const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
100
  'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
101
  'block-size,border,border-block,border-block-color,border-block-end,border-block-end-color,border-block-end-style,border-block-end-width,border-block-start,border-block-start-color,border-block-start-style,border-block-start-width,border-block-style,border-block-width,' +
102
  'border-bottom,border-bottom-color,border-bottom-left-radius,border-bottom-right-radius,border-bottom-style,border-bottom-width,' +
 
135
  'font.mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
136
  };
137
 
138
+ export const EASINGS = {
139
  snap: 'cubic-bezier(0.2, 0.9, 0.3, 1)',
140
  smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
141
  bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
 
159
 
160
  const IDENT_RE = /^-?(?:[A-Za-z_]|--[A-Za-z_])[A-Za-z0-9_.\\-]*/;
161
 
162
+ // functions whose numeric arguments are NOT lengths - numbers stay bare
163
  const UNITLESS_FN = new Set(('oklch,oklab,lab,lch,hsl,hsla,rgb,rgba,hwb,color,color-mix,light-dark,' +
164
  'brightness,saturate,contrast,grayscale,sepia,invert,opacity,' +
165
  'scale,scalex,scaley,scalez,scale3d,steps,cubic-bezier,linear,radial,conic').split(','));
 
172
  * - token references become var(--dssl-…), `token/NN` becomes color-mix
173
  * - unknown identifiers are errors (with did-you-mean)
174
  */
175
+ /** split a selector list at top-level commas (not inside :is(...) and friends) */
176
+ function splitTopLevel(sel) {
177
+ const out = []; let depth = 0, cur = '';
178
+ for (const ch of String(sel)) {
179
+ if (ch === '(' || ch === '[') depth++;
180
+ if (ch === ')' || ch === ']') depth--;
181
+ if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
182
+ cur += ch;
183
+ }
184
+ if (cur.trim()) out.push(cur.trim());
185
+ return out;
186
+ }
187
+
188
  function resolveValue(raw, ctx) {
189
  let out = '';
190
  let i = 0;
 
229
 
230
  if (ident.startsWith('--')) { out += ident; continue; } // custom property
231
 
232
+ // function call - resolve the inside
233
  if (raw[i] === '(') {
234
  let depth = 1, j = i + 1;
235
  while (j < n && depth > 0) {
 
243
  }
244
  const inner = raw.slice(i + 1, j - 1);
245
  const fname = ident.toLowerCase();
246
+ // v0.8.0 - url() is atomic. The validator used to tokenize INSIDE it,
247
  // so `url(a.png)` raised "unknown value a.png" (quoted urls worked
248
  // only because strings were already consumed whole). CSS allows the
249
  // unquoted form; DSSL passes it through untouched now.
 
252
  i = j;
253
  continue;
254
  }
255
+ // v0.3.2 fix: repeat(COUNT, tracks) - the leading count is an integer,
256
  // never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
257
  // which the browser rejected and the whole grid silently collapsed.
258
  if (fname === 'repeat') {
 
262
  i = j;
263
  continue;
264
  }
265
+ // auto-fill / auto-fit / nested weirdness - fall through to generic
266
  }
267
  // color-ish functions keep bare numbers bare; calc-family gets px
268
  const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
 
293
  continue;
294
  }
295
 
296
+ // v0.5.0 - font names are proper nouns: JetBrains Mono, IBM Plex Serif.
297
  // In font-family values and font.* tokens, bare multi-word names pass
298
  // through instead of demanding quotes. Everywhere else stays loud.
299
  if (ctx.bareIdents && /^[A-Za-z][A-Za-z0-9_-]*$/.test(ident)) {
 
314
  const hex = raw.slice(i, j);
315
  out += hex;
316
  i = j;
317
+ // v0.7.2 - alpha on a raw hex color: #ff0000/50 used to become
318
+ // #ff0000/50px (the bare number got the generic px treatment) - invalid
319
  // CSS, silently dropped by the browser. Tokens always had /NN via
320
  // color-mix; raw hex now gets the same treatment.
321
  const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
 
331
  return out;
332
  }
333
 
334
+ /** paren-aware split on the `->` arrow - gradient stops use it as a separator */
335
  function splitArrow(s) {
336
  const out = [];
337
  let cur = '', depth = 0, q = null;
 
348
  return out;
349
  }
350
 
351
+ /** split on a top-level separator (parens-aware) - commas inside fn() survive */
352
  function topSplit(str, sep) {
353
  const out = [];
354
  let cur = '', depth = 0;
 
364
 
365
  function findToken(tokens, ident) {
366
  if (tokens.has(ident)) return ident;
367
+ // try progressive dot paths (a.b.c defined, user typed a.b.c - exact only, plus flat fallback)
368
  if (ident.includes('.')) {
369
  const parts = ident.split('.');
370
  for (let k = parts.length - 1; k > 0; k--) {
371
  const key = parts.slice(0, k).join('.');
372
+ if (tokens.has(key)) return key; // hmm - partial match would change meaning; require exact
373
  }
374
  }
375
  return null;
 
382
  const len = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: false });
383
  const num = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: true });
384
 
385
+ // v0.6.0 - the deform family speaks degrees, not lengths: bare numbers are
386
  // angles and `20deg` is accepted for people with CSS muscle memory.
387
  const degNum = (raw, what, ctx) => {
388
  const t = String(raw).trim();
 
391
  ctx.errors.push({
392
  file: ctx.file, line: ctx.line,
393
  message: `${what} takes a bare number of degrees`,
394
+ hint: `try ${what}: 20 or ${what}: -12 - bare numbers are degrees here`,
395
  });
396
  return null;
397
  }
398
  return parseFloat(m[1]);
399
  };
400
 
401
+ /** v0.6.0 - shared grammar for gradient / gradient-text: kind · prefix · stops.
402
  * Stops may be arrow-joined (`a -> b -> c`) or comma-joined (`linear 135, a, b`);
403
+ * the prefix is whatever the grammar calls direction/shape - everything else is a stop. */
404
  // Stops are colors by construction, so a bare word that is not a color name is
405
  // a typo the browser would only mutter about at apply time. Say it NOW instead.
406
  const COLOR_WORDS = new Set(('black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
 
450
  ctx.errors.push({
451
  file: ctx.file, line: ctx.line,
452
  message: `gradient needs at least two stops`,
453
+ hint: 'try gradient: linear 135, #ff0055 -> #00d4ff - commas work too: linear 135, #ff0055, #00d4ff',
454
  });
455
  return null;
456
  }
 
491
  ctx.errors.push({
492
  file: ctx.file, line: ctx.line,
493
  message: `"${head}" is not a conic gradient prefix`,
494
+ hint: 'use from <angle> and/or at <position> - try conic from 45 at center, …',
495
  });
496
  return null;
497
  }
 
523
 
524
  function parseFlexWords(value, ctx, dir) {
525
  const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
526
+ // v0.8.0 - axis roles. Every place word used to write BOTH axes, so
527
  // `row: gap 8 between center` (the guide's own example) compiled to
528
+ // justify-content: space-between followed by justify-content: center -
529
  // and center won, bunching everything in the middle. Words now declare
530
  // the axis they are actually about: distribution words (between/around/
531
  // evenly) own the main axis, stretch/baseline own the cross axis, and
 
556
  const axisWarn = (prop, prev) => {
557
  ctx.warnings = ctx.warnings || [];
558
  ctx.warnings.push(prev === a
559
+ ? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir} - the second copy wins`, hint: `drop the duplicate; one distribution word + one alignment word is the whole grammar` }
560
+ : { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win - drop one, or reorder so the distribution word comes first` });
561
  };
562
  let wrote = false;
563
  if (role === 'main' || (role === 'both' && !mainSet)) {
 
575
  ctx.warnings.push({
576
  file: ctx.file, line: ctx.line,
577
  message: mainSet === a || crossSet === a
578
+ ? `"${a}" is written twice in ${dir} - both axes already came from it`
579
  : `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`,
580
+ hint: 'one distribution word + one alignment word is the whole grammar - the extra word is dropped',
581
  });
582
  }
583
  continue;
 
587
  return out;
588
  }
589
 
590
+ // v0.6.0 - deform members all land in the transform channel, so asking for
591
  // `transition: 200 snap bend` should just work and mean transform.
592
  const DEFORM_TO_TRANSFORM = {
593
  bend: 'transform', 'bend-y': 'transform', 'bend-up': 'transform',
 
605
  column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
606
  grid(v, ctx) {
607
  const atoms = v.split(/\s+/).filter(Boolean);
608
+ // v0.7.2 - `grid: 3 gap 12` (the documented form) used to die: the gap
609
  // atoms were fed into grid-template-columns → "3px gap 12px", invalid.
610
  // gap and rows are now first-class words of the shorthand.
611
  let gap = null, rows = null;
 
617
  }
618
  let cols;
619
  if (!rest.length) {
620
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'grid needs columns', hint: 'grid: 3 · grid: auto-fill 200 · grid: 200px 1fr - optionally with gap N and rows N' });
621
  cols = 'none';
622
  } else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) {
623
  cols = `repeat(${rest[0]}, 1fr)`;
 
648
  }
649
  return [{ prop: 'place-items', value: `${p[0]} ${p[1]}` }, { prop: 'place-content', value: `${p[0]} ${p[1]}` }];
650
  },
651
+ // v0.3.4 - the one-word centering primitive, because "how do I center this
652
  // thing" is the first question everyone asks. Emits the alignment pair only;
653
  // the ENGINE guarantees a formatting context for alignment declarations
654
  // (inline elements → inline-grid, blocks → grid), so `center:` can never
 
735
  { prop: 'white-space', value: 'nowrap' },
736
  ];
737
  },
738
+ // v0.4.0 - the accessibility classic: visually hidden, still announced by
739
  // screen readers. One word instead of nine lines of clip folklore.
740
  'visually-hidden'() {
741
  return [
 
750
  { prop: 'border', value: '0' },
751
  ];
752
  },
753
+ // v0.4.0 - transition grew per-property specs. Each comma part is
754
  // duration [easing] [property] (property omitted → all)
755
  // The one-value form keeps working exactly as before:
756
  // transition: 200 snap
 
764
  const atoms = part.split(/\s+/);
765
  const dur = atoms[0];
766
  if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
767
+ // v0.8.0 - the CSS habit is property-first (`transition: color .2s
768
  // ease`). That used to be a generic "not a time" error and the part
769
  // silently dropped. The error now REWRITES the value into the DSSL
770
  // order (time · easing · property) so the fix is a copy-paste.
 
776
  ctx.errors.push({
777
  file: ctx.file, line: ctx.line,
778
  message: `transition: the CSS form (property first) is not DSSL`,
779
+ hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''} - time first, easing, property last`,
780
  });
781
  continue;
782
  }
783
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration · easing · property - try 200 or 0.2s' });
784
  continue;
785
  }
786
  const ms = /^\d+$/.test(dur) ? dur + 'ms' : dur;
 
820
  }
821
  if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
822
  if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
823
+ // v0.5.0 - looping: `infinite` runs forever, `alternate` ping-pongs.
824
+ // animate: pulse 1600 infinite alternate - was impossible before.
825
  if (a === 'infinite') { spec.iterations = Infinity; continue; }
826
  if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
827
  ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate'])) });
828
  }
829
  return [{ prop: '@animate', value: spec }];
830
  },
831
+ // v0.4.0 - choreography: a parent with `stagger: 60` offsets each element
832
  // child's animation delay by its DOM position. Pairs with `seen` + `animate`
833
  // on the children: grids and lists cascade in as they scroll into view.
834
  stagger(v, ctx) {
 
841
  const ms = t.endsWith('s') && !t.endsWith('ms') ? parseFloat(t) * 1000 : parseFloat(t);
842
  return [{ prop: '@stagger', value: ms }];
843
  },
844
+ // v0.5.0 - overlap policy. CSS can stack transparent things all day and say
845
  // nothing; DSSL refuses. `deny` makes the element always yield out of any
846
  // overlap; `auto` yields only while the element sits below 100% opacity.
847
  // Both inherit down the tree, so `body { overlap: auto }` is the site-wide
 
861
  },
862
 
863
  /* ---------------------------------------------------------------- *
864
+ * v0.6.0 - the deform family: bend, bend-y, bend-up, fold, stretch,
865
  * bend-around. All are engine-native (@-props) that compose into the
866
  * transform channel, so `transition: 200 snap transform` tweens them
867
  * and `:active { bend: 14 }` makes a button visibly flex under the
 
878
  if (Math.abs(n) > 170) {
879
  ctx.errors.push({
880
  file: ctx.file, line: ctx.line,
881
+ message: `bend-around caps at ±170° - past that the arc folds back on itself`,
882
  hint: 'try 30..120 for a visible arc',
883
  });
884
  return [];
 
901
  ctx.errors.push({
902
  file: ctx.file, line: ctx.line,
903
  message: `stretch: ${m[1]} would flatten the element to nothing`,
904
+ hint: 'use a factor above 0 - 1.2 is a gentle pull, 2 is cartoonish',
905
  });
906
  return [];
907
  }
 
910
  return [{ prop: '@stretch', value: { factor: f, axis } }];
911
  },
912
 
913
+ /* v0.6.0 - gradient tools. `gradient` replaces the CSS function soup with
914
  * kind · prefix · stop -> stop; stops may be colors, tokens, alpha mixes
915
  * (ink/40) and carry a trailing % position. gradient-text clips the same
916
  * gradient into the glyphs. gradient-flow loops background-position so a
917
+ * gradient drifts forever - one line instead of keyframe folklore. */
918
  gradient(v, ctx) {
919
  const img = buildGradient(v, ctx);
920
  return img ? [{ prop: 'background-image', value: img }] : [];
 
959
  },
960
  };
961
 
962
+ /* v0.8.0 - fluid sizing sugar. `fluid: 16, 24` keeps working (font-size),
963
  * and now any length-ish property takes it inline: `font-size: fluid 16 24`.
964
  * Both compile to a clamp() that scales linearly between the viewport bounds
965
+ * (360px and 1200px by default) - the formula everyone writes by hand and
966
  * gets wrong. */
967
  const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' +
968
  'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left,' +
 
1008
  }
1009
 
1010
  /* ------------------------------------------------------------------ *
1011
+ * 0.9.0: texture, aura and slider
1012
+ *
1013
+ * All three validate their own vocabulary in their own module. Here they only
1014
+ * get hooks: how to report an error on this line, how to turn a colour word
1015
+ * into CSS (tokens, alpha and all), and how to suggest a correction.
1016
+ * ------------------------------------------------------------------ */
1017
+
1018
+ function styleHooks(ctx) {
1019
+ return {
1020
+ error: (message, hint) => ctx.errors.push({ file: ctx.file, line: ctx.line, message, hint: hint || undefined }),
1021
+ color: (word) => {
1022
+ const before = ctx.errors.length;
1023
+ const css = resolveValue(word, { ...ctx, unitless: false, bareIdents: false });
1024
+ return ctx.errors.length > before ? null : css;
1025
+ },
1026
+ suggest: (word, list) => { const h = didYouMean(word, list); return h ? `did you mean "${h}"?` : null; },
1027
+ };
1028
+ }
1029
+
1030
+ SEMANTIC.texture = function texture(v, ctx) {
1031
+ const layers = parseTexture(v, styleHooks(ctx));
1032
+ if (layers === null) return [];
1033
+ return [{ prop: '@texture', value: layers.length ? layers : null }];
1034
+ };
1035
+
1036
+ function auraDecls(prop, v, ctx) {
1037
+ const r = parseAura(prop, v, styleHooks(ctx));
1038
+ if (!r) return [];
1039
+ const out = [];
1040
+ // an empty list or none clears the slot; undefined means this property does not touch it
1041
+ if (r.glow !== undefined) out.push({ prop: '@aura-glow', value: r.glow && r.glow.length ? r.glow : null });
1042
+ if (r.particles !== undefined) out.push({ prop: '@aura-particles', value: r.particles && r.particles.length ? r.particles : null });
1043
+ return out;
1044
+ }
1045
+ for (const name of ['aura', 'aura-glow', 'aura-particle', 'aura-particles', 'glow-particle', 'glow-particles', 'aura-glow-particle', 'aura-glow-particles']) {
1046
+ SEMANTIC[name] = (v, ctx) => auraDecls(name, v, ctx);
1047
+ }
1048
+
1049
+ SEMANTIC.slider = function slider(v, ctx) {
1050
+ const spec = parseSlider(v, styleHooks(ctx));
1051
+ if (!spec) return [];
1052
+ return [{ prop: '@slider', value: spec }];
1053
+ };
1054
+
1055
+ /* ------------------------------------------------------------------ *
1056
+ * resolveDecl - one declaration → zero or more engine decls
1057
  * ------------------------------------------------------------------ */
1058
 
1059
  function resolveDecl(prop, value, ctx) {
1060
  const errors = ctx.errors;
1061
  const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings };
1062
 
1063
+ // v0.8.0 - !important does not exist in DSSL. It used to fall through as a
1064
  // value: the compiler said "unknown value important" AND still emitted
1065
  // `red !important`, which the browser then silently dropped as an invalid
1066
  // inline value. Now: one loud error, nothing emitted, and the pin { }
 
1068
  if (hasImportant(value)) {
1069
  errors.push({
1070
  file: ctx.file, line: ctx.line,
1071
+ message: `!important does not exist in DSSL - "${prop}: ${String(value).replace(/\s*!\s*important\s*/i, '')}" was NOT applied`,
1072
+ hint: 'the cascade is (layer, order) and later wins - put the rule in pin { } when it genuinely must win',
1073
  });
1074
  return [];
1075
  }
1076
 
1077
  if (prop.startsWith('--')) {
1078
+ // v0.4.0 - scoped tokens. Dotted names are token re-declarations:
1079
  // .danger { --color.accent: color.err }
1080
  // sets --dssl-color-accent ON the element, and custom properties inherit,
1081
  // so every var() reference in the subtree re-resolves. Plain names
 
1089
  const sem = SEMANTIC[prop];
1090
  if (sem) return sem(value, ctx2);
1091
 
1092
+ // v0.8.0 - the animation shorthand passes through (it is legal CSS), but
1093
  // bare motion names must not pay the generic unknown-value tax: "spin"
1094
  // used to error with the surreal suggestion "did you mean span?". Motion
1095
  // names pass silently here and a dedicated warning in the final loop says
 
1098
  return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }];
1099
  }
1100
 
1101
+ // v0.8.0 - inline fluid sugar on any length-ish property:
1102
  // font-size: fluid 16 24 → clamp(16px, calc(16px + 0.952vw), 24px)
1103
  const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim());
1104
  if (fluidM && FLUID_PROPS.has(prop)) {
 
1116
  return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
1117
  }
1118
 
1119
+ /** string-aware !important scan - quoted content: "!important" must not trip it */
1120
  function hasImportant(v) {
1121
  const s = String(v);
1122
  let q = null;
 
1179
  const errors = [];
1180
  const rules = [];
1181
  const shadowRules = [];
1182
+ const raws = []; // v0.8.0 - raw { } / @css { } verbatim passthrough blocks
1183
  const motions = {};
1184
  const behaviors = {};
1185
+ // v0.7.0 - compile-time warnings: things that compile and run but are
1186
+ // probably not what the author meant. Separate from errors - ok stays ok -
1187
  // but the engine prints them loudly so nobody debugs cascade order by hand
1188
  // ever again.
1189
  const warnings = [];
 
1219
  } else if (node.kind === 'motion') {
1220
  motions[node.name] = node;
1221
  } else {
1222
+ // v0.8.0 - defensive `|| []`: a malformed tree can throw from here
1223
  // and take the whole stylesheet down (the @font-face crash report).
1224
  // The parser now skips unknown at-rules whole, but the guard stays.
1225
  for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]);
 
1258
  continue;
1259
  }
1260
  const props = {};
1261
+ const deform = {}; // v0.6.0 - bend/stretch/fold in keyframes compose into transform
1262
  const DEFORM_KEY = { '@bend': 'bend', '@bend-y': 'bendY', '@fold': 'fold', '@bend-up': 'bendUp' };
1263
  for (const d of child.children) {
1264
  if (d.kind !== 'decl') continue;
 
1280
  deform.fold !== undefined || deform.bendUp !== undefined) {
1281
  // no box exists at resolve time, so edge-pinned members ride a
1282
  // transformOrigin keyframe property instead of baked translates.
1283
+ // Presence (not truthiness) - `bend: 0` in a frame is a real reset.
1284
  const c = composeDeform(deform, props.transform || '', null);
1285
  props.transform = c.transform;
1286
  if (c.origin) props.transformOrigin = c.origin;
 
1302
  for (const key of keys) {
1303
  const raw = tokenMap.get(key);
1304
  const prop = '--dssl-' + key.replace(/\./g, '-');
1305
+ // font.* tokens hold font stacks - proper nouns, not keywords (v0.5.0)
1306
  decls.push({ prop, value: resolveValue(raw, { ...ctxBase, prop: key, bareIdents: /^font/.test(key) }) });
1307
  }
1308
  if (!decls.length) return null;
 
1321
  /* ---- main recursive walk ---- */
1322
 
1323
  const walk = (children, ctx, ctxRule) => {
1324
+ // v0.7.2 - parent-first flush. Two real bugs lived here. (1) A nested
1325
  // block (hover { }, @media, a nested selector) was emitted BEFORE the
1326
  // parent's own declarations got their cascade order, so `background: red`
1327
  // on the parent always beat `hover { background: red }` nested inside it
1328
+ // - the exact pattern the docs taught, and the external review's headline
1329
  // finding. (2) media/container/signal/layer blocks flushed with the
1330
  // PARENT context, dropping the condition or layer entirely: a nested
1331
  // `@media dark` was silently unconditional AND sorted before base.
 
1355
  }
1356
  const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
1357
  for (const r of resolved) {
1358
+ // v0.7.0 - remember WHICH source property wrote a display value,
1359
  // so the ordering linter can tell an explicit "display:" from a
1360
  // shorthand that expanded into one (column: → display: flex).
1361
  if (r.prop === 'display' && node.prop !== 'display') r.via = node.prop;
 
1366
  case 'selector': {
1367
  if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) {
1368
  const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]);
1369
+ if (near) warnings.push({ file, line: node.line, message: `"${node.sel}" compiled as a descendant selector ("${combine(ctx.sel, node.sel)}")`, hint: `if you meant the state, it is spelled "${near}" - this block currently styles descendants called <${node.sel}>` });
1370
  }
1371
  flushPending(node.line);
1372
  const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
 
1383
  }
1384
  flushPending(node.line);
1385
  const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
1386
+ // v0.7.2 - the arrow is motion/gradient-stop syntax; inside a seen
1387
  // block it compiled into an inline style the browser rejects, so the
1388
  // element just sat at its base style forever. Say so, loudly, and
1389
  // point at the two real patterns (final value, or a motion).
1390
  if (node.name === 'seen') {
1391
  for (const d of node.children) {
1392
  if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) {
1393
+ errors.push({ file, line: d.line, message: `"${d.prop}: ${d.value}" - the -> arrow is motion syntax and does nothing inside seen { }`, hint: 'write the final style (seen { opacity: 1 }) or attach a motion (seen { animate: rise-in 400 })' });
1394
  }
1395
  }
1396
  }
 
1447
  if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1448
  if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1449
  const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1450
+ // v0.5.0 - engine-native properties have no pseudo-element story:
1451
  // there is no DOM handle to nudge, animate, stack or stagger. They
1452
  // used to leak into the shadow sheet as junk CSS the browser
1453
+ // silently dropped - exactly the silence this language refuses.
1454
  for (const r of resolved) {
1455
  if (r.prop.startsWith('@')) {
1456
+ errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo block' });
1457
  continue;
1458
  }
1459
  srule.decls.push({ ...r, line: d.line });
 
1473
  break;
1474
  }
1475
  case 'raw': {
1476
+ // v0.8.0 - verbatim CSS escape hatch (@font-face, @keyframes,
1477
  // @supports, @import, @property…). The parser captured the body
1478
  // untouched; it rides the same document stylesheet as the
1479
  // pseudo-element rules and survives `dssl build` verbatim.
 
1509
  function flush(sub, ctx, line) {
1510
  if (!sub.decls.length) return;
1511
  if (!ctx.sel) {
1512
+ errors.push({ file, line, message: 'declarations must live inside a selector', hint: 'top-level blocks are tokens, behavior, motion, @layer, @media… - or a selector like .card' });
1513
  return;
1514
  }
1515
+ // v0.5.0 - the pseudo-element trap fix. A full selector like `.card::before`
1516
  // used to compile ok and then match NOTHING: inline styles can never reach
1517
  // pseudo-elements. Now the selector is split into element + pseudo halves
1518
+ // and the rule rides the shadow-CSS pipeline - exactly what the nested
1519
  // form (`.card { ::before { } }`) always did. Both forms are now one thing.
1520
  const ps = analyzePseudo(ctx.sel);
1521
  if (ps.mode === 'all') {
1522
  // parity with the nested path: bare `content:` becomes content: ""
1523
+ // v0.5.0 - and engine-native properties fail loud here too (see the
1524
  // nested pseudo path): no DOM handle means no nudge, animate or stack.
1525
  const decls = [];
1526
  for (const d of sub.decls) {
1527
  if (d.prop && d.prop.startsWith('@')) {
1528
+ errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo rule' });
1529
  continue;
1530
  }
1531
  decls.push(d.prop === 'content' && d.value === '' ? { ...d, value: '""' } : d);
 
1544
  errors.push({
1545
  file, line,
1546
  message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM',
1547
+ hint: 'style the slotted element itself with a normal selector - it IS reachable from the page',
1548
  });
1549
  continue;
1550
  }
 
1556
  });
1557
  continue;
1558
  }
1559
+ // v0.5.0 - parity with the nested path: if every declaration was
1560
  // rejected (engine-native props, bad values), emit NOTHING rather
1561
  // than an empty ghost rule that matches and styles zero things.
1562
  if (!decls.length) continue;
 
1569
  return;
1570
  }
1571
  if (ps.mode === 'mixed') {
1572
+ errors.push({ file, line, message: `selector list mixes pseudo-element and plain selectors: "${ctx.sel}"`, hint: 'split it into two rules - one for the pseudo-element, one for the rest' });
1573
  return;
1574
  }
1575
+ // 0.9.0: slider. One description becomes the base decls on the input (put FIRST so the
1576
+ // author's own declarations in the same rule win) and one shadow rule per vendor pseudo.
1577
+ const sliderDecl = sub.decls.find((d) => d.prop === '@slider');
1578
+ if (sliderDecl && sliderDecl.value) {
1579
+ const css = sliderCss(sliderDecl.value);
1580
+ sub.decls.unshift(...css.base.map((d) => ({ ...d, line })));
1581
+ for (const part of splitTopLevel(ctx.sel)) {
1582
+ for (const sh of css.shadow) {
1583
+ shadowRules.push({
1584
+ id: ruleId++, sel: part, pseudo: sh.pseudo,
1585
+ media: ctx.media, signals: ctx.signals, containers: ctx.containers,
1586
+ layer: ctx.layer, order: order++, decls: sh.decls.map((d) => ({ ...d, line })), file, line,
1587
+ });
1588
+ }
1589
+ }
1590
+ }
1591
  warnDisplayClash(sub.decls);
1592
  rules.push({
1593
  id: ruleId++, sel: ctx.sel, media: ctx.media, signals: ctx.signals,
 
1597
  });
1598
  }
1599
 
1600
+ /* v0.7.0 - the ordering linter. Within one rule, later wins; that is the
1601
  * whole cascade model. But "column: gap 2" quietly expanding into
1602
  * display: flex after an explicit "display: none" reads like a menu
1603
+ * toggle and behaves like a bug report - a real reviewer lost an evening
1604
  * to exactly this ("their own source comments warn about this, but it
1605
  * should be a compiler warning, not a comment"). Agreed. When a
1606
  * shorthand's expansion clobbers an explicit display written earlier in
1607
  * the SAME rule, say so at compile time. Cross-rule overrides are the
1608
+ * documented cascade at work, not an accident - no warning there. */
1609
  function warnDisplayClash(decls) {
1610
  let explicit = null;
1611
  for (const d of decls) {
 
1614
  if (explicit && explicit.value !== d.value && warnings.length < 400) {
1615
  warnings.push({
1616
  file, line: d.line,
1617
+ message: `"${d.via}:" expands to display: ${d.value}, which overrides the earlier "display: ${explicit.value}" (line ${explicit.line}) in the same rule - later wins, so the earlier one is dead`,
1618
  hint: `if the override is intended, delete the dead line; otherwise put "${d.via}:" first, or move the display you want to win AFTER it`,
1619
  });
1620
  }
 
1637
  errors.push({
1638
  file, line: d.line,
1639
  message: `unknown motion "${d.value.motion}"`,
1640
+ hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } } - then "animate: ${d.value.motion} 400" runs it`,
1641
  });
1642
  d.value.broken = true;
1643
  } else {
 
1645
  d.value.essential = m.essential;
1646
  }
1647
  }
1648
+ // v0.8.0 - the animation shorthand is CSS passthrough, so a motion
1649
  // name that matches nothing used to be a SILENT no-op (no @keyframes
1650
  // can exist in DSSL). Warn with the whole story; if the keyframes come
1651
  // from raw { } or another stylesheet, the warning says it can be ignored.
 
1655
  if (words.length && !named) {
1656
  warnings.push({
1657
  file, line: d.line,
1658
+ message: `animation "${d.value}" references no motion defined in this sheet - it will not run`,
1659
+ hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400 - @keyframes does not exist in DSSL; raw { } can carry verbatim keyframes`,
1660
  });
1661
  }
1662
  }
1663
  }
1664
  }
1665
 
1666
+ // v0.8.0 - pre-parse EVERY motion (not just referenced ones) so the static
1667
  // emitter can turn the whole motion vocabulary into real @keyframes.
1668
  const motionFrames = {};
1669
  for (const name of Object.keys(motions)) {
 
1671
  if (m && m.frames.length) motionFrames[name] = m;
1672
  }
1673
 
1674
+ // 0.9.0: the lint pass. Warnings only, so ok stays ok; `dssl check --strict` makes them fatal.
1675
+ try { warnings.push(...lintRules({ rules, tokenMap, file })); } catch { /* a lint bug must never break a compile */ }
1676
+
1677
  const mediaQueries = new Set();
1678
  for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1679
 
 
1686
  return parent + ' ' + child;
1687
  }
1688
 
1689
+ /* v0.5.0 - where does a selector carry a pseudo-element?
1690
+ * 'none' - plain selector, inline-style path
1691
+ * 'all' - every comma-part has one: { parts: [{ el, pseudo }] } → shadow CSS
1692
+ * 'mixed' - some parts have one, some don't → loud error (split the list)
1693
  * The `::` scan is string- and paren-aware so [data-x="::a"] can't false-hit. */
1694
  function analyzePseudo(sel) {
1695
  // top-level comma split
dssl-vscode/lib/engine/slider.js ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: slider.js
3
+ *
4
+ * input[type=range] {
5
+ * slider: track 6 color.line radius full, fill color.accent,
6
+ * thumb 24 color.paper shape circle, ring 3 color.accent,
7
+ * glow color.accent 14, ticks 10 color.muted
8
+ * }
9
+ *
10
+ * A range input is styled through vendor pseudo-elements, and every engine has
11
+ * its own: ::-webkit-slider-runnable-track and ::-webkit-slider-thumb for
12
+ * Chrome, Edge, Safari and Opera, ::-moz-range-track, ::-moz-range-thumb and
13
+ * ::-moz-range-progress for Firefox. Writing both sets by hand is the pain.
14
+ * `slider:` writes both from one description.
15
+ *
16
+ * It is plain CSS, with one small helper: the filled part of the track (and
17
+ * the coloured bars of a graph) follow the value, which CSS cannot know, so
18
+ * the engine keeps --dssl-p (the value as 0..1) up to date on the input.
19
+ *
20
+ * Clauses, comma separated:
21
+ * track <height> [color] [radius <n>|full]
22
+ * fill <color> | fill none
23
+ * thumb <size> [color]
24
+ * shape circle | square | diamond | pill | bar
25
+ * ring <width> <color> a border around the thumb
26
+ * glow <color> <radius> a soft light around the thumb
27
+ * ticks <count> [color] [length] little marks along the track
28
+ * graph <v1> <v2> ... bars (0 to 100) behind the track, a histogram slider
29
+ * graph-height <px> graph-gap <px>
30
+ *
31
+ * Pure: no DOM.
32
+ * ========================================================================= */
33
+
34
+ import { splitWords } from './texture.js';
35
+
36
+ export const SLIDER_CLAUSES = {
37
+ track: 'track [height] [color] [radius <n>|full]',
38
+ fill: 'fill <color> | fill none',
39
+ thumb: 'thumb <size> [color]',
40
+ shape: 'shape circle | square | diamond | pill | bar',
41
+ ring: 'ring <width> <color>',
42
+ glow: 'glow <color> <radius>',
43
+ ticks: 'ticks <count> [color] [length]',
44
+ graph: 'graph <v1> <v2> ... (each 0 to 100)',
45
+ 'graph-height': 'graph-height <px>',
46
+ 'graph-gap': 'graph-gap <px>',
47
+ };
48
+ export const THUMB_SHAPES = ['circle', 'square', 'diamond', 'pill', 'bar'];
49
+
50
+ function splitCommas(s) {
51
+ const out = []; let depth = 0, cur = '';
52
+ for (const ch of s) {
53
+ if (ch === '(') depth++;
54
+ if (ch === ')') depth--;
55
+ if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
56
+ cur += ch;
57
+ }
58
+ if (cur.trim()) out.push(cur.trim());
59
+ return out;
60
+ }
61
+ const isNum = (w) => w !== undefined && w !== '' && !Number.isNaN(Number(w));
62
+
63
+ /**
64
+ * hooks: { error(msg, hint), color(word) -> css | null, suggest(word, list) -> string | null }
65
+ * Returns a spec object, or null when something was wrong.
66
+ */
67
+ export function parseSlider(value, hooks) {
68
+ const text = String(value || '').trim();
69
+ if (!text) { hooks.error('slider needs a description', 'e.g. slider: track 6 color.line, fill color.accent, thumb 22 color.accent'); return null; }
70
+ const spec = { track: { h: 6, color: 'var(--dssl-color-line)', radius: 'full' }, fill: 'var(--dssl-color-accent)', thumb: { size: 22, color: 'var(--dssl-color-accent)' }, shape: 'circle', ring: null, glow: null, ticks: null, graph: null, graphHeight: 40, graphGap: 2 };
71
+ const bad = (m, h) => { hooks.error(m, h); return null; };
72
+ const col = (w) => hooks.color(w);
73
+
74
+ for (const clause of splitCommas(text)) {
75
+ const w = splitWords(clause);
76
+ const head = w[0];
77
+ if (!(head in SLIDER_CLAUSES)) {
78
+ return bad(`unknown slider setting "${head}"`, hooks.suggest(head, Object.keys(SLIDER_CLAUSES)) || `settings: ${Object.keys(SLIDER_CLAUSES).join(', ')}`);
79
+ }
80
+ const rest = w.slice(1);
81
+ // each clause reports how many words it understood; anything left over is a mistake, not noise
82
+ let used = rest.length;
83
+ if (head === 'track') {
84
+ // any order: a number is the height, "radius <n>|full" the corners, anything else a colour
85
+ let i = 0;
86
+ while (i < rest.length) {
87
+ const x = rest[i];
88
+ if (x === 'radius') {
89
+ const r = rest[i + 1];
90
+ if (r === 'full') spec.track.radius = 'full';
91
+ else if (isNum(r)) spec.track.radius = Number(r);
92
+ else return bad('track radius needs a number or "full"', SLIDER_CLAUSES.track);
93
+ i += 2;
94
+ } else if (isNum(x)) { spec.track.h = Number(x); i++; }
95
+ else { const c = col(x); if (c === null) return null; spec.track.color = c; i++; }
96
+ }
97
+ } else if (head === 'fill') {
98
+ used = 1;
99
+ if (rest[0] === 'none') spec.fill = null;
100
+ else { const c = col(rest[0]); if (c === null) return null; spec.fill = c; }
101
+ } else if (head === 'thumb') {
102
+ if (!isNum(rest[0])) return bad('thumb needs a size in px', SLIDER_CLAUSES.thumb);
103
+ spec.thumb.size = Number(rest[0]);
104
+ used = 1;
105
+ if (rest[1]) { used = 2; const c = col(rest[1]); if (c === null) return null; spec.thumb.color = c; }
106
+ } else if (head === 'shape') {
107
+ if (!THUMB_SHAPES.includes(rest[0])) return bad(`unknown thumb shape "${rest[0] ?? ''}"`, hooks.suggest(rest[0] || '', THUMB_SHAPES) || `use one of: ${THUMB_SHAPES.join(', ')}`);
108
+ spec.shape = rest[0];
109
+ used = 1;
110
+ } else if (head === 'ring') {
111
+ if (!isNum(rest[0]) || !rest[1]) return bad('ring needs a width and a colour', SLIDER_CLAUSES.ring);
112
+ const c = col(rest[1]); if (c === null) return null;
113
+ spec.ring = { w: Number(rest[0]), color: c };
114
+ used = 2;
115
+ } else if (head === 'glow') {
116
+ if (!rest[0]) return bad('glow needs a colour and a radius', SLIDER_CLAUSES.glow);
117
+ const c = col(rest[0]); if (c === null) return null;
118
+ spec.glow = { color: c, r: isNum(rest[1]) ? Number(rest[1]) : 12 };
119
+ used = isNum(rest[1]) ? 2 : 1;
120
+ } else if (head === 'ticks') {
121
+ if (!isNum(rest[0]) || Number(rest[0]) < 2) return bad('ticks needs a count of at least 2', SLIDER_CLAUSES.ticks);
122
+ let color = 'var(--dssl-color-muted)', len = 6;
123
+ for (const x of rest.slice(1)) { if (isNum(x)) len = Number(x); else { const c = col(x); if (c === null) return null; color = c; } }
124
+ spec.ticks = { n: Math.round(Number(rest[0])), color, len };
125
+ } else if (head === 'graph') {
126
+ const vals = rest.map(Number);
127
+ if (vals.length < 2 || vals.some((n) => Number.isNaN(n))) return bad('graph needs at least two numbers', SLIDER_CLAUSES.graph);
128
+ if (vals.some((n) => n < 0 || n > 100)) return bad('graph values are 0 to 100', SLIDER_CLAUSES.graph);
129
+ if (vals.length > 120) return bad('graph is limited to 120 bars', null);
130
+ spec.graph = vals;
131
+ } else if (head === 'graph-height') {
132
+ if (!isNum(rest[0]) || Number(rest[0]) < 8) return bad('graph-height needs a number of px, at least 8', null);
133
+ spec.graphHeight = Number(rest[0]);
134
+ used = 1;
135
+ } else if (head === 'graph-gap') {
136
+ if (!isNum(rest[0]) || Number(rest[0]) < 0) return bad('graph-gap needs a number of px', null);
137
+ spec.graphGap = Number(rest[0]);
138
+ used = 1;
139
+ }
140
+ if (used < rest.length) {
141
+ return bad(`unexpected "${rest.slice(used).join(' ')}" in the ${head} setting`, `separate settings with commas, e.g. thumb 16 #fff, shape bar. ${SLIDER_CLAUSES[head]}`);
142
+ }
143
+ }
144
+ return spec;
145
+ }
146
+
147
+ const f = (n) => String(+Number(n).toFixed(2));
148
+
149
+ /**
150
+ * Build the CSS for a parsed slider.
151
+ * Returns { base: [{prop,value}], shadow: [{pseudo, decls:[{prop,value}]}] }
152
+ * `base` goes on the element itself, `shadow` are the vendor pseudo-element rules.
153
+ */
154
+ export function sliderCss(spec) {
155
+ const T = spec.track.h;
156
+ const hasGraph = !!spec.graph;
157
+ const G = spec.graphHeight;
158
+ const trackH = hasGraph ? G : T;
159
+ const S = spec.thumb.size;
160
+ // thumb box by shape
161
+ let tw = S, th = S, tr = '0', rot = '';
162
+ if (spec.shape === 'circle') tr = '50%';
163
+ else if (spec.shape === 'square') tr = `${f(Math.min(6, S / 4))}px`;
164
+ else if (spec.shape === 'diamond') { tr = '2px'; rot = 'rotate(45deg)'; tw = th = S * 0.82; }
165
+ else if (spec.shape === 'pill') { tw = S * 0.7; th = S * 1.3; tr = '999px'; }
166
+ else if (spec.shape === 'bar') { tw = Math.max(4, S * 0.25); th = hasGraph ? G + 6 : S * 1.4; tr = '2px'; }
167
+ const ringW = spec.ring ? spec.ring.w : 0;
168
+ // a fully rounded track would clip a graph's bars into a semicircle, so graphs default to small corners
169
+ const trackRadius = spec.track.radius === 'full' ? (hasGraph ? '4px' : `${f(trackH / 2)}px`) : `${f(spec.track.radius)}px`;
170
+ const inputH = Math.max(trackH, th) + (spec.ticks ? spec.ticks.len + 2 : 0);
171
+
172
+ const p = 'var(--dssl-p, 0.5)';
173
+ // where the thumb centre is, as a length along the track
174
+ const along = `calc(${f(tw / 2)}px + ${p} * (100% - ${f(tw)}px))`;
175
+ const trackColor = spec.track.color;
176
+ const fill = spec.fill;
177
+
178
+ // ---- the track's background
179
+ const layers = []; // each: [image, size, position]
180
+ if (hasGraph) {
181
+ const n = spec.graph.length;
182
+ const lineH = Math.max(2, T);
183
+ const usable = G - lineH - 2;
184
+ spec.graph.forEach((v, i) => {
185
+ const x = n === 1 ? 50 : (i / (n - 1)) * 100;
186
+ const bw = `calc((100% - ${f((n - 1) * spec.graphGap)}px) / ${n})`;
187
+ const bh = Math.max(1, (usable * v) / 100);
188
+ const mixer = fill
189
+ ? `color-mix(in srgb, ${fill} calc(clamp(0, (${p} - ${f((i + 0.5) / n)}) * 1000, 1) * 100%), ${trackColor})`
190
+ : trackColor;
191
+ layers.push([`linear-gradient(${mixer}, ${mixer})`, `${bw} ${f(bh)}px`, `${f(x)}% calc(100% - ${f(lineH + 2)}px)`]);
192
+ });
193
+ const line = fill ? `linear-gradient(to right, ${fill} 0, ${fill} ${along}, ${trackColor} ${along}, ${trackColor} 100%)` : `linear-gradient(${trackColor}, ${trackColor})`;
194
+ layers.push([line, `100% ${f(lineH)}px`, '0 100%']);
195
+ } else if (fill) {
196
+ layers.push([`linear-gradient(to right, ${fill} 0, ${fill} ${along}, ${trackColor} ${along}, ${trackColor} 100%)`, '100% 100%', '0 0']);
197
+ } else {
198
+ layers.push([`linear-gradient(${trackColor}, ${trackColor})`, '100% 100%', '0 0']);
199
+ }
200
+ const trackBg = [
201
+ { prop: 'background-image', value: layers.map((l) => l[0]).join(', ') },
202
+ { prop: 'background-size', value: layers.map((l) => l[1]).join(', ') },
203
+ { prop: 'background-position', value: layers.map((l) => l[2]).join(', ') },
204
+ { prop: 'background-repeat', value: 'no-repeat' },
205
+ ];
206
+
207
+ const thumbCommon = [
208
+ { prop: 'box-sizing', value: 'border-box' },
209
+ { prop: 'width', value: `${f(tw)}px` },
210
+ { prop: 'height', value: `${f(th)}px` },
211
+ { prop: 'background', value: spec.thumb.color },
212
+ { prop: 'border-radius', value: tr },
213
+ { prop: 'border', value: ringW ? `${f(ringW)}px solid ${spec.ring.color}` : '0' },
214
+ { prop: 'cursor', value: 'pointer' },
215
+ ];
216
+ const glow = spec.glow ? [{ prop: 'box-shadow', value: `0 0 ${f(spec.glow.r)}px ${f(spec.glow.r / 3)}px ${spec.glow.color}` }] : [];
217
+ if (rot) thumbCommon.push({ prop: 'transform', value: rot });
218
+
219
+ const base = [
220
+ { prop: 'appearance', value: 'none' },
221
+ { prop: '-webkit-appearance', value: 'none' },
222
+ { prop: 'background-color', value: 'transparent' },
223
+ { prop: 'margin', value: '0' },
224
+ { prop: 'width', value: '100%' },
225
+ { prop: 'height', value: `${f(inputH)}px` },
226
+ { prop: 'cursor', value: 'pointer' },
227
+ ];
228
+ if (spec.ticks) {
229
+ const n = spec.ticks.n;
230
+ base.push(
231
+ { prop: 'background-image', value: `repeating-linear-gradient(to right, ${spec.ticks.color} 0, ${spec.ticks.color} 1px, transparent 1px, transparent calc((100% - 1px) / ${n - 1}))` },
232
+ { prop: 'background-size', value: `calc(100% - ${f(tw)}px) ${f(spec.ticks.len)}px` },
233
+ { prop: 'background-position', value: `center bottom` },
234
+ { prop: 'background-repeat', value: 'no-repeat' },
235
+ );
236
+ }
237
+
238
+ const shadow = [
239
+ { pseudo: '-webkit-slider-runnable-track', decls: [{ prop: 'height', value: `${f(trackH)}px` }, { prop: 'border-radius', value: trackRadius }, ...trackBg] },
240
+ { pseudo: '-webkit-slider-thumb', decls: [
241
+ { prop: '-webkit-appearance', value: 'none' }, { prop: 'appearance', value: 'none' }, ...thumbCommon, ...glow,
242
+ { prop: 'margin-top', value: `${f((trackH - th) / 2)}px` },
243
+ ] },
244
+ { pseudo: '-moz-range-track', decls: [{ prop: 'height', value: `${f(trackH)}px` }, { prop: 'border-radius', value: trackRadius }, ...trackBg] },
245
+ { pseudo: '-moz-range-thumb', decls: [...thumbCommon, ...glow] },
246
+ // Firefox can fill natively, but we paint the fill ourselves (above) so both engines match
247
+ { pseudo: '-moz-range-progress', decls: [{ prop: 'background', value: 'transparent' }, { prop: 'height', value: `${f(trackH)}px` }] },
248
+ ];
249
+ return { base, shadow };
250
+ }
dssl-vscode/lib/engine/texture.js ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL: texture.js
3
+ *
4
+ * .card { texture: paper }
5
+ * .metal { texture: brushed intensity 0.6 color2 #cfd8e3 }
6
+ * .mine { texture: noise scale 3 octaves 4 color #2a1a0a color2 #c08040 blend multiply }
7
+ *
8
+ * A texture is a LAYER of generated image that sits on top of an element's own
9
+ * background. There are generators (noise, bump, lines, dots, grid, checker,
10
+ * weave, carbon, waves, hex) and settings (scale, intensity, contrast, color,
11
+ * color2, blend, ...). Every named preset below is nothing more than a stored
12
+ * combination of those, so anything a preset does you can write yourself, and
13
+ * you can name your own with a `behavior` block.
14
+ *
15
+ * This file is pure: no DOM. parseTexture() runs in the compiler and checks the
16
+ * words; buildTexture() turns a parsed layer into CSS background pieces and is
17
+ * given a function that resolves colour words (the engine passes one that
18
+ * understands the page's tokens; tests pass parseColor).
19
+ * ========================================================================= */
20
+
21
+ import { parseColor, withAlpha, toRgba, mix } from './color.js';
22
+
23
+ const mix2 = (x, y, t) => mix(x, y, t, 'srgb');
24
+
25
+ /* ------------------------------------------------------------------ *
26
+ * vocabulary
27
+ * ------------------------------------------------------------------ */
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 = {
35
+ scale: { words: 1, kind: 'number', min: 0.1, max: 2000, doc: 'feature size in px: how big the grains, cells or lines are' },
36
+ intensity: { words: 1, kind: 'number', min: 0, max: 2, doc: 'how strong the layer is, 0 to 1 (more than 1 over-drives it)' },
37
+ contrast: { words: 1, kind: 'number', min: 0, max: 40, doc: 'noise contrast: 1 is as generated, higher is harsher' },
38
+ threshold: { words: 1, kind: 'number', min: 0, max: 1, doc: 'noise cut-off: only values above it show (glitter uses a high one)' },
39
+ contour: { words: 1, kind: 'number', min: 0.005, max: 0.5, doc: 'draw thin lines where the noise crosses the threshold, instead of a plain cut: veins, rings, contours' },
40
+ octaves: { words: 1, kind: 'number', min: 1, max: 8, int: true, doc: 'noise detail layers, 1 is smooth, 5 is rough' },
41
+ seed: { words: 1, kind: 'number', min: 0, max: 99999, int: true, doc: 'a different random pattern with the same look' },
42
+ stretch: { words: 2, kind: 'numbers', min: 0.1, max: 200, doc: 'stretch noise features along x and y, e.g. stretch 40 1 for wood grain' },
43
+ depth: { words: 1, kind: 'number', min: 0, max: 30, doc: 'bump height: how deep the relief looks' },
44
+ light: { words: 1, kind: 'number', min: -360, max: 360, doc: 'bump light direction in degrees' },
45
+ angle: { words: 1, kind: 'number', min: -360, max: 360, doc: 'direction of lines and patterns, in degrees' },
46
+ thickness: { words: 1, kind: 'number', min: 0.1, max: 500, doc: 'line or grid-line width in px' },
47
+ radius: { words: 1, kind: 'number', min: 0.02, max: 1, doc: 'dot size as a fraction of the cell' },
48
+ softness: { words: 1, kind: 'number', min: 0, max: 1, doc: 'how soft the edges of dots and lines are' },
49
+ animate: { words: 1, kind: 'number', min: 0, max: 60000, doc: 'milliseconds per shimmer step (film-grain flicker); 0 is still' },
50
+ type: { words: 1, kind: 'word', values: ['fractal', 'turbulence'], doc: 'noise flavour: fractal is clouds, turbulence is veins' },
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
+
57
+ /* ------------------------------------------------------------------ *
58
+ * presets: a name is a stored set of settings, nothing more
59
+ * layers: one or more { gen, ...settings }. First entries sit on top.
60
+ * ------------------------------------------------------------------ */
61
+
62
+ const K = (r, g, b, a = 1) => `rgba(${r},${g},${b},${a})`;
63
+
64
+ export const PRESETS = {
65
+ // ---- grains and noise
66
+ grain: { doc: 'fine film grain, neutral on any colour', mask: true,
67
+ layers: [{ gen: 'noise', scale: 1.1, octaves: 2, contrast: 1.7, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
68
+ noise: { doc: 'coarser static', mask: true,
69
+ layers: [{ gen: 'noise', scale: 2.6, octaves: 3, contrast: 1.9, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
70
+ rough: { doc: 'gritty relief, like a rough cast wall', mask: true,
71
+ layers: [{ gen: 'bump', scale: 3, octaves: 4, depth: 3.4, light: 135, intensity: 0.75, color: '#000', color2: '#fff', blend: 'overlay' }] },
72
+ sand: { doc: 'warm fine sand grains', mask: true,
73
+ layers: [{ gen: 'bump', scale: 1.5, octaves: 3, depth: 2.2, light: 130, intensity: 0.7, color: '#5a4426', color2: '#fff1d0', blend: 'overlay' }] },
74
+ paper: { doc: 'soft paper fibre with a little unevenness',
75
+ layers: [
76
+ { gen: 'noise', scale: 1.5, octaves: 4, contrast: 1.2, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay' },
77
+ { gen: 'noise', scale: 120, octaves: 3, contrast: 1.3, intensity: 0.25, color: '#000', color2: '#fff', blend: 'soft-light', seed: 7 },
78
+ ] },
79
+ cloud: { doc: 'big soft cloudy variation', mask: true,
80
+ layers: [{ gen: 'noise', scale: 90, octaves: 5, contrast: 1.6, intensity: 0.7, color: '#000', color2: '#fff', blend: 'soft-light' }] },
81
+ concrete: { doc: 'pitted concrete',
82
+ layers: [
83
+ { gen: 'bump', scale: 6, octaves: 5, depth: 2.6, light: 135, intensity: 0.7, color: '#000', color2: '#fff', blend: 'overlay' },
84
+ { gen: 'noise', scale: 140, octaves: 4, contrast: 1.5, intensity: 0.45, color: '#000', color2: '#fff', blend: 'soft-light', seed: 3 },
85
+ ] },
86
+ stone: { doc: 'carved stone with broad relief',
87
+ layers: [{ gen: 'bump', scale: 26, octaves: 5, depth: 2.4, light: 125, intensity: 0.75, color: '#000', color2: '#fff', blend: 'overlay' }] },
88
+ crumpled: { doc: 'crumpled foil or paper, big creases',
89
+ layers: [{ gen: 'bump', scale: 34, octaves: 4, depth: 4, light: 135, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay', seed: 11 }] },
90
+ glitter: { doc: 'sparse bright sparkles', mask: true,
91
+ layers: [{ gen: 'noise', scale: 1.2, octaves: 1, contrast: 16, threshold: 0.66, intensity: 1, color: 'rgba(255,255,255,0)', color2: '#fff', blend: 'screen', seed: 5 }] },
92
+ frost: { doc: 'fine icy crystals',
93
+ layers: [{ gen: 'bump', scale: 2.2, octaves: 4, depth: 3.2, light: 140, intensity: 0.95, color: 'rgba(160,210,255,0)', color2: '#ffffff', blend: 'screen' }] },
94
+ // ---- natural, stretched
95
+ marble: { doc: 'veined marble',
96
+ layers: [
97
+ { gen: 'noise', scale: 120, octaves: 5, contour: 0.035, threshold: 0.5, intensity: 0.9, color: '#6c6877', color2: 'rgba(247,245,240,0)', blend: 'multiply', seed: 4 },
98
+ { gen: 'noise', scale: 120, octaves: 5, contour: 0.16, threshold: 0.5, intensity: 0.25, color: '#9b97a6', color2: 'rgba(247,245,240,0)', blend: 'multiply', seed: 4 },
99
+ { gen: 'noise', scale: 200, octaves: 3, contrast: 1.4, intensity: 0.3, color: '#cfc9c0', color2: '#fbfaf7', blend: 'soft-light', seed: 21 },
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',
106
+ layers: [{ gen: 'bump', scale: 5, octaves: 2, depth: 4.2, light: 120, intensity: 0.85, color: '#000', color2: '#fff', blend: 'overlay' }] },
107
+ brushed: { doc: 'brushed metal, fine lines along x',
108
+ layers: [{ gen: 'noise', scale: 1.2, octaves: 2, contrast: 1.5, stretch: [60, 1], intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
109
+ // ---- woven
110
+ canvas: { doc: 'artist canvas weave',
111
+ layers: [
112
+ { gen: 'weave', scale: 3, thickness: 1, intensity: 0.5, color: '#000', blend: 'multiply' },
113
+ { gen: 'noise', scale: 1.4, octaves: 3, contrast: 1.3, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay' },
114
+ ] },
115
+ linen: { doc: 'loose linen weave with slubs',
116
+ layers: [
117
+ { gen: 'weave', scale: 4, thickness: 1.2, intensity: 0.4, color: '#5a4a32', blend: 'multiply' },
118
+ { gen: 'noise', scale: 80, octaves: 3, contrast: 1.4, stretch: [8, 1], intensity: 0.25, color: '#000', color2: '#fff', blend: 'soft-light', seed: 9 },
119
+ ] },
120
+ denim: { doc: 'diagonal twill, blue by default',
121
+ layers: [
122
+ { gen: 'lines', angle: 45, scale: 3, thickness: 1.4, intensity: 0.45, color: '#0d2147', blend: 'multiply' },
123
+ { gen: 'noise', scale: 1.3, octaves: 3, contrast: 1.4, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay', seed: 2 },
124
+ ] },
125
+ carbon: { doc: 'carbon fibre twill',
126
+ layers: [{ gen: 'carbon', scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' }] },
127
+ // ---- patterns
128
+ scanlines: { doc: 'CRT scanlines',
129
+ layers: [{ gen: 'lines', angle: 0, scale: 4, thickness: 2, intensity: 0.28, color: '#000', blend: 'normal' }] },
130
+ halftone: { doc: 'newsprint dots',
131
+ layers: [{ gen: 'dots', scale: 8, radius: 0.2, softness: 0.2, intensity: 0.55, color: '#000', blend: 'multiply', stagger: true }] },
132
+ dots: { doc: 'a plain dot grid',
133
+ layers: [{ gen: 'dots', scale: 14, radius: 0.16, softness: 0.05, intensity: 0.35, color: '#000', blend: 'normal' }] },
134
+ lines: { doc: 'diagonal hairlines',
135
+ layers: [{ gen: 'lines', angle: 45, scale: 8, thickness: 1, intensity: 0.3, color: '#000', blend: 'normal' }] },
136
+ grid: { doc: 'a square grid, like graph paper',
137
+ layers: [{ gen: 'grid', scale: 24, thickness: 1, intensity: 0.25, color: '#000', blend: 'normal' }] },
138
+ checker: { doc: 'checkerboard',
139
+ layers: [{ gen: 'checker', scale: 24, intensity: 0.12, color: '#000', blend: 'normal' }] },
140
+ crosshatch: { doc: 'crossed hairlines',
141
+ layers: [
142
+ { gen: 'lines', angle: 45, scale: 6, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
143
+ { gen: 'lines', angle: -45, scale: 6, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
144
+ ] },
145
+ stripes: { doc: 'bold diagonal stripes',
146
+ layers: [{ gen: 'lines', angle: 45, scale: 32, thickness: 16, intensity: 0.12, color: '#000', blend: 'normal' }] },
147
+ waves: { doc: 'soft repeating waves',
148
+ layers: [{ gen: 'waves', scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' }] },
149
+ hex: { doc: 'honeycomb',
150
+ layers: [{ gen: 'hex', scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' }] },
151
+ };
152
+
153
+ export const PRESET_NAMES = Object.keys(PRESETS);
154
+
155
+ /** baseline settings per generator, used when you start from a bare generator name */
156
+ const GEN_DEFAULTS = {
157
+ noise: { scale: 3, octaves: 3, contrast: 1.5, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay', type: 'fractal' },
158
+ bump: { scale: 6, octaves: 4, depth: 3, light: 135, intensity: 0.6, color: '#000', color2: '#fff', blend: 'overlay' },
159
+ lines: { scale: 8, thickness: 1, angle: 45, intensity: 0.3, color: '#000', blend: 'normal' },
160
+ dots: { scale: 12, radius: 0.2, softness: 0.1, intensity: 0.4, color: '#000', blend: 'normal' },
161
+ grid: { scale: 24, thickness: 1, intensity: 0.25, color: '#000', blend: 'normal' },
162
+ checker: { scale: 24, intensity: 0.15, color: '#000', blend: 'normal' },
163
+ weave: { scale: 4, thickness: 1, intensity: 0.4, color: '#000', blend: 'multiply' },
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
+ /* ------------------------------------------------------------------ *
170
+ * parsing (compile time)
171
+ * ------------------------------------------------------------------ */
172
+
173
+ /** split on top-level commas ("grain, scanlines" or "rgb(1, 2, 3)" stays whole) */
174
+ function splitCommas(s) {
175
+ const out = []; let depth = 0, cur = '';
176
+ for (const ch of s) {
177
+ if (ch === '(') depth++;
178
+ if (ch === ')') depth--;
179
+ if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
180
+ cur += ch;
181
+ }
182
+ if (cur.trim()) out.push(cur.trim());
183
+ return out;
184
+ }
185
+
186
+ /** split on top-level whitespace, keeping rgb(1 2 3) and color.ink/40 as one word */
187
+ export function splitWords(s) {
188
+ const out = []; let depth = 0, cur = '';
189
+ for (const ch of s.trim()) {
190
+ if (ch === '(') depth++;
191
+ if (ch === ')') depth--;
192
+ if (/\s/.test(ch) && depth === 0) { if (cur) out.push(cur); cur = ''; continue; }
193
+ cur += ch;
194
+ }
195
+ if (cur) out.push(cur);
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * Parse the value of `texture:`.
201
+ * hooks.error(message, hint) report a problem
202
+ * hooks.color(word) turn a colour word into the CSS the engine will resolve later
203
+ * hooks.suggest(word, list) did-you-mean
204
+ * Returns an array of layer specs, [] for `none`, or null when it was invalid.
205
+ */
206
+ export function parseTexture(value, hooks) {
207
+ const text = String(value || '').trim();
208
+ if (!text) { hooks.error('texture needs a name', `try texture: grain, or one of ${PRESET_NAMES.slice(0, 6).join(', ')} ...`); return null; }
209
+ if (text === 'none') return [];
210
+ const out = [];
211
+ for (const part of splitCommas(text)) {
212
+ const words = splitWords(part);
213
+ const first = words[0];
214
+ let base;
215
+ if (PRESETS[first]) base = PRESETS[first].layers.map((l) => ({ ...l, preset: first }));
216
+ else if (GENERATORS.includes(first)) base = [{ gen: first, preset: null, ...GEN_DEFAULTS[first] }];
217
+ else {
218
+ hooks.error(`unknown texture "${first}"`, hooks.suggest(first, [...PRESET_NAMES, ...GENERATORS]) || `presets: ${PRESET_NAMES.join(', ')}. generators: ${GENERATORS.join(', ')}`);
219
+ return null;
220
+ }
221
+ const over = {};
222
+ for (let i = 1; i < words.length; i++) {
223
+ const key = words[i];
224
+ const def = TEXTURE_KEYS[key];
225
+ if (!def) {
226
+ hooks.error(`unknown texture setting "${key}"`, hooks.suggest(key, Object.keys(TEXTURE_KEYS)) || `settings: ${Object.keys(TEXTURE_KEYS).join(', ')}`);
227
+ return null;
228
+ }
229
+ if (def.kind === 'flag') { over[key] = true; continue; }
230
+ const vals = words.slice(i + 1, i + 1 + def.words);
231
+ if (vals.length < def.words || vals.some((v) => TEXTURE_KEYS[v])) {
232
+ hooks.error(`texture setting "${key}" needs ${def.words === 1 ? 'a value' : def.words + ' values'}`, def.doc);
233
+ return null;
234
+ }
235
+ i += def.words;
236
+ if (def.kind === 'number' || def.kind === 'numbers') {
237
+ const nums = vals.map((v) => Number(v));
238
+ if (nums.some((n) => Number.isNaN(n))) { hooks.error(`texture setting "${key}" needs a number, got "${vals.join(' ')}"`, def.doc); return null; }
239
+ if (nums.some((n) => n < def.min || n > def.max)) { hooks.error(`texture "${key}" must be between ${def.min} and ${def.max}`, def.doc); return null; }
240
+ over[key] = def.kind === 'numbers' ? nums : (def.int ? Math.round(nums[0]) : nums[0]);
241
+ } else if (def.kind === 'word') {
242
+ if (!def.values.includes(vals[0])) {
243
+ hooks.error(`unknown ${key} "${vals[0]}"`, hooks.suggest(vals[0], def.values) || `use one of: ${def.values.join(', ')}`);
244
+ return null;
245
+ }
246
+ over[key] = vals[0];
247
+ } else if (def.kind === 'color') {
248
+ const css = hooks.color(vals[0]);
249
+ if (css === null) return null;
250
+ over[key] = css;
251
+ }
252
+ }
253
+ // settings given in a custom texture apply to every layer of a multi-layer preset
254
+ // (color settings apply to the first layer only when the preset has several, so a
255
+ // two-layer preset keeps its own second tone)
256
+ base.forEach((layer, idx) => {
257
+ const merged = { ...layer };
258
+ for (const [k, v] of Object.entries(over)) {
259
+ if ((k === 'color' || k === 'color2') && base.length > 1 && idx > 0) continue;
260
+ merged[k] = v;
261
+ }
262
+ out.push(merged);
263
+ });
264
+ }
265
+ return out;
266
+ }
267
+
268
+ /* ------------------------------------------------------------------ *
269
+ * building (run time, in the browser, or in tests)
270
+ * ------------------------------------------------------------------ */
271
+
272
+ const svgUrl = (svg) => `url("data:image/svg+xml,${encodeURIComponent(svg).replace(/'/g, '%27')}")`;
273
+ const f = (n, d = 3) => String(+Number(n).toFixed(d));
274
+ const n01 = (x) => Math.max(0, Math.min(1, x));
275
+
276
+ /** resolve a layer's two colours into rgba objects with intensity applied */
277
+ function layerColors(spec, rc) {
278
+ let a = rc(spec.color) || parseColor('#000');
279
+ let b = rc(spec.color2) || (spec.color2 === undefined ? null : parseColor('#fff'));
280
+ const k = spec.intensity === undefined ? 1 : spec.intensity;
281
+ if (spec.invert && b) [a, b] = [b, a];
282
+ const scaleA = (c) => (c ? withAlpha(c, n01(c.a * k)) : null);
283
+ return { a: scaleA(a), b: scaleA(b), raw: { a, b } };
284
+ }
285
+
286
+ function noiseFreq(spec) {
287
+ const base = 1 / Math.max(0.2, spec.scale || 3);
288
+ const st = spec.stretch || [1, 1];
289
+ let fx = base / (st[0] || 1), fy = base / (st[1] || 1);
290
+ if (spec.angle === 90 || spec.angle === -90 || spec.angle === 270) [fx, fy] = [fy, fx];
291
+ return [Math.max(0.0008, fx), Math.max(0.0008, fy)];
292
+ }
293
+
294
+ const tileFor = (spec) => {
295
+ const f2 = noiseFreq(spec);
296
+ // keep tiles reasonably small, but big enough to hide repetition for coarse noise
297
+ const feature = 1 / Math.min(f2[0], f2[1]);
298
+ return Math.round(Math.max(128, Math.min(512, feature * 3)));
299
+ };
300
+
301
+ /** a lookup table that is 0 right at `level` and rises to 1 by `width` away: contour lines */
302
+ function contourTable(level, width) {
303
+ const n = 96, out = [];
304
+ for (let i = 0; i < n; i++) out.push(f(Math.min(1, Math.abs(i / (n - 1) - level) / width), 3));
305
+ return out.join(' ');
306
+ }
307
+
308
+ /** noise → contrast curve → two-colour ramp. The workhorse. */
309
+ function buildNoise(spec, rc) {
310
+ const { a, b } = layerColors(spec, rc);
311
+ const A = a, B = b || a;
312
+ const [fx, fy] = noiseFreq(spec);
313
+ const oct = Math.max(1, Math.round(spec.octaves || 3));
314
+ const c = spec.contrast === undefined ? 1.5 : spec.contrast;
315
+ const t = spec.threshold === undefined ? 0.5 : spec.threshold;
316
+ const off = 0.5 - c * t;
317
+ const T = tileFor(spec);
318
+ const ch = (x) => f(x / 255);
319
+ const svg =
320
+ `<svg xmlns='http://www.w3.org/2000/svg' width='${T}' height='${T}'>` +
321
+ `<filter id='f' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>` +
322
+ `<feTurbulence type='${spec.type === 'turbulence' ? 'turbulence' : 'fractalNoise'}' baseFrequency='${f(fx, 5)} ${f(fy, 5)}' numOctaves='${oct}' seed='${spec.seed || 0}' stitchTiles='stitch'/>` +
323
+ (spec.contour
324
+ ? `<feColorMatrix type='matrix' values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/>` +
325
+ `<feComponentTransfer><feFuncR type='table' tableValues='${contourTable(t, spec.contour)}'/><feFuncG type='table' tableValues='${contourTable(t, spec.contour)}'/><feFuncB type='table' tableValues='${contourTable(t, spec.contour)}'/></feComponentTransfer>`
326
+ : `<feColorMatrix type='matrix' values='${f(c)} 0 0 0 ${f(off)} ${f(c)} 0 0 0 ${f(off)} ${f(c)} 0 0 0 ${f(off)} 0 0 0 0 1'/>`) +
327
+ `<feColorMatrix type='matrix' values='${f(ch(B.r - A.r))} 0 0 0 ${ch(A.r)} ${f(ch(B.g - A.g))} 0 0 0 ${ch(A.g)} ${f(ch(B.b - A.b))} 0 0 0 ${ch(A.b)} ${f(B.a - A.a)} 0 0 0 ${f(A.a)}'/>` +
328
+ `</filter><rect width='100%' height='100%' filter='url(#f)'/></svg>`;
329
+ return [{ image: svgUrl(svg), size: `${T}px ${T}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'overlay', tile: T }];
330
+ }
331
+
332
+ /**
333
+ * Noise used as a height map and lit from one side, so it reads as physical
334
+ * relief. The lighting is a 3x3 directional-derivative convolution with
335
+ * edgeMode="wrap", which keeps the tile seamless. feDiffuseLighting looks
336
+ * nicer up close but treats the tile edge specially and leaves a 1px seam.
337
+ */
338
+ function buildBump(spec, rc) {
339
+ const { a, b } = layerColors(spec, rc);
340
+ const A = a, B = b || a;
341
+ const [fx, fy] = noiseFreq(spec);
342
+ const oct = Math.max(1, Math.round(spec.octaves || 4));
343
+ const T = tileFor(spec);
344
+ const az = ((spec.light === undefined ? 135 : spec.light) * Math.PI) / 180;
345
+ // relief strength grows slowly with feature size: big features have gentle slopes, but the fine
346
+ // octaves layered on top keep their steep ones, so a linear rule over-drives anything coarse
347
+ const m = (spec.depth === undefined ? 3 : spec.depth) * 0.33 * Math.pow(Math.max(0.5, spec.scale || 6), 0.25);
348
+ const gx = [-1, 0, 1, -2, 0, 2, -1, 0, 1], gy = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
349
+ const kernel = gx.map((v, i) => f((m * (Math.cos(az) * v + Math.sin(az) * gy[i])) / 4, 4)).join(' ');
350
+ const ch = (x) => f(x / 255);
351
+ const svg =
352
+ `<svg xmlns='http://www.w3.org/2000/svg' width='${T}' height='${T}'>` +
353
+ `<filter id='f' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>` +
354
+ `<feTurbulence type='fractalNoise' baseFrequency='${f(fx, 5)} ${f(fy, 5)}' numOctaves='${oct}' seed='${spec.seed || 0}' stitchTiles='stitch'/>` +
355
+ `<feColorMatrix type='matrix' values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/>` +
356
+ `<feConvolveMatrix order='3' kernelMatrix='${kernel}' divisor='1' bias='0.5' edgeMode='wrap' preserveAlpha='true'/>` +
357
+ `<feColorMatrix type='matrix' values='${f(ch(B.r - A.r))} 0 0 0 ${ch(A.r)} ${f(ch(B.g - A.g))} 0 0 0 ${ch(A.g)} ${f(ch(B.b - A.b))} 0 0 0 ${ch(A.b)} ${f(B.a - A.a)} 0 0 0 ${f(A.a)}'/>` +
358
+ `</filter><rect width='100%' height='100%' filter='url(#f)'/></svg>`;
359
+ return [{ image: svgUrl(svg), size: `${T}px ${T}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'overlay', tile: T }];
360
+ }
361
+
362
+ function buildLines(spec, rc) {
363
+ const { a, b } = layerColors(spec, rc);
364
+ const p = Math.max(1, spec.scale || 8), w = Math.min(p, Math.max(0.2, spec.thickness || 1));
365
+ const soft = (spec.softness === undefined ? 0.4 : spec.softness) * 1.5;
366
+ const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
367
+ const ang = spec.angle === undefined ? 45 : spec.angle;
368
+ const image = `repeating-linear-gradient(${f(-ang, 1)}deg, ${ink} 0, ${ink} ${f(w)}px, ${bg} ${f(w + soft)}px, ${bg} ${f(p)}px)`;
369
+ return [{ image, size: 'auto', repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: p }];
370
+ }
371
+
372
+ function buildDots(spec, rc) {
373
+ const { a, b } = layerColors(spec, rc);
374
+ const S = Math.max(2, spec.scale || 12), r = (spec.radius === undefined ? 0.2 : spec.radius) * S;
375
+ const soft = Math.max(0.3, (spec.softness === undefined ? 0.1 : spec.softness) * S);
376
+ const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
377
+ const dot = `radial-gradient(circle at center, ${ink} 0, ${ink} ${f(Math.max(0, r - soft / 2))}px, ${bg} ${f(r + soft / 2)}px, ${bg} 100%)`;
378
+ const layers = [{ image: dot, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S }];
379
+ if (spec.stagger) layers.push({ image: dot, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: `${f(S / 2)}px ${f(S / 2)}px`, blend: spec.blend || 'normal', tile: S });
380
+ return layers;
381
+ }
382
+
383
+ function buildGrid(spec, rc) {
384
+ const { a, b } = layerColors(spec, rc);
385
+ const S = Math.max(3, spec.scale || 24), w = Math.max(0.3, spec.thickness || 1);
386
+ const ink = toRgba(a);
387
+ const blend = spec.blend || 'normal';
388
+ const out = [
389
+ { image: `linear-gradient(${ink} ${f(w)}px, transparent 0)`, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend, tile: S },
390
+ { image: `linear-gradient(90deg, ${ink} ${f(w)}px, transparent 0)`, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend, tile: S },
391
+ ];
392
+ if (b) out.push({ image: `linear-gradient(${toRgba(b)}, ${toRgba(b)})`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: S });
393
+ return out;
394
+ }
395
+
396
+ function buildChecker(spec, rc) {
397
+ const { a, b } = layerColors(spec, rc);
398
+ const S = Math.max(2, spec.scale || 24);
399
+ const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
400
+ const image = `conic-gradient(${ink} 25%, ${bg} 0 50%, ${ink} 0 75%, ${bg} 0)`;
401
+ return [{ image, size: `${f(S * 2)}px ${f(S * 2)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S * 2 }];
402
+ }
403
+
404
+ /** plain-weave threads: horizontal and vertical fine lines */
405
+ function buildWeave(spec, rc) {
406
+ const { a } = layerColors(spec, rc);
407
+ const p = Math.max(1.5, spec.scale || 4), w = Math.max(0.4, spec.thickness || 1);
408
+ const ink = toRgba(a), clear = toRgba(withAlpha(a.r === undefined ? parseColor('#000') : a, 0));
409
+ const blend = spec.blend || 'multiply';
410
+ return [
411
+ { image: `repeating-linear-gradient(0deg, ${ink} 0, ${ink} ${f(w)}px, ${clear} ${f(w + 0.6)}px, ${clear} ${f(p)}px)`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: p },
412
+ { image: `repeating-linear-gradient(90deg, ${ink} 0, ${ink} ${f(w)}px, ${clear} ${f(w + 0.6)}px, ${clear} ${f(p)}px)`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: p },
413
+ ];
414
+ }
415
+
416
+ /** the classic layered-gradient carbon fibre, recoloured from color and color2 */
417
+ function buildCarbon(spec, rc) {
418
+ const { a, b } = layerColors(spec, rc);
419
+ const A = a, B = b || a;
420
+ const dark = toRgba(A), light = toRgba(B);
421
+ const mid = toRgba(mix2(A, B, 0.5)), deep = toRgba(mix2(A, parseColor('#000'), 0.25));
422
+ const S = Math.max(8, spec.scale || 20), h = S / 2, q = S / 4;
423
+ const blend = spec.blend || 'normal';
424
+ const sz = `${f(S)}px ${f(S)}px`;
425
+ const L = (image, size, position) => ({ image, size, repeat: 'repeat', position, blend, tile: S });
426
+ return [
427
+ L(`linear-gradient(27deg, ${dark} ${f(q)}px, transparent ${f(q)}px)`, sz, `0 ${f(q)}px`),
428
+ L(`linear-gradient(207deg, ${dark} ${f(q)}px, transparent ${f(q)}px)`, sz, `${f(h)}px 0`),
429
+ L(`linear-gradient(27deg, ${light} ${f(q)}px, transparent ${f(q)}px)`, sz, `0 ${f(h)}px`),
430
+ L(`linear-gradient(207deg, ${light} ${f(q)}px, transparent ${f(q)}px)`, sz, `${f(h)}px ${f(q)}px`),
431
+ L(`linear-gradient(90deg, ${mid} ${f(h)}px, transparent ${f(h)}px)`, sz, '0 0'),
432
+ L(`linear-gradient(${light} 25%, ${dark} 25%, ${dark} 50%, transparent 50%, transparent 75%, ${mid} 75%, ${mid})`, sz, '0 0'),
433
+ L(`linear-gradient(${deep}, ${deep})`, 'auto', '0 0'),
434
+ ];
435
+ }
436
+
437
+ function buildWaves(spec, rc) {
438
+ const { a } = layerColors(spec, rc);
439
+ const S = Math.max(8, spec.scale || 40), w = Math.max(0.3, spec.thickness || 1.2);
440
+ const col = toRgba(a);
441
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${f(S)}' height='${f(S / 2)}' viewBox='0 0 ${f(S)} ${f(S / 2)}'>` +
442
+ `<path d='M0 ${f(S / 4)} Q ${f(S / 4)} 0 ${f(S / 2)} ${f(S / 4)} T ${f(S)} ${f(S / 4)}' fill='none' stroke='${col}' stroke-width='${f(w)}'/></svg>`;
443
+ return [{ image: svgUrl(svg), size: `${f(S)}px ${f(S / 2)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S }];
444
+ }
445
+
446
+ function buildHex(spec, rc) {
447
+ const { a } = layerColors(spec, rc);
448
+ const W = Math.max(14, spec.scale || 28), k = W / 56;
449
+ const col = toRgba(a), sw = Math.max(0.3, spec.thickness || 1) / k;
450
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${f(W)}' height='${f(100 * k)}' viewBox='0 0 56 100'>` +
451
+ `<path d='M28 66L0 50L0 16L28 0L56 16L56 50L28 66L28 100M28 0L28 34L0 50L0 84L28 100L56 84L56 50L28 34' fill='none' stroke='${col}' stroke-width='${f(sw)}'/></svg>`;
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,
459
+ * -position and -blend-mode, top layer first.
460
+ * rc(cssColorWord) -> {r,g,b,a} | null
461
+ */
462
+ export function buildTexture(layers, rc) {
463
+ const parts = [];
464
+ for (const spec of layers) {
465
+ const builder = BUILDERS[spec.gen];
466
+ if (!builder) continue;
467
+ for (const piece of builder(spec, rc)) parts.push({ ...piece, animate: spec.animate || 0 });
468
+ }
469
+ return {
470
+ image: parts.map((p) => p.image).join(', '),
471
+ size: parts.map((p) => p.size).join(', '),
472
+ repeat: parts.map((p) => p.repeat).join(', '),
473
+ position: parts.map((p) => p.position).join(', '),
474
+ blend: parts.map((p) => p.blend).join(', '),
475
+ count: parts.length,
476
+ parts,
477
+ };
478
+ }
479
+
480
+ /* ------------------------------------------------------------------ *
481
+ * deterministic value noise, for aura masks and tests
482
+ * ------------------------------------------------------------------ */
483
+
484
+ /** small fast seeded PRNG (mulberry32) */
485
+ export function rng(seed) {
486
+ let a = (seed >>> 0) || 1;
487
+ return () => {
488
+ a |= 0; a = (a + 0x6d2b79f5) | 0;
489
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
490
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
491
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
492
+ };
493
+ }
494
+
495
+ /**
496
+ * An alpha mask (Uint8Array of size*size) for the textures that make sense as one.
497
+ * Used by aura so particles and glow can be grainy, dotted, scanned. Tileable.
498
+ */
499
+ export function makeMask(layer, size = 128) {
500
+ const out = new Uint8Array(size * size);
501
+ const gen = layer.gen;
502
+ const k = layer.intensity === undefined ? 1 : Math.min(1, layer.intensity);
503
+ if (gen === 'lines') {
504
+ const p = Math.max(2, layer.scale || 8), w = layer.thickness || 1, ang = ((layer.angle || 0) * Math.PI) / 180;
505
+ // normal to a line whose direction is (cos a, -sin a) in screen coordinates
506
+ const c = Math.sin(ang), s = Math.cos(ang);
507
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
508
+ const d = (((x * c + y * s) % p) + p) % p;
509
+ out[y * size + x] = d < w ? 255 * k : 0;
510
+ }
511
+ return out;
512
+ }
513
+ if (gen === 'dots') {
514
+ const S = Math.max(2, layer.scale || 12), r = (layer.radius === undefined ? 0.2 : layer.radius) * S;
515
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
516
+ const dx = (x % S) - S / 2, dy = (y % S) - S / 2;
517
+ out[y * size + x] = Math.hypot(dx, dy) < r ? 255 * k : 0;
518
+ }
519
+ return out;
520
+ }
521
+ // everything else: value-noise fbm, tiled
522
+ const rand = rng((layer.seed || 0) + 17);
523
+ const cell = Math.max(1, Math.round(layer.scale || 3));
524
+ const gridN = Math.max(2, Math.floor(size / cell));
525
+ const lat = new Float32Array(gridN * gridN);
526
+ for (let i = 0; i < lat.length; i++) lat[i] = rand();
527
+ const oct = Math.max(1, Math.min(6, layer.octaves || 3));
528
+ const contrast = layer.contrast === undefined ? 1.5 : layer.contrast;
529
+ const thr = layer.threshold === undefined ? 0.5 : layer.threshold;
530
+ const smooth = (t) => t * t * (3 - 2 * t);
531
+ const sample = (u, v, n) => {
532
+ const g = Math.max(2, Math.round(n));
533
+ const x = ((u % 1) + 1) % 1 * g, y = ((v % 1) + 1) % 1 * g;
534
+ const x0 = Math.floor(x), y0 = Math.floor(y), fx = smooth(x - x0), fy = smooth(y - y0);
535
+ const at = (ix, iy) => lat[(((iy % gridN) + gridN) % gridN) * gridN + (((ix % gridN) + gridN) % gridN)];
536
+ const a = at(x0, y0), b = at(x0 + 1, y0), c = at(x0, y0 + 1), d = at(x0 + 1, y0 + 1);
537
+ return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
538
+ };
539
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
540
+ let v = 0, amp = 0.5, n = gridN, tot = 0;
541
+ for (let o = 0; o < oct; o++) { v += sample(x / size, y / size, n) * amp; tot += amp; amp *= 0.5; n *= 2; }
542
+ v /= tot;
543
+ const m = Math.max(0, Math.min(1, contrast * (v - thr) + 0.5));
544
+ out[y * size + x] = 255 * m * k;
545
+ }
546
+ return out;
547
+ }
dssl-vscode/lib/engine/utils.js CHANGED
@@ -1,4 +1,4 @@
1
- /* DSSL — utils.js — tiny shared helpers */
2
 
3
  export function levenshtein(a, b) {
4
  if (a === b) return 0;
@@ -34,13 +34,13 @@ export function didYouMean(word, candidates) {
34
  }
35
 
36
  /* ------------------------------------------------------------------ *
37
- * v0.6.0 — the deform family (bend / bend-y / bend-up / fold / stretch)
38
  *
39
  * These are engine-native properties (`@bend`, …) that compose into ONE
40
  * transform string, so transitions and WAAPI tween them like any other
41
  * transform. Edge-pinned members (fold plants the top edge, bend-up plants
42
  * the bottom edge) bake their pivot into the transform with the classic
43
- * translate · rotate · translate trick — that way they never fight a user
44
  * transform-origin. In keyframe mode there is no box yet, so they emit a
45
  * transformOrigin for WAAPI instead.
46
  * ------------------------------------------------------------------ */
@@ -50,10 +50,10 @@ const r4 = (v) => Math.round(v * 1e4) / 1e4;
50
 
51
  /**
52
  * Compose deform parts (+ optional base transform) into a transform string.
53
- * d — { bend, bendY, stretch, stretchAxis, fold, bendUp } numbers, 0 = off
54
- * base — an existing transform string to run after the deform parts, or ''
55
- * box — { h } element height for edge-pinned pivots; null in keyframe mode
56
- * Returns { transform, origin } — origin is only set in keyframe mode.
57
  */
58
  export function composeDeform(d, base, box) {
59
  const parts = [];
@@ -71,7 +71,7 @@ export function composeDeform(d, base, box) {
71
  if (h > 0) parts.push(`translateY(${r2(h / 2)}px)`, `rotateX(${d.bendUp}deg)`, `translateY(${r2(-h / 2)}px)`);
72
  else { parts.push(`rotateX(${d.bendUp}deg)`); origin = '50% 100%'; }
73
  }
74
- if (d.stretch && d.stretch !== 1) { // squash & stretch — volume stays constant
75
  const inv = r4(1 / d.stretch);
76
  parts.push(d.stretchAxis === 'vertical'
77
  ? `scaleY(${d.stretch}) scaleX(${inv})`
@@ -83,7 +83,7 @@ export function composeDeform(d, base, box) {
83
  }
84
 
85
  /**
86
- * v0.6.0 — bend-around: lay n children along a circular arc.
87
  * Positive deg wraps the row around an invisible ball (a dome, ends dip);
88
  * negative deg opens it into a smile (ends rise). Returns the pivot radius
89
  * R (px, measured from each child's center) and one rotation angle per
 
1
+ /* DSSL - utils.js - tiny shared helpers */
2
 
3
  export function levenshtein(a, b) {
4
  if (a === b) return 0;
 
34
  }
35
 
36
  /* ------------------------------------------------------------------ *
37
+ * v0.6.0 - the deform family (bend / bend-y / bend-up / fold / stretch)
38
  *
39
  * These are engine-native properties (`@bend`, …) that compose into ONE
40
  * transform string, so transitions and WAAPI tween them like any other
41
  * transform. Edge-pinned members (fold plants the top edge, bend-up plants
42
  * the bottom edge) bake their pivot into the transform with the classic
43
+ * translate · rotate · translate trick - that way they never fight a user
44
  * transform-origin. In keyframe mode there is no box yet, so they emit a
45
  * transformOrigin for WAAPI instead.
46
  * ------------------------------------------------------------------ */
 
50
 
51
  /**
52
  * Compose deform parts (+ optional base transform) into a transform string.
53
+ * d - { bend, bendY, stretch, stretchAxis, fold, bendUp } numbers, 0 = off
54
+ * base - an existing transform string to run after the deform parts, or ''
55
+ * box - { h } element height for edge-pinned pivots; null in keyframe mode
56
+ * Returns { transform, origin } - origin is only set in keyframe mode.
57
  */
58
  export function composeDeform(d, base, box) {
59
  const parts = [];
 
71
  if (h > 0) parts.push(`translateY(${r2(h / 2)}px)`, `rotateX(${d.bendUp}deg)`, `translateY(${r2(-h / 2)}px)`);
72
  else { parts.push(`rotateX(${d.bendUp}deg)`); origin = '50% 100%'; }
73
  }
74
+ if (d.stretch && d.stretch !== 1) { // squash & stretch - volume stays constant
75
  const inv = r4(1 / d.stretch);
76
  parts.push(d.stretchAxis === 'vertical'
77
  ? `scaleY(${d.stretch}) scaleX(${inv})`
 
83
  }
84
 
85
  /**
86
+ * v0.6.0 - bend-around: lay n children along a circular arc.
87
  * Positive deg wraps the row around an invisible ball (a dome, ends dip);
88
  * negative deg opens it into a smile (ends rise). Returns the pivot radius
89
  * R (px, measured from each child's center) and one rotation angle per
dssl-vscode/lib/props-docs.js CHANGED
@@ -1,14 +1,14 @@
1
- /* props-docs.js — the DSSL vocabulary with one-line docs.
2
  * Feeds completions and hovers. Semantic vocabulary entries carry real
3
  * explanations; passthrough CSS properties get a short reminder that they
4
  * pass through untouched. */
5
 
6
  export const SEMANTIC = {
7
- row: 'flex row in one line: `row: gap space.4 between center` sets display:flex, flex-direction, gap, justify-content, align-items. Warning: it writes display — put explicit display: before it, not after.',
8
  column: 'flex column in one line: `column: gap 2`. Same display caveat as row: the shorthand wins if it comes later in the rule.',
9
  grid: 'grid layout in one line: `grid: 3 gap 12` = 3 equal columns with a 12px gap.',
10
  stack: 'everything stacked and centered: `stack: center`. Flex column + center on both axes.',
11
- center: 'alignment that cannot silently no-op — the engine promotes the element to a grid formatting context when needed: `center: both`.',
12
  size: 'width and height in one line: `size: 40 20` = 40 wide, 20 tall. `size: 40` = both.',
13
  shadow: 'box shadow from the shadow token scale: `shadow: sm | md | lg | xl`. Custom: `shadow: 0 2 4 ink/10, 0 4 12 ink/8`.',
14
  radius: 'border-radius, bare number or token: `radius: md`, `radius: full`, `radius: 14`.',
@@ -29,6 +29,16 @@ export const SEMANTIC = {
29
  'visually-hidden': 'hidden from eyes, visible to screen readers.',
30
  cover: 'fills the containing block absolutely.',
31
  use: 'apply a behavior: `use focus-ring`. Behaviors are named rule groups declared with `behavior name { }`.',
 
 
 
 
 
 
 
 
 
 
32
  seen: 'state that fires ONCE when the element first scrolls into view. Reveal-on-scroll with zero JavaScript. Composes with stagger. Cannot be combined with pseudo-elements.',
33
  };
34
 
 
1
+ /* props-docs.js - the DSSL vocabulary with one-line docs.
2
  * Feeds completions and hovers. Semantic vocabulary entries carry real
3
  * explanations; passthrough CSS properties get a short reminder that they
4
  * pass through untouched. */
5
 
6
  export const SEMANTIC = {
7
+ row: 'flex row in one line: `row: gap space.4 between center` sets display:flex, flex-direction, gap, justify-content, align-items. Warning: it writes display - put explicit display: before it, not after.',
8
  column: 'flex column in one line: `column: gap 2`. Same display caveat as row: the shorthand wins if it comes later in the rule.',
9
  grid: 'grid layout in one line: `grid: 3 gap 12` = 3 equal columns with a 12px gap.',
10
  stack: 'everything stacked and centered: `stack: center`. Flex column + center on both axes.',
11
+ center: 'alignment that cannot silently no-op - the engine promotes the element to a grid formatting context when needed: `center: both`.',
12
  size: 'width and height in one line: `size: 40 20` = 40 wide, 20 tall. `size: 40` = both.',
13
  shadow: 'box shadow from the shadow token scale: `shadow: sm | md | lg | xl`. Custom: `shadow: 0 2 4 ink/10, 0 4 12 ink/8`.',
14
  radius: 'border-radius, bare number or token: `radius: md`, `radius: full`, `radius: 14`.',
 
29
  'visually-hidden': 'hidden from eyes, visible to screen readers.',
30
  cover: 'fills the containing block absolutely.',
31
  use: 'apply a behavior: `use focus-ring`. Behaviors are named rule groups declared with `behavior name { }`.',
32
+ texture: 'a generated texture layered ON TOP of the element\u2019s own background: `texture: grain`, `texture: wood scale 6 intensity 0.8`, `texture: grain, scanlines`. 30 presets (grain, paper, marble, wood, carbon, halftone, ...) or build your own from a generator (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex) plus settings (scale, intensity, contrast, octaves, color, color2, blend, stretch, contour, ...). Open the designer to see them all.',
33
+ aura: 'light and particles around an element, with NO presets: you describe it. `aura: glow #ff7a00 30 flicker 0.3, particles #fd0 -> #f60 count 60 shape flame glowing behind`. Replaces both slots. `aura: none` clears them.',
34
+ 'aura-glow': 'just the glow slot: `aura-glow: color.accent 24 pulse 1600`. Positional colour and radius, then settings (intensity, pulse, depth, flicker, texture(...), seed, essential). `aura-glow: none` clears only the glow.',
35
+ 'aura-particle': 'just the particles slot: `aura-particle: #7cf -> transparent 40 up size 6 life 800 speed 30 from bottom`. Settings: count, size, life, speed, dir, spread, gravity, drag, turbulence, from, shape, grow, glowing, behind, blend, texture(...), seed, essential.',
36
+ 'aura-particles': 'the same as `aura-particle`.',
37
+ 'glow-particle': 'the particles slot with every particle glowing (additive light): `glow-particle: #fff 30 out`.',
38
+ 'glow-particles': 'the same as `glow-particle`.',
39
+ 'aura-glow-particle': 'both slots at once, settings shared: `aura-glow-particle: #ff7a00 28 pulse 900 count 50 up`. A glow layer AND a glowing particles layer.',
40
+ 'aura-glow-particles': 'the same as `aura-glow-particle`.',
41
+ slider: 'styles `<input type="range">` through the WebKit/Blink (`::-webkit-slider-*`) and Firefox (`::-moz-range-*`) pseudo-elements at once: `slider: track 6 color.line radius full, fill color.accent, thumb 22 color.paper, ring 3 color.accent, glow color.accent 14, ticks 10`. Add `graph 20 45 70 90 60` for a histogram slider.',
42
  seen: 'state that fires ONCE when the element first scrolls into view. Reveal-on-scroll with zero JavaScript. Composes with stagger. Cannot be combined with pseudo-elements.',
43
  };
44
 
dssl-vscode/media/designer-core.js ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL designer: designer-core.js
3
+ *
4
+ * The state of the Aura, Texture and Slider designers, and the two-way trip
5
+ * between that state and DSSL text. Pure: no DOM, no vscode. The panel
6
+ * (designer.js) draws controls for this state; the tests (scripts/test-designer.mjs)
7
+ * check that what it writes compiles and reads back to the same thing.
8
+ *
9
+ * Colours in the state are { hex: '#rrggbb', a: 0..1 }, because that is what a
10
+ * colour picker plus an alpha slider produces. DSSL text gets #rrggbb, or
11
+ * rgba(...) when it is see-through.
12
+ * ========================================================================= */
13
+
14
+ import { parseColor, toHex } from '../lib/engine/color.js';
15
+ import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, parseTexture } from '../lib/engine/texture.js';
16
+ import { parseAura, GLOW_DEFAULTS, PARTICLE_DEFAULTS, SHAPES, FROM_WORDS, GLOW_KEYS, PARTICLE_KEYS } from '../lib/engine/aura.js';
17
+ import { parseSlider, THUMB_SHAPES } from '../lib/engine/slider.js';
18
+
19
+ export { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, SHAPES, FROM_WORDS, THUMB_SHAPES, GLOW_KEYS, PARTICLE_KEYS, GLOW_DEFAULTS, PARTICLE_DEFAULTS };
20
+
21
+ /* ---------------- small helpers ---------------- */
22
+
23
+ const fmt = (n) => String(+Number(n).toFixed(3));
24
+ export const colorToDssl = (c) => (c.a >= 0.999 ? c.hex : `rgba(${parseInt(c.hex.slice(1, 3), 16)},${parseInt(c.hex.slice(3, 5), 16)},${parseInt(c.hex.slice(5, 7), 16)},${fmt(c.a)})`);
25
+ export const dsslToColor = (css) => {
26
+ const p = parseColor(css);
27
+ return p ? { hex: toHex(p), a: +p.a.toFixed(3) } : null;
28
+ };
29
+ const hooksFor = (warnings) => ({
30
+ error: (m) => warnings.push(m),
31
+ color: (w) => w,
32
+ suggest: () => null,
33
+ });
34
+ /** a range as DSSL writes it: "5" when both ends match, "4 10" (low then high) otherwise */
35
+ const rangeText = (r) => (r[0] === r[1] ? fmt(r[0]) : `${fmt(Math.min(...r))} ${fmt(Math.max(...r))}`);
36
+
37
+ /* ====================================================================== *
38
+ * AURA
39
+ * ====================================================================== */
40
+
41
+ export const defaultAuraState = () => ({
42
+ glow: { on: true, color: { hex: '#ff7a00', a: 1 }, radius: GLOW_DEFAULTS.radius, intensity: 1, pulse: 0, depth: GLOW_DEFAULTS.depth, flicker: 0, texture: '', seed: 1, essential: false },
43
+ particles: {
44
+ on: true,
45
+ stops: [{ hex: '#ffd24a', a: 1 }, { hex: '#ff6a00', a: 1 }, { hex: '#ff2800', a: 0 }],
46
+ count: 60, size: [6, 10], life: [700, 1100], speed: [40, 110], dir: 'up', deg: -90, spread: 40,
47
+ gravity: -40, drag: 0, turbulence: 60, from: 'top', shape: 'flame', grow: 0.3,
48
+ glowing: true, behind: true, blend: '', texture: '', seed: 1, essential: false,
49
+ },
50
+ });
51
+
52
+ function glowWords(g) {
53
+ const w = [colorToDssl(g.color), fmt(g.radius)];
54
+ if (g.intensity !== 1) w.push('intensity', fmt(g.intensity));
55
+ if (g.pulse) w.push('pulse', fmt(g.pulse));
56
+ if (g.pulse && g.depth !== GLOW_DEFAULTS.depth) w.push('depth', fmt(g.depth));
57
+ if (g.flicker) w.push('flicker', fmt(g.flicker));
58
+ if (g.texture) w.push(`texture(${g.texture})`);
59
+ if (g.seed !== 1) w.push('seed', fmt(g.seed));
60
+ if (g.essential) w.push('essential');
61
+ return w;
62
+ }
63
+
64
+ function particleWords(p, { bare = false } = {}) {
65
+ const D = PARTICLE_DEFAULTS;
66
+ const w = [p.stops.map(colorToDssl).join(' -> ')];
67
+ w.push('count', fmt(p.count));
68
+ if (p.size[0] !== D.size[0] || p.size[1] !== D.size[1]) w.push('size', rangeText(p.size));
69
+ if (p.life[0] !== D.life[0] || p.life[1] !== D.life[1]) w.push('life', p.life[0] === p.life[1] ? fmt(p.life[0]) : `${fmt(Math.min(...p.life))} ${fmt(Math.max(...p.life))}`);
70
+ if (p.speed[0] !== D.speed[0] || p.speed[1] !== D.speed[1]) w.push('speed', p.speed[0] === p.speed[1] ? fmt(p.speed[0]) : `${fmt(Math.min(...p.speed))} ${fmt(Math.max(...p.speed))}`);
71
+ const dir = p.dir === 'deg' ? fmt(p.deg) : p.dir;
72
+ if (!(p.dir === 'up')) w.push('dir', dir);
73
+ if (p.spread !== D.spread) w.push('spread', fmt(p.spread));
74
+ if (p.gravity) w.push('gravity', fmt(p.gravity));
75
+ if (p.drag) w.push('drag', fmt(p.drag));
76
+ if (p.turbulence) w.push('turbulence', fmt(p.turbulence));
77
+ if (p.from !== D.from) w.push('from', p.from);
78
+ if (p.shape !== D.shape) w.push('shape', p.shape);
79
+ if (p.grow !== D.grow) w.push('grow', fmt(p.grow));
80
+ if (p.glowing) w.push('glowing');
81
+ if (p.behind) w.push('behind');
82
+ if (p.blend) w.push('blend', p.blend);
83
+ if (p.texture) w.push(`texture(${p.texture})`);
84
+ if (p.seed !== 1) w.push('seed', fmt(p.seed));
85
+ if (p.essential) w.push('essential');
86
+ return w;
87
+ }
88
+
89
+ /** DSSL for an aura state. style 'separate': one declaration per slot. 'single': one aura: line. */
90
+ export function auraToDssl(state, style = 'separate') {
91
+ const g = state.glow.on ? glowWords(state.glow).join(' ') : null;
92
+ const p = state.particles.on ? particleWords(state.particles).join(' ') : null;
93
+ if (!g && !p) return 'aura: none';
94
+ if (style === 'single') {
95
+ const parts = [];
96
+ if (g) parts.push(`glow ${g}`);
97
+ if (p) parts.push(`particles ${p}`);
98
+ return `aura: ${parts.join(',\n ')}`;
99
+ }
100
+ const lines = [];
101
+ if (g) lines.push(`aura-glow: ${g}`);
102
+ if (p) lines.push(`aura-particle: ${p}`);
103
+ return lines.join('\n');
104
+ }
105
+
106
+ /** what a layer means when nothing is said about a setting: the LANGUAGE's defaults, not the designer's starting look */
107
+ const languageGlow = () => ({ on: false, color: { hex: '#ffffff', a: 1 }, radius: GLOW_DEFAULTS.radius, intensity: GLOW_DEFAULTS.intensity, pulse: 0, depth: GLOW_DEFAULTS.depth, flicker: 0, texture: '', seed: 1, essential: false });
108
+ const languageParticles = () => ({
109
+ on: false, stops: [{ hex: '#ffffff', a: 1 }, { hex: '#ffffff', a: 0 }], count: PARTICLE_DEFAULTS.count, size: [...PARTICLE_DEFAULTS.size], life: [...PARTICLE_DEFAULTS.life], speed: [...PARTICLE_DEFAULTS.speed],
110
+ dir: 'up', deg: -90, spread: PARTICLE_DEFAULTS.spread, gravity: 0, drag: 0, turbulence: 0, from: PARTICLE_DEFAULTS.from, shape: PARTICLE_DEFAULTS.shape, grow: PARTICLE_DEFAULTS.grow,
111
+ glowing: false, behind: false, blend: '', texture: '', seed: 1, essential: false,
112
+ });
113
+
114
+ /** read an aura back from DSSL text (aura:, aura-glow:, aura-particle: ...). Returns { state, notes }. */
115
+ export function auraFromDssl(text) {
116
+ const state = { glow: languageGlow(), particles: languageParticles() };
117
+ const notes = [];
118
+ const decls = splitDecls(text);
119
+ for (const [prop, value] of decls) {
120
+ if (!/^(aura|aura-glow|aura-particles?|glow-particles?|aura-glow-particles?)$/.test(prop)) continue;
121
+ const warnings = [];
122
+ const r = parseAura(prop, value, hooksFor(warnings));
123
+ if (!r) { notes.push(`could not read "${prop}: ${value}": ${warnings.join('; ')}`); continue; }
124
+ if (r.glow && r.glow[0]) {
125
+ const g = r.glow[0];
126
+ Object.assign(state.glow, { on: true });
127
+ if (g.color) { const c = dsslToColor(g.color); if (c) state.glow.color = c; else notes.push(`the colour "${g.color}" is a token or variable, so the designer used its default`); }
128
+ for (const k of ['radius', 'intensity', 'pulse', 'depth', 'flicker', 'seed']) if (g[k] !== undefined) state.glow[k] = g[k];
129
+ if (g.essential) state.glow.essential = true;
130
+ if (g.texture && g.texture[0]) state.glow.texture = textureLayerName(g.texture[0]);
131
+ }
132
+ if (r.particles && r.particles[0]) {
133
+ const q = r.particles[0];
134
+ state.particles.on = true;
135
+ if (q.colors) {
136
+ const stops = q.colors.map((c) => dsslToColor(c)).filter(Boolean);
137
+ if (stops.length) state.particles.stops = stops.length === 1 ? [stops[0], { ...stops[0], a: 0 }] : stops;
138
+ if (stops.length < q.colors.length) notes.push('a token or variable colour could not be shown on a picker and was skipped');
139
+ }
140
+ for (const k of ['count', 'spread', 'gravity', 'drag', 'turbulence', 'grow', 'seed']) if (q[k] !== undefined) state.particles[k] = q[k];
141
+ for (const k of ['size', 'life', 'speed']) if (q[k]) state.particles[k] = q[k];
142
+ if (q.dir !== undefined) { if (typeof q.dir === 'number') { state.particles.dir = q.dir === -90 ? 'up' : q.dir === 90 ? 'down' : q.dir === 180 ? 'left' : q.dir === 0 ? 'right' : 'deg'; state.particles.deg = q.dir; } else state.particles.dir = q.dir; }
143
+ for (const k of ['from', 'shape', 'blend']) if (q[k]) state.particles[k] = q[k];
144
+ for (const k of ['glowing', 'behind', 'essential']) if (q[k]) state.particles[k] = true;
145
+ if (q.texture && q.texture[0]) state.particles.texture = textureLayerName(q.texture[0]);
146
+ }
147
+ }
148
+ return { state, notes };
149
+ }
150
+
151
+ /* ====================================================================== *
152
+ * TEXTURE
153
+ * ====================================================================== */
154
+
155
+ export const defaultTextureLayer = (name = 'grain') => ({ name, set: {}, color: null, color2: null });
156
+ export const defaultTextureState = () => ({ layers: [defaultTextureLayer('wood')], base: '#b8a58a' });
157
+
158
+ const TEXTURE_NUMERIC = ['scale', 'intensity', 'contrast', 'threshold', 'octaves', 'seed', 'depth', 'light', 'angle', 'thickness', 'radius', 'softness', 'contour', 'animate'];
159
+
160
+ /** what a preset or generator would use with no overrides, to know what to leave out of the output */
161
+ function baselineFor(name) {
162
+ if (PRESETS[name]) return PRESETS[name].layers[0];
163
+ return {};
164
+ }
165
+
166
+ export function textureLayerToDssl(layer) {
167
+ const w = [layer.name];
168
+ const base = baselineFor(layer.name);
169
+ for (const k of TEXTURE_NUMERIC) if (layer.set[k] !== undefined && layer.set[k] !== base[k]) w.push(k, fmt(layer.set[k]));
170
+ if (layer.set.stretch && (layer.set.stretch[0] !== (base.stretch || [1, 1])[0] || layer.set.stretch[1] !== (base.stretch || [1, 1])[1])) w.push('stretch', fmt(layer.set.stretch[0]), fmt(layer.set.stretch[1]));
171
+ if (layer.set.type && layer.set.type !== base.type) w.push('type', layer.set.type);
172
+ if (layer.set.blend && layer.set.blend !== base.blend) w.push('blend', layer.set.blend);
173
+ if (layer.set.invert) w.push('invert');
174
+ if (layer.color) w.push('color', colorToDssl(layer.color));
175
+ if (layer.color2) w.push('color2', colorToDssl(layer.color2));
176
+ return w.join(' ');
177
+ }
178
+ const textureLayerName = (l) => textureLayerToDssl(fromParsedTexture(l));
179
+ export const textureToDssl = (state) => (state.layers.length ? `texture: ${state.layers.map(textureLayerToDssl).join(', ')}` : 'texture: none');
180
+
181
+ function fromParsedTexture(parsed) {
182
+ const layer = defaultTextureLayer(parsed.preset || parsed.gen);
183
+ const base = baselineFor(layer.name);
184
+ for (const k of [...TEXTURE_NUMERIC, 'stretch', 'type', 'blend', 'invert']) if (parsed[k] !== undefined && JSON.stringify(parsed[k]) !== JSON.stringify(base[k])) layer.set[k] = parsed[k];
185
+ if (parsed.color !== undefined && parsed.color !== base.color) layer.color = dsslToColor(parsed.color);
186
+ if (parsed.color2 !== undefined && parsed.color2 !== base.color2) layer.color2 = dsslToColor(parsed.color2);
187
+ return layer;
188
+ }
189
+
190
+ export function textureFromDssl(text) {
191
+ const state = { layers: [], base: '#b8a58a' };
192
+ const notes = [];
193
+ for (const [prop, value] of splitDecls(text)) {
194
+ if (prop !== 'texture') continue;
195
+ const warnings = [];
196
+ const parsed = parseTexture(value, hooksFor(warnings));
197
+ if (!parsed) { notes.push(`could not read "texture: ${value}": ${warnings.join('; ')}`); continue; }
198
+ // one parsed layer per generator layer; a multi-layer preset reads back as the preset name, once
199
+ const seen = new Set();
200
+ for (const l of parsed) {
201
+ const key = l.preset ? `${l.preset}` : `${l.gen}:${JSON.stringify(l)}`;
202
+ if (l.preset && seen.has(key)) continue;
203
+ seen.add(key);
204
+ state.layers.push(fromParsedTexture(l));
205
+ }
206
+ }
207
+ if (!state.layers.length) state.layers.push(defaultTextureLayer('grain'));
208
+ return { state, notes };
209
+ }
210
+
211
+ /* ====================================================================== *
212
+ * SLIDER
213
+ * ====================================================================== */
214
+
215
+ export const defaultSliderState = () => ({
216
+ trackH: 8, trackColor: { hex: '#445566', a: 1 }, trackRadius: 'full', trackRadiusPx: 4,
217
+ fill: true, fillColor: { hex: '#ff7a00', a: 1 },
218
+ thumb: 22, thumbColor: { hex: '#ffffff', a: 1 }, shape: 'circle',
219
+ ring: false, ringW: 3, ringColor: { hex: '#ff7a00', a: 1 },
220
+ glow: false, glowColor: { hex: '#ff7a00', a: 1 }, glowR: 14,
221
+ ticks: false, tickCount: 10, tickColor: { hex: '#8890a0', a: 1 }, tickLen: 6,
222
+ graph: false, bars: [20, 45, 70, 90, 60, 35, 50, 80, 95, 40, 60, 75], graphH: 48, graphGap: 3,
223
+ });
224
+
225
+ export function sliderToDssl(s) {
226
+ const c = [];
227
+ const radius = s.trackRadius === 'full' ? 'radius full' : `radius ${fmt(s.trackRadiusPx)}`;
228
+ c.push(`track ${fmt(s.trackH)} ${colorToDssl(s.trackColor)} ${radius}`);
229
+ c.push(s.fill ? `fill ${colorToDssl(s.fillColor)}` : 'fill none');
230
+ c.push(`thumb ${fmt(s.thumb)} ${colorToDssl(s.thumbColor)}`);
231
+ if (s.shape !== 'circle') c.push(`shape ${s.shape}`);
232
+ if (s.ring) c.push(`ring ${fmt(s.ringW)} ${colorToDssl(s.ringColor)}`);
233
+ if (s.glow) c.push(`glow ${colorToDssl(s.glowColor)} ${fmt(s.glowR)}`);
234
+ if (s.ticks) c.push(`ticks ${fmt(s.tickCount)} ${colorToDssl(s.tickColor)} ${fmt(s.tickLen)}`);
235
+ if (s.graph) { c.push(`graph ${s.bars.map(fmt).join(' ')}`); c.push(`graph-height ${fmt(s.graphH)}`); c.push(`graph-gap ${fmt(s.graphGap)}`); }
236
+ return `slider: ${c.join(',\n ')}`;
237
+ }
238
+
239
+ export function sliderFromDssl(text) {
240
+ const state = defaultSliderState();
241
+ state.fill = false; state.ring = false; state.glow = false; state.ticks = false; state.graph = false;
242
+ const notes = [];
243
+ for (const [prop, value] of splitDecls(text)) {
244
+ if (prop !== 'slider') continue;
245
+ const warnings = [];
246
+ const spec = parseSlider(value, hooksFor(warnings));
247
+ if (!spec) { notes.push(`could not read "slider: ${value}": ${warnings.join('; ')}`); continue; }
248
+ const col = (css, into) => { const c = dsslToColor(css); if (c) return c; notes.push(`the colour "${css}" is a token or variable, so the designer used its default`); return into; };
249
+ state.trackH = spec.track.h; state.trackColor = col(spec.track.color, state.trackColor);
250
+ if (spec.track.radius === 'full') state.trackRadius = 'full'; else { state.trackRadius = 'px'; state.trackRadiusPx = spec.track.radius; }
251
+ if (spec.fill) { state.fill = true; state.fillColor = col(spec.fill, state.fillColor); }
252
+ state.thumb = spec.thumb.size; state.thumbColor = col(spec.thumb.color, state.thumbColor); state.shape = spec.shape;
253
+ if (spec.ring) { state.ring = true; state.ringW = spec.ring.w; state.ringColor = col(spec.ring.color, state.ringColor); }
254
+ if (spec.glow) { state.glow = true; state.glowColor = col(spec.glow.color, state.glowColor); state.glowR = spec.glow.r; }
255
+ if (spec.ticks) { state.ticks = true; state.tickCount = spec.ticks.n; state.tickColor = col(spec.ticks.color, state.tickColor); state.tickLen = spec.ticks.len; }
256
+ if (spec.graph) { state.graph = true; state.bars = spec.graph; state.graphH = spec.graphHeight; state.graphGap = spec.graphGap; }
257
+ }
258
+ return { state, notes };
259
+ }
260
+
261
+ /* ---------------- shared ---------------- */
262
+
263
+ /** "prop: value" pairs from a snippet, joining continuation lines (a trailing comma continues the value) */
264
+ export function splitDecls(text) {
265
+ const out = [];
266
+ const lines = String(text).replace(/\/\*[\s\S]*?\*\//g, '').split('\n');
267
+ let cur = null;
268
+ for (const raw of lines) {
269
+ const line = raw.replace(/\/\/.*$/, '');
270
+ if (!line.trim()) continue;
271
+ if (cur && /,\s*$/.test(cur[1])) { cur[1] += ' ' + line.trim(); continue; }
272
+ const m = /^\s*(?:[\w.#:&>+~\[\]="' *-]+\{)?\s*([\w-]+)\s*:\s*(.*?)\s*;?\s*$/.exec(line);
273
+ if (m) { cur = [m[1], m[2]]; out.push(cur); } else cur = null;
274
+ }
275
+ return out.map(([p, v]) => [p, v.replace(/\s*;?\s*\}?\s*$/, '').trim()]);
276
+ }
277
+
278
+ /** a complete rule around the snippet, so the real compiler can check it */
279
+ export const wrapForCompile = (snippet, selector = '.preview') => `${selector} {\n ${snippet.replace(/\n/g, '\n ')}\n}\n`;
dssl-vscode/media/designer.css ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The designer follows the editor's theme through VS Code's own CSS variables; the
2
+ fallbacks only matter when the page is opened outside the editor (tests, screenshots). */
3
+ :root {
4
+ --bg: var(--vscode-editor-background, #1e1e1e);
5
+ --fg: var(--vscode-editor-foreground, #d4d4d4);
6
+ --muted: var(--vscode-descriptionForeground, #9a9a9a);
7
+ --line: var(--vscode-panel-border, #3c3c3c);
8
+ --field: var(--vscode-input-background, #2a2a2a);
9
+ --accent: var(--vscode-button-background, #0e639c);
10
+ --accent-fg: var(--vscode-button-foreground, #fff);
11
+ --good: var(--vscode-testing-iconPassed, #73c991);
12
+ --bad: var(--vscode-errorForeground, #f48771);
13
+ --font: var(--vscode-font-family, system-ui, sans-serif);
14
+ --mono: var(--vscode-editor-font-family, ui-monospace, Menlo, Consolas, monospace);
15
+ }
16
+ * { box-sizing: border-box; }
17
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.45 var(--font); }
18
+ header { display: flex; align-items: center; gap: 18px; padding: 10px 16px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg); z-index: 5; }
19
+ h1 { font-size: 15px; margin: 0; font-weight: 600; }
20
+ nav { display: flex; gap: 4px; }
21
+ button { font: inherit; color: var(--fg); background: var(--field); border: 1px solid var(--line); border-radius: 3px; padding: 5px 12px; cursor: pointer; }
22
+ button:hover:not(:disabled) { border-color: var(--accent); }
23
+ button:disabled { opacity: .45; cursor: default; }
24
+ button.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
25
+ button.tab.on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
26
+ button.mini { padding: 2px 8px; font-size: 12px; }
27
+ button.mini.on { background: var(--accent); color: var(--accent-fg); }
28
+ button.mini.add { border-style: dashed; }
29
+ .panel[hidden] { display: none; }
30
+ .cols { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(340px, 1fr); gap: 18px; padding: 14px 16px 6px; align-items: start; }
31
+ @media (max-width: 760px) { .cols { grid-template-columns: 1fr; } }
32
+ .right { position: sticky; top: 52px; }
33
+ fieldset { border: 1px solid var(--line); border-radius: 4px; margin: 0 0 12px; padding: 8px 12px 10px; min-width: 0; }
34
+ legend { padding: 0 6px; font-weight: 600; }
35
+ .row { display: grid; grid-template-columns: 112px 1fr; gap: 8px; align-items: center; padding: 2px 0; }
36
+ .lab { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
37
+ .ctl { display: flex; align-items: center; gap: 6px; min-width: 0; }
38
+ .ctl input[type=range] { flex: 1; min-width: 60px; accent-color: var(--accent); }
39
+ .ctl input[type=range].alpha { max-width: 70px; }
40
+ input.num { width: 64px; background: var(--field); color: var(--fg); border: 1px solid var(--line); border-radius: 3px; padding: 2px 4px; font: inherit; }
41
+ select { background: var(--field); color: var(--fg); border: 1px solid var(--line); border-radius: 3px; padding: 3px 4px; font: inherit; max-width: 100%; }
42
+ input[type=color] { width: 34px; height: 24px; padding: 0; border: 1px solid var(--line); background: none; }
43
+ .pair { display: grid; gap: 0; }
44
+ .stop { display: flex; gap: 6px; align-items: center; } .stop .row { flex: 1; }
45
+ .note, .cap { color: var(--muted); font-size: 12px; margin: 4px 0; }
46
+ .stage { height: 230px; border: 1px solid var(--line); border-radius: 6px; background: #0b0a12; display: grid; place-items: center; overflow: hidden; position: relative; }
47
+ .target { width: 130px; height: 84px; border-radius: 14px; background: #1d1b2c; color: #ccc; display: grid; place-items: center; font-size: 12px; }
48
+ .bgs { display: flex; gap: 6px; align-items: center; margin: 8px 0; color: var(--muted); }
49
+ canvas#life-curve, canvas#live-graph { width: 100%; max-width: 360px; height: auto; border: 1px solid var(--line); border-radius: 3px; background: color-mix(in srgb, var(--field) 60%, transparent); display: block; margin-bottom: 8px; }
50
+ .tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(86px, 1fr)); gap: 6px; }
51
+ .tilehead { grid-column: 1 / -1; color: var(--muted); font-size: 12px; margin-top: 6px; }
52
+ .tile { padding: 4px; display: grid; gap: 3px; text-align: left; }
53
+ .tile .sw { display: block; height: 42px; border-radius: 3px; }
54
+ .tile .nm { font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
55
+ .layers { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 6px; }
56
+ .swatch { height: 200px; border-radius: 6px; border: 1px solid var(--line); }
57
+ .swrow { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; color: var(--muted); }
58
+ .sliderstage { padding: 38px 24px; border: 1px solid var(--line); border-radius: 6px; background: #101018; }
59
+ .bars { display: flex; gap: 2px; align-items: flex-end; height: 70px; margin-bottom: 6px; }
60
+ .bars input.bar { writing-mode: vertical-lr; direction: rtl; width: 12px; height: 70px; padding: 0; accent-color: var(--accent); }
61
+ footer { border-top: 1px solid var(--line); padding: 10px 16px 16px; position: sticky; bottom: 0; background: var(--bg); z-index: 5; }
62
+ .outhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
63
+ #code { width: 100%; font: 12.5px/1.5 var(--mono); background: var(--field); color: var(--fg); border: 1px solid var(--line); border-radius: 3px; padding: 8px; resize: vertical; }
64
+ .status.good { color: var(--good); } .status.bad { color: var(--bad); }
65
+ .btns { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
66
+ .flash { color: var(--muted); opacity: 0; transition: opacity .2s; } .flash.on { opacity: 1; }
dssl-vscode/media/designer.js ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL designer: designer.js
3
+ *
4
+ * The panel behind "DSSL: Open Designer". Three tabs (Aura, Texture, Slider)
5
+ * with a control for every setting, a live preview drawn by the SAME code the
6
+ * engine uses (the aura renderer, the texture builder, the slider CSS), a few
7
+ * graphs, and the DSSL it writes, checked by the real compiler as you edit.
8
+ *
9
+ * It talks to the editor through four messages:
10
+ * panel -> editor insert { text } copy { text } requestSelection {} ready {}
11
+ * editor -> panel selection { text } tab { tab }
12
+ * Everything else happens in here, so it can be driven (and tested) in any browser.
13
+ * ========================================================================= */
14
+
15
+ import * as core from './designer-core.js';
16
+ import { AuraManager, resolveLayer, parseAura } from '../lib/engine/aura.js';
17
+ import { buildTexture, parseTexture, PRESETS, TEXTURE_KEYS } from '../lib/engine/texture.js';
18
+ import { sliderCss, parseSlider } from '../lib/engine/slider.js';
19
+ import { parseColor } from '../lib/engine/color.js';
20
+ import { parse } from '../lib/engine/parser.js';
21
+ import { resolve } from '../lib/engine/resolver.js';
22
+
23
+ const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : { postMessage() {}, getState: () => null, setState() {} };
24
+
25
+ /* ---------------- tiny DOM helpers ---------------- */
26
+
27
+ const h = (tag, attrs = {}, ...kids) => {
28
+ const el = document.createElement(tag);
29
+ for (const [k, v] of Object.entries(attrs || {})) {
30
+ if (v === undefined || v === null || v === false) continue;
31
+ if (k === 'class') el.className = v;
32
+ else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
33
+ else if (k === 'text') el.textContent = v;
34
+ else el.setAttribute(k, v === true ? '' : v);
35
+ }
36
+ for (const kid of kids.flat()) if (kid !== null && kid !== undefined && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
37
+ return el;
38
+ };
39
+ const $ = (sel, root = document) => root.querySelector(sel);
40
+
41
+ /** every control calls this when it changes something */
42
+ let onChange = () => {};
43
+
44
+ function row(label, ...controls) { return h('label', { class: 'row' }, h('span', { class: 'lab' }, label), h('span', { class: 'ctl' }, ...controls)); }
45
+
46
+ /** a labelled slider that also shows (and accepts) its number */
47
+ function num(label, get, set, min, max, step = 1, hint = '') {
48
+ const range = h('input', { type: 'range', min, max, step, value: get() });
49
+ const box = h('input', { type: 'number', min, max, step, value: get(), class: 'num' });
50
+ const sync = (v) => { range.value = v; box.value = v; };
51
+ range.addEventListener('input', () => { set(Number(range.value)); box.value = range.value; onChange(); });
52
+ box.addEventListener('input', () => { const v = Number(box.value); if (!Number.isNaN(v)) { set(v); range.value = v; onChange(); } });
53
+ const el = row(label, range, box);
54
+ if (hint) el.title = hint;
55
+ el.sync = () => sync(get());
56
+ return el;
57
+ }
58
+ /** low and high of a range, as two sliders */
59
+ function pair(label, get, set, min, max, step = 1, hint = '') {
60
+ const a = num(`${label} from`, () => get()[0], (v) => set([Math.min(v, get()[1]), Math.max(v, get()[1])]), min, max, step, hint);
61
+ const b = num(`${label} to`, () => get()[1], (v) => set([Math.min(get()[0], v), Math.max(get()[0], v)]), min, max, step, hint);
62
+ return h('div', { class: 'pair' }, a, b);
63
+ }
64
+ function color(label, get, set, alpha = true) {
65
+ const c = h('input', { type: 'color', value: get().hex });
66
+ const a = h('input', { type: 'range', min: 0, max: 1, step: 0.01, value: get().a, title: 'opacity', class: 'alpha' });
67
+ c.addEventListener('input', () => { set({ hex: c.value, a: Number(a.value) }); onChange(); });
68
+ a.addEventListener('input', () => { set({ hex: c.value, a: Number(a.value) }); onChange(); });
69
+ const el = row(label, c, alpha ? a : null);
70
+ el.sync = () => { c.value = get().hex; a.value = get().a; };
71
+ return el;
72
+ }
73
+ function select(label, get, set, options, hint = '') {
74
+ const s = h('select', {}, options.map((o) => h('option', { value: o.value ?? o, selected: (o.value ?? o) === get() }, o.label ?? o)));
75
+ s.addEventListener('change', () => { set(s.value); onChange(); });
76
+ const el = row(label, s);
77
+ if (hint) el.title = hint;
78
+ el.sync = () => { s.value = get(); };
79
+ return el;
80
+ }
81
+ function check(label, get, set, hint = '') {
82
+ const c = h('input', { type: 'checkbox', checked: get() });
83
+ c.addEventListener('change', () => { set(c.checked); onChange(); });
84
+ const el = row(label, c);
85
+ if (hint) el.title = hint;
86
+ el.sync = () => { c.checked = get(); };
87
+ return el;
88
+ }
89
+ const group = (title, ...kids) => h('fieldset', {}, h('legend', {}, title), ...kids);
90
+
91
+ /* ---------------- output: the code box, compile check, buttons ---------------- */
92
+
93
+ const out = { text: '' };
94
+ let codeBox, statusBox;
95
+
96
+ function compileCheck(snippet) {
97
+ try {
98
+ const src = core.wrapForCompile(snippet);
99
+ const { ast, errors: pe } = parse(src, 'designer.dssl');
100
+ const doc = resolve(ast, 'designer.dssl');
101
+ const errors = [...pe, ...doc.errors];
102
+ if (errors.length) return { ok: false, text: errors.map((e) => e.message + (e.hint ? ` (${e.hint})` : '')).join('; ') };
103
+ return { ok: true, text: doc.warnings.length ? `compiles, ${doc.warnings.length} warning(s)` : 'compiles' };
104
+ } catch (e) { return { ok: false, text: String(e.message || e) }; }
105
+ }
106
+
107
+ function setOutput(text) {
108
+ out.text = text;
109
+ codeBox.value = text;
110
+ const c = compileCheck(text);
111
+ statusBox.textContent = c.ok ? `✓ ${c.text}` : `✗ ${c.text}`;
112
+ statusBox.className = 'status ' + (c.ok ? 'good' : 'bad');
113
+ return c;
114
+ }
115
+
116
+ /* ====================================================================== *
117
+ * AURA TAB
118
+ * ====================================================================== */
119
+
120
+ const aura = { state: core.defaultAuraState(), style: 'separate', manager: null, history: [], bg: '#0b0a12', stage: null, target: null };
121
+
122
+ function auraLayersFromText(text) {
123
+ const hooks = { error() {}, color: (w) => w, suggest: () => null };
124
+ const glow = [], parts = [];
125
+ for (const [prop, value] of core.splitDecls(text)) {
126
+ if (!/^(aura|aura-glow|aura-particles?)$/.test(prop)) continue;
127
+ const r = parseAura(prop, value, hooks);
128
+ if (!r) continue;
129
+ if (r.glow) glow.push(...r.glow);
130
+ if (r.particles) parts.push(...r.particles);
131
+ }
132
+ const fb = { r: 255, g: 255, b: 255, a: 1 };
133
+ return { glow: glow.map((l) => resolveLayer(l, parseColor, fb)), parts: parts.map((l) => resolveLayer(l, parseColor, fb)) };
134
+ }
135
+
136
+ function applyAuraPreview(text) {
137
+ if (!aura.manager) aura.manager = new AuraManager({ reducedMotion: { matches: false }, budget: 1200 });
138
+ const { glow, parts } = auraLayersFromText(text);
139
+ aura.manager.set(aura.target, glow, parts);
140
+ drawLifeCurves();
141
+ }
142
+
143
+ function drawLifeCurves() {
144
+ const cv = $('#life-curve');
145
+ if (!cv) return;
146
+ const x = cv.getContext('2d');
147
+ const W = cv.width, H = cv.height;
148
+ x.clearRect(0, 0, W, H);
149
+ const css = getComputedStyle(document.body);
150
+ const fg = css.getPropertyValue('--vscode-editor-foreground') || '#ccc';
151
+ x.strokeStyle = 'rgba(128,128,128,.35)'; x.lineWidth = 1;
152
+ for (let i = 0; i <= 4; i++) { const y = 6 + (i / 4) * (H - 16); x.beginPath(); x.moveTo(26, y); x.lineTo(W - 4, y); x.stroke(); }
153
+ x.fillStyle = fg; x.font = '10px sans-serif';
154
+ x.fillText('1', 8, 12); x.fillText('0', 8, H - 8); x.fillText('life →', W - 40, H - 1);
155
+ const p = aura.state.particles;
156
+ if (!p.on) { x.fillText('particles are off', 40, H / 2); return; }
157
+ const stops = p.stops.length ? p.stops : [{ a: 1 }];
158
+ const alphaAt = (t) => { const n = stops.length - 1; if (n <= 0) return stops[0].a; const f = Math.min(0.9999, t) * n; const i = Math.floor(f); return stops[i].a + (stops[i + 1].a - stops[i].a) * (f - i); };
159
+ const line = (fn, strokeStyle, max) => { x.strokeStyle = strokeStyle; x.lineWidth = 2; x.beginPath(); for (let i = 0; i <= 60; i++) { const t = i / 60; const v = Math.max(0, Math.min(1, fn(t) / max)); const px = 26 + t * (W - 30), py = H - 10 - v * (H - 16); i ? x.lineTo(px, py) : x.moveTo(px, py); } x.stroke(); };
160
+ line(alphaAt, '#ff9f43', 1);
161
+ const gmax = Math.max(1, p.grow);
162
+ line((t) => 1 + (p.grow - 1) * t, '#48dbfb', gmax);
163
+ x.fillStyle = '#ff9f43'; x.fillText('opacity', 34, 14);
164
+ x.fillStyle = '#48dbfb'; x.fillText(`size (1 → ${p.grow})`, 90, 14);
165
+ }
166
+
167
+ function drawLiveGraph() {
168
+ const cv = $('#live-graph');
169
+ if (!cv || !aura.manager) return;
170
+ const st = aura.manager.stats;
171
+ aura.history.push(st.particles);
172
+ if (aura.history.length > 150) aura.history.shift();
173
+ const x = cv.getContext('2d'), W = cv.width, H = cv.height;
174
+ x.clearRect(0, 0, W, H);
175
+ const max = Math.max(10, ...aura.history);
176
+ x.strokeStyle = '#7bed9f'; x.lineWidth = 1.5; x.beginPath();
177
+ aura.history.forEach((v, i) => { const px = (i / 149) * W, py = H - 4 - (v / max) * (H - 10); i ? x.lineTo(px, py) : x.moveTo(px, py); });
178
+ x.stroke();
179
+ x.fillStyle = getComputedStyle(document.body).color; x.font = '10px sans-serif';
180
+ x.fillText(`${st.particles} particles · ${st.fps || 0} fps · ${st.frameMs.toFixed(2)} ms/frame`, 6, 11);
181
+ }
182
+
183
+ function buildAuraTab() {
184
+ const s = aura.state;
185
+ const g = s.glow, p = s.particles;
186
+ const controls = [];
187
+ const texOpts = ['', 'grain', 'noise', 'rough', 'sand', 'cloud', 'glitter', 'dots', 'lines', 'halftone', 'scanlines'].map((v) => ({ value: v, label: v || 'none' }));
188
+
189
+ controls.push(group('Output',
190
+ select('Write it as', () => aura.style, (v) => { aura.style = v; }, [{ value: 'separate', label: 'aura-glow and aura-particle' }, { value: 'single', label: 'one aura: line' }]),
191
+ ));
192
+ const glowCtl = group('Glow',
193
+ check('On', () => g.on, (v) => { g.on = v; }, 'a soft light around the element'),
194
+ color('Colour', () => g.color, (v) => { g.color = v; }),
195
+ num('Radius', () => g.radius, (v) => { g.radius = v; }, 1, 200, 1, 'how far the light reaches, px'),
196
+ num('Intensity', () => g.intensity, (v) => { g.intensity = v; }, 0, 3, 0.05),
197
+ num('Pulse (ms)', () => g.pulse, (v) => { g.pulse = v; }, 0, 6000, 50, '0 is steady; otherwise one breath in and out takes this long'),
198
+ num('Pulse depth', () => g.depth, (v) => { g.depth = v; }, 0, 1, 0.05),
199
+ num('Flicker', () => g.flicker, (v) => { g.flicker = v; }, 0, 1, 0.05, 'random flicker, like a flame'),
200
+ select('Texture', () => g.texture, (v) => { g.texture = v; }, texOpts),
201
+ num('Seed', () => g.seed, (v) => { g.seed = v; }, 1, 99, 1),
202
+ check('Essential', () => g.essential, (v) => { g.essential = v; }, 'keep animating under reduced motion'),
203
+ );
204
+ const stopsBox = h('div', { class: 'stops' });
205
+ const renderStops = () => {
206
+ stopsBox.replaceChildren();
207
+ p.stops.forEach((st, i) => {
208
+ stopsBox.append(h('div', { class: 'stop' },
209
+ color(`Colour ${i + 1}`, () => p.stops[i], (v) => { p.stops[i] = v; }),
210
+ h('button', { class: 'mini', title: 'remove this colour', disabled: p.stops.length <= 1, onclick: () => { p.stops.splice(i, 1); renderStops(); onChange(); } }, '×')));
211
+ });
212
+ stopsBox.append(h('button', { class: 'mini add', disabled: p.stops.length >= 5, onclick: () => { p.stops.push({ ...p.stops[p.stops.length - 1], a: 0 }); renderStops(); onChange(); } }, '+ add a colour stop'));
213
+ };
214
+ renderStops();
215
+ const partCtl = group('Particles',
216
+ check('On', () => p.on, (v) => { p.on = v; }),
217
+ h('div', { class: 'note' }, 'Colours blend along a particle\'s life, left to right.'), stopsBox,
218
+ num('Count', () => p.count, (v) => { p.count = v; }, 1, 300, 1, 'how many are alive at once'),
219
+ pair('Size', () => p.size, (v) => { p.size = v; }, 1, 60, 1, 'diameter in px'),
220
+ pair('Life (ms)', () => p.life, (v) => { p.life = v; }, 100, 4000, 50),
221
+ pair('Speed (px/s)', () => p.speed, (v) => { p.speed = v; }, 0, 400, 5),
222
+ select('Direction', () => p.dir, (v) => { p.dir = v; }, ['up', 'down', 'left', 'right', 'out', 'in', { value: 'deg', label: 'custom degrees' }]),
223
+ num('Degrees', () => p.deg, (v) => { p.deg = v; }, -180, 180, 1, 'used when direction is custom (-90 is up)'),
224
+ num('Spread', () => p.spread, (v) => { p.spread = v; }, 0, 360, 1, 'width of the fan of directions'),
225
+ num('Gravity', () => p.gravity, (v) => { p.gravity = v; }, -300, 300, 5, 'negative pulls up, like a flame'),
226
+ num('Drag', () => p.drag, (v) => { p.drag = v; }, 0, 5, 0.1),
227
+ num('Turbulence', () => p.turbulence, (v) => { p.turbulence = v; }, 0, 300, 5),
228
+ select('From', () => p.from, (v) => { p.from = v; }, core.FROM_WORDS),
229
+ select('Shape', () => p.shape, (v) => { p.shape = v; }, core.SHAPES),
230
+ num('Grow', () => p.grow, (v) => { p.grow = v; }, 0, 4, 0.1, 'end size as a multiple of the start'),
231
+ check('Glowing', () => p.glowing, (v) => { p.glowing = v; }),
232
+ check('Behind the element', () => p.behind, (v) => { p.behind = v; }, 'draw only outside the element'),
233
+ select('Blend', () => p.blend, (v) => { p.blend = v; }, [{ value: '', label: 'automatic' }, 'add', 'normal']),
234
+ select('Texture', () => p.texture, (v) => { p.texture = v; }, texOpts),
235
+ num('Seed', () => p.seed, (v) => { p.seed = v; }, 1, 99, 1),
236
+ check('Essential', () => p.essential, (v) => { p.essential = v; }),
237
+ );
238
+ controls.push(glowCtl, partCtl);
239
+
240
+ const stage = h('div', { class: 'stage' }, h('div', { class: 'target', id: 'aura-target' }, 'element'));
241
+ aura.stage = stage; aura.target = $('.target', stage);
242
+ const bgPick = h('div', { class: 'bgs' }, h('span', {}, 'Preview on:'), ...[['#0b0a12', 'dark'], ['#f2f0f7', 'light'], ['#2a2f3a', 'slate']].map(([c, n]) => h('button', { class: 'mini', onclick: () => { stage.style.background = c; aura.target.style.background = c === '#f2f0f7' ? '#cfc8e0' : '#1d1b2c'; aura.target.style.color = c === '#f2f0f7' ? '#222' : '#ccc'; } }, n)));
243
+ const graphs = group('Graphs',
244
+ h('div', { class: 'cap' }, 'One particle over its life'), h('canvas', { id: 'life-curve', width: 360, height: 90 }),
245
+ h('div', { class: 'cap' }, 'Live: particles alive'), h('canvas', { id: 'live-graph', width: 360, height: 50 }));
246
+ return { controls, preview: [stage, bgPick, graphs], onChange: () => { const text = core.auraToDssl(aura.state, aura.style); setOutput(text); applyAuraPreview(text); } };
247
+ }
248
+
249
+ /* ====================================================================== *
250
+ * TEXTURE TAB
251
+ * ====================================================================== */
252
+
253
+ const tex = { state: core.defaultTextureState(), sel: 0 };
254
+
255
+ function textureCss(text) {
256
+ const hooks = { error() {}, color: (w) => w, suggest: () => null };
257
+ const decls = core.splitDecls(text).filter(([p]) => p === 'texture');
258
+ if (!decls.length) return null;
259
+ const layers = parseTexture(decls[0][1], hooks);
260
+ return layers ? buildTexture(layers, parseColor) : null;
261
+ }
262
+
263
+ function buildTextureTab() {
264
+ const s = tex.state;
265
+ const layer = () => s.layers[Math.min(tex.sel, s.layers.length - 1)] || s.layers[0];
266
+ const grid = h('div', { class: 'tiles' });
267
+ const tile = (name, kind) => {
268
+ const b = textureCss(`texture: ${name}`);
269
+ const t = h('button', { class: 'tile', title: PRESETS[name] ? PRESETS[name].doc : `generator: ${name}`, onclick: () => { s.layers[tex.sel] = core.defaultTextureLayer(name); rebuild(); } }, h('span', { class: 'sw' }), h('span', { class: 'nm' }, name));
270
+ if (b) Object.assign($('.sw', t).style, { backgroundColor: '#b8a58a', backgroundImage: b.image, backgroundSize: b.size, backgroundRepeat: b.repeat, backgroundPosition: b.position, backgroundBlendMode: b.blend });
271
+ t.dataset.name = name;
272
+ return t;
273
+ };
274
+ core.PRESET_NAMES.forEach((n) => grid.append(tile(n)));
275
+ grid.append(h('div', { class: 'tilehead' }, 'Generators (start from scratch)'));
276
+ core.GENERATORS.forEach((n) => grid.append(tile(n)));
277
+
278
+ const layerBar = h('div', { class: 'layers' });
279
+ const settings = h('div', {});
280
+ const swatch = h('div', { class: 'swatch', id: 'tex-swatch' });
281
+ const baseColor = h('input', { type: 'color', value: s.base, onchange: (e) => { s.base = e.target.value; swatch.style.backgroundColor = s.base; } });
282
+ const numeric = ['scale', 'intensity', 'contrast', 'threshold', 'octaves', 'seed', 'depth', 'light', 'angle', 'thickness', 'radius', 'softness', 'contour', 'animate'];
283
+
284
+ function rebuild() {
285
+ layerBar.replaceChildren(...s.layers.map((l, i) => h('button', { class: 'mini' + (i === tex.sel ? ' on' : ''), onclick: () => { tex.sel = i; rebuild(); } }, `${i + 1}. ${l.name}`)),
286
+ h('button', { class: 'mini add', disabled: s.layers.length >= 4, onclick: () => { s.layers.push(core.defaultTextureLayer('grain')); tex.sel = s.layers.length - 1; rebuild(); } }, '+ stack another'),
287
+ h('button', { class: 'mini', disabled: s.layers.length <= 1, onclick: () => { s.layers.splice(tex.sel, 1); tex.sel = 0; rebuild(); } }, 'remove this'));
288
+ settings.replaceChildren();
289
+ const L = layer();
290
+ const base = PRESETS[L.name] ? PRESETS[L.name].layers[0] : {};
291
+ const eff = (k) => (L.set[k] !== undefined ? L.set[k] : base[k]);
292
+ const info = h('div', { class: 'note' }, PRESETS[L.name] ? `${L.name}: ${PRESETS[L.name].doc}` : `${L.name} (a generator: set it up yourself)`);
293
+ settings.append(info);
294
+ for (const k of numeric) {
295
+ const def = TEXTURE_KEYS[k];
296
+ const dflt = base[k] !== undefined ? base[k] : (def.min + Math.min(def.max, 20) / 2);
297
+ const step = def.int ? 1 : def.max <= 2 ? 0.05 : def.max <= 40 ? 0.1 : 1;
298
+ const el = num(k, () => (eff(k) !== undefined ? eff(k) : dflt), (v) => { L.set[k] = v; }, def.min, Math.min(def.max, k === 'scale' ? 300 : def.max), step, def.doc);
299
+ settings.append(el);
300
+ }
301
+ const st = eff('stretch') || [1, 1];
302
+ settings.append(
303
+ num('stretch x', () => (L.set.stretch || st)[0], (v) => { L.set.stretch = [v, (L.set.stretch || st)[1]]; }, 1, 80, 1, TEXTURE_KEYS.stretch.doc),
304
+ num('stretch y', () => (L.set.stretch || st)[1], (v) => { L.set.stretch = [(L.set.stretch || st)[0], v]; }, 1, 80, 1),
305
+ select('blend', () => eff('blend') || 'normal', (v) => { L.set.blend = v; }, ['normal', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'color-burn', 'color-dodge', 'darken', 'lighten', 'difference', 'exclusion']),
306
+ select('type', () => eff('type') || 'fractal', (v) => { L.set.type = v; }, ['fractal', 'turbulence']),
307
+ check('invert', () => !!L.set.invert, (v) => { L.set.invert = v || undefined; }),
308
+ h('div', { class: 'pair' },
309
+ check('own colour', () => !!L.color, (v) => { L.color = v ? (L.color || { hex: '#000000', a: 1 }) : null; rebuild(); }),
310
+ L.color ? color('colour', () => L.color, (v) => { L.color = v; }) : null),
311
+ h('div', { class: 'pair' },
312
+ check('own colour 2', () => !!L.color2, (v) => { L.color2 = v ? (L.color2 || { hex: '#ffffff', a: 1 }) : null; rebuild(); }),
313
+ L.color2 ? color('colour 2', () => L.color2, (v) => { L.color2 = v; }) : null),
314
+ h('button', { class: 'mini', onclick: () => { L.set = {}; L.color = null; L.color2 = null; rebuild(); } }, 'reset this layer to the preset'));
315
+ api.onChange();
316
+ }
317
+ const api = {
318
+ controls: [group('Textures', h('div', { class: 'note' }, 'Pick a preset (or a generator), then adjust it. Stack up to 4.'), grid), group('Layers', layerBar, settings)],
319
+ preview: [h('div', { class: 'swrow' }, h('span', {}, 'Preview on:'), baseColor), swatch,
320
+ h('div', { class: 'sample', style: 'margin-top:8px' }, 'The same texture over text: ', h('b', {}, 'DSSL'))],
321
+ onChange: () => {
322
+ const text = core.textureToDssl(s);
323
+ const c = setOutput(text);
324
+ const b = c.ok ? textureCss(text) : null;
325
+ swatch.style.backgroundColor = s.base;
326
+ Object.assign(swatch.style, { backgroundImage: b ? b.image : 'none', backgroundSize: b ? b.size : '', backgroundRepeat: b ? b.repeat : '', backgroundPosition: b ? b.position : '', backgroundBlendMode: b ? b.blend : '' });
327
+ },
328
+ afterBuild: rebuild,
329
+ };
330
+ return api;
331
+ }
332
+
333
+ /* ====================================================================== *
334
+ * SLIDER TAB
335
+ * ====================================================================== */
336
+
337
+ const sl = { state: core.defaultSliderState() };
338
+
339
+ function buildSliderTab() {
340
+ const s = sl.state;
341
+ const style = h('style', { id: 'pv-style' });
342
+ const input = h('input', { type: 'range', id: 'pv', min: 0, max: 100, value: 60 });
343
+ input.addEventListener('input', () => input.style.setProperty('--dssl-p', String(Number(input.value) / 100)));
344
+ input.style.setProperty('--dssl-p', '0.6');
345
+ const bars = h('div', { class: 'bars' });
346
+ const renderBars = () => {
347
+ bars.replaceChildren(...s.bars.map((v, i) => h('input', { type: 'range', class: 'bar', min: 0, max: 100, value: v, title: `bar ${i + 1}`, oninput: (e) => { s.bars[i] = Number(e.target.value); onChange(); } })));
348
+ };
349
+ renderBars();
350
+ const controls = [
351
+ group('Track',
352
+ num('Height', () => s.trackH, (v) => { s.trackH = v; }, 1, 40),
353
+ color('Colour', () => s.trackColor, (v) => { s.trackColor = v; }),
354
+ select('Corners', () => s.trackRadius, (v) => { s.trackRadius = v; }, [{ value: 'full', label: 'fully round' }, { value: 'px', label: 'a number of px' }]),
355
+ num('Corner px', () => s.trackRadiusPx, (v) => { s.trackRadiusPx = v; }, 0, 20),
356
+ check('Fill', () => s.fill, (v) => { s.fill = v; }, 'colour the part before the thumb'),
357
+ color('Fill colour', () => s.fillColor, (v) => { s.fillColor = v; })),
358
+ group('Thumb',
359
+ num('Size', () => s.thumb, (v) => { s.thumb = v; }, 6, 60),
360
+ color('Colour', () => s.thumbColor, (v) => { s.thumbColor = v; }),
361
+ select('Shape', () => s.shape, (v) => { s.shape = v; }, core.THUMB_SHAPES),
362
+ check('Ring', () => s.ring, (v) => { s.ring = v; }), num('Ring width', () => s.ringW, (v) => { s.ringW = v; }, 1, 10), color('Ring colour', () => s.ringColor, (v) => { s.ringColor = v; }),
363
+ check('Glow', () => s.glow, (v) => { s.glow = v; }), color('Glow colour', () => s.glowColor, (v) => { s.glowColor = v; }), num('Glow radius', () => s.glowR, (v) => { s.glowR = v; }, 2, 60)),
364
+ group('Ticks',
365
+ check('Ticks', () => s.ticks, (v) => { s.ticks = v; }), num('Count', () => s.tickCount, (v) => { s.tickCount = v; }, 2, 40), color('Colour', () => s.tickColor, (v) => { s.tickColor = v; }), num('Length', () => s.tickLen, (v) => { s.tickLen = v; }, 2, 20)),
366
+ group('Graph (histogram slider)',
367
+ check('Show graph', () => s.graph, (v) => { s.graph = v; }),
368
+ num('Height', () => s.graphH, (v) => { s.graphH = v; }, 16, 120), num('Bar gap', () => s.graphGap, (v) => { s.graphGap = v; }, 0, 10),
369
+ h('div', { class: 'cap' }, 'Bar heights'), bars,
370
+ h('div', { class: 'pair' },
371
+ h('button', { class: 'mini', onclick: () => { s.bars = Array.from({ length: s.bars.length }, (_, i) => Math.round(50 + 45 * Math.sin(i / 1.6))); renderBars(); onChange(); } }, 'wave'),
372
+ h('button', { class: 'mini', onclick: () => { s.bars = Array.from({ length: s.bars.length }, (_, i) => Math.round(((i + 1) / s.bars.length) * 100)); renderBars(); onChange(); } }, 'ramp'),
373
+ h('button', { class: 'mini', onclick: () => { s.bars = Array.from({ length: s.bars.length }, () => Math.round(10 + Math.random() * 90)); renderBars(); onChange(); } }, 'random'),
374
+ h('button', { class: 'mini', onclick: () => { s.bars = s.bars.length < 40 ? [...s.bars, 50] : s.bars; renderBars(); onChange(); } }, '+ bar'),
375
+ h('button', { class: 'mini', onclick: () => { s.bars = s.bars.length > 2 ? s.bars.slice(0, -1) : s.bars; renderBars(); onChange(); } }, '− bar'))),
376
+ ];
377
+ return {
378
+ controls,
379
+ preview: [h('div', { class: 'sliderstage' }, input, style), h('div', { class: 'note' }, 'This panel is Chromium, so it shows the -webkit-slider-* rules. The -moz-range-* rules for Firefox are written alongside them by the compiler.')],
380
+ onChange: () => {
381
+ const text = core.sliderToDssl(s);
382
+ const c = setOutput(text);
383
+ const hooks = { error() {}, color: (w) => w, suggest: () => null };
384
+ const decl = core.splitDecls(text).find(([p]) => p === 'slider');
385
+ const spec = c.ok && decl ? parseSlider(decl[1], hooks) : null;
386
+ if (!spec) return;
387
+ const css = sliderCss(spec);
388
+ const rule = (sel, decls) => `${sel} { ${decls.map((d) => `${d.prop}: ${d.value}`).join('; ')} }`;
389
+ style.textContent = [rule('#pv', css.base), ...css.shadow.filter((x) => x.pseudo.startsWith('-webkit')).map((x) => rule(`#pv::${x.pseudo}`, x.decls))].join('\n');
390
+ },
391
+ };
392
+ }
393
+
394
+ /* ====================================================================== *
395
+ * the shell: tabs, output box, buttons
396
+ * ====================================================================== */
397
+
398
+ const tabs = {};
399
+ let current = 'aura';
400
+
401
+ function showTab(name) {
402
+ current = name;
403
+ document.body.dataset.tab = name;
404
+ for (const [n, t] of Object.entries(tabs)) { t.panel.hidden = n !== name; t.btn.classList.toggle('on', n === name); }
405
+ if (name !== 'aura' && aura.manager) aura.manager.set(aura.target, [], []);
406
+ onChange = tabs[name].api.onChange;
407
+ onChange();
408
+ vscode.setState({ tab: name, aura: aura.state, tex: tex.state, sl: sl.state, style: aura.style });
409
+ }
410
+
411
+ /** load a snippet (from the editor's selection) into whichever tab it belongs to */
412
+ function loadText(text) {
413
+ const notes = [];
414
+ const props = core.splitDecls(text).map(([p]) => p);
415
+ let target = null;
416
+ if (props.some((p) => /^(aura|aura-|glow-)/.test(p))) {
417
+ const r = core.auraFromDssl(text); Object.assign(aura.state, r.state); notes.push(...r.notes); target = 'aura';
418
+ } else if (props.includes('texture')) {
419
+ const r = core.textureFromDssl(text); Object.assign(tex.state, r.state); tex.sel = 0; notes.push(...r.notes); target = 'texture';
420
+ } else if (props.includes('slider')) {
421
+ const r = core.sliderFromDssl(text); Object.assign(sl.state, r.state); notes.push(...r.notes); target = 'slider';
422
+ }
423
+ if (!target) { flash('Nothing to load: select an aura, texture or slider declaration first.'); return; }
424
+ mount(target); // rebuild the controls from the new state
425
+ flash(notes.length ? `Loaded. ${notes.join(' ')}` : 'Loaded from your selection.');
426
+ }
427
+
428
+ let flashBox;
429
+ function flash(msg) { flashBox.textContent = msg; flashBox.className = 'flash on'; clearTimeout(flash.t); flash.t = setTimeout(() => { flashBox.className = 'flash'; }, 5000); }
430
+
431
+ function mount(name) {
432
+ const builders = { aura: buildAuraTab, texture: buildTextureTab, slider: buildSliderTab };
433
+ const api = builders[name]();
434
+ const t = tabs[name];
435
+ t.api = api;
436
+ t.panel.replaceChildren(h('div', { class: 'cols' }, h('div', { class: 'left' }, ...api.controls), h('div', { class: 'right' }, ...api.preview)));
437
+ if (api.afterBuild) { onChange = api.onChange; api.afterBuild(); }
438
+ if (current === name) { onChange = api.onChange; api.onChange(); }
439
+ }
440
+
441
+ function init() {
442
+ const app = $('#app');
443
+ const saved = vscode.getState();
444
+ if (saved) {
445
+ if (saved.aura) Object.assign(aura.state, saved.aura);
446
+ if (saved.tex) Object.assign(tex.state, saved.tex);
447
+ if (saved.sl) Object.assign(sl.state, saved.sl);
448
+ if (saved.style) aura.style = saved.style;
449
+ }
450
+ const start = document.body.dataset.tab || (saved && saved.tab) || 'aura';
451
+ flashBox = h('div', { class: 'flash' });
452
+ codeBox = h('textarea', { id: 'code', readonly: true, rows: 7, spellcheck: false });
453
+ statusBox = h('div', { class: 'status' });
454
+ const header = h('header', {}, h('h1', {}, 'DSSL designer'), h('nav', {},
455
+ ...[['aura', 'Aura'], ['texture', 'Texture'], ['slider', 'Slider']].map(([n, l]) => { const b = h('button', { class: 'tab', 'data-tab': n, onclick: () => showTab(n) }, l); tabs[n] = { btn: b, panel: h('section', { class: 'panel', hidden: true }) }; return b; })));
456
+ const outBox = h('footer', {},
457
+ h('div', { class: 'outhead' }, h('strong', {}, 'DSSL'), statusBox),
458
+ codeBox,
459
+ h('div', { class: 'btns' },
460
+ h('button', { id: 'insert', class: 'primary', onclick: () => vscode.postMessage({ type: 'insert', text: out.text }) }, 'Insert at cursor'),
461
+ h('button', { id: 'copy', onclick: async () => { vscode.postMessage({ type: 'copy', text: out.text }); try { await navigator.clipboard.writeText(out.text); } catch { /* the editor copies for us */ } flash('Copied.'); } }, 'Copy'),
462
+ h('button', { id: 'load', onclick: () => vscode.postMessage({ type: 'requestSelection' }) }, 'Load from selection'),
463
+ flashBox));
464
+ app.replaceChildren(header, ...Object.values(tabs).map((t) => t.panel), outBox);
465
+ for (const n of Object.keys(tabs)) mount(n);
466
+ showTab(start);
467
+ setInterval(() => { if (current === 'aura') drawLiveGraph(); }, 100);
468
+ window.addEventListener('message', (e) => {
469
+ const m = e.data || {};
470
+ if (m.type === 'selection') loadText(m.text || '');
471
+ if (m.type === 'tab' && tabs[m.tab]) showTab(m.tab);
472
+ });
473
+ vscode.postMessage({ type: 'ready' });
474
+ window.__designer = { core, aura, tex, sl, showTab, loadText, get out() { return out.text; } }; // for tests and curious people
475
+ }
476
+
477
+ init();
dssl-vscode/package.json CHANGED
@@ -1,8 +1,8 @@
1
  {
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, hover docs. Works in VS Code and VSCodium.",
5
- "version": "0.8.2",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
@@ -14,14 +14,24 @@
14
  "Linters"
15
  ],
16
  "keywords": [
17
- "dssl",
18
  "css",
19
- "styling",
20
  "design tokens",
21
- "style language"
 
 
 
 
 
22
  ],
23
  "activationEvents": [
24
- "onLanguage:dssl"
 
 
 
 
 
25
  ],
26
  "main": "./extension.js",
27
  "contributes": {
@@ -59,10 +69,61 @@
59
  "description": "Milliseconds to wait after a keystroke before re-running the compiler."
60
  }
61
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  }
63
  },
64
  "repository": {
65
  "type": "git",
66
  "url": "https://huggingface.co/EHS-Studio/dssl"
67
  }
68
- }
 
1
  {
2
  "name": "dssl-vscode",
3
  "displayName": "DSSL Language Support",
4
+ "description": "The DSSL style language in your editor: syntax highlighting, real-compiler diagnostics (the same errors the browser console shows), completions from the live token registry and for textures, auras and sliders, hover docs, an interactive designer with sliders and live previews for aura, texture and range sliders, and a CSS to DSSL converter. Works in VS Code and VSCodium.",
5
+ "version": "0.9.0",
6
  "publisher": "ehs-studio",
7
  "icon": "icon.png",
8
  "license": "MIT",
 
14
  "Linters"
15
  ],
16
  "keywords": [
17
+ "aura",
18
  "css",
19
+ "css converter",
20
  "design tokens",
21
+ "designer",
22
+ "dssl",
23
+ "particles",
24
+ "style language",
25
+ "styling",
26
+ "texture"
27
  ],
28
  "activationEvents": [
29
+ "onLanguage:dssl",
30
+ "onCommand:dssl.openDesigner",
31
+ "onCommand:dssl.openAuraDesigner",
32
+ "onCommand:dssl.openTextureDesigner",
33
+ "onCommand:dssl.openSliderDesigner",
34
+ "onCommand:dssl.convertCss"
35
  ],
36
  "main": "./extension.js",
37
  "contributes": {
 
69
  "description": "Milliseconds to wait after a keystroke before re-running the compiler."
70
  }
71
  }
72
+ },
73
+ "commands": [
74
+ {
75
+ "command": "dssl.openDesigner",
76
+ "title": "Open the designer",
77
+ "category": "DSSL",
78
+ "icon": "$(paintcan)"
79
+ },
80
+ {
81
+ "command": "dssl.openAuraDesigner",
82
+ "title": "Design an aura (glow and particles)",
83
+ "category": "DSSL"
84
+ },
85
+ {
86
+ "command": "dssl.openTextureDesigner",
87
+ "title": "Design a texture",
88
+ "category": "DSSL"
89
+ },
90
+ {
91
+ "command": "dssl.openSliderDesigner",
92
+ "title": "Design a range slider",
93
+ "category": "DSSL"
94
+ },
95
+ {
96
+ "command": "dssl.convertCss",
97
+ "title": "Convert a CSS file to DSSL",
98
+ "category": "DSSL"
99
+ }
100
+ ],
101
+ "menus": {
102
+ "editor/title": [
103
+ {
104
+ "command": "dssl.openDesigner",
105
+ "when": "resourceLangId == dssl",
106
+ "group": "navigation"
107
+ }
108
+ ],
109
+ "editor/context": [
110
+ {
111
+ "command": "dssl.openDesigner",
112
+ "when": "resourceLangId == dssl",
113
+ "group": "navigation@9"
114
+ }
115
+ ],
116
+ "explorer/context": [
117
+ {
118
+ "command": "dssl.convertCss",
119
+ "when": "resourceExtname == .css",
120
+ "group": "navigation@9"
121
+ }
122
+ ]
123
  }
124
  },
125
  "repository": {
126
  "type": "git",
127
  "url": "https://huggingface.co/EHS-Studio/dssl"
128
  }
129
+ }