Z User commited on
Commit
2d39154
·
1 Parent(s): 5240484

v0.7.1: editor extension (real compiler, VS Code + VSCodium), bench page, llms.txt + AGENTS.md + PARITY.md, comment line-number fix; README grows the editor chapter

Browse files
AGENTS.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md — for AI agents (and humans) working on this repo
2
+
3
+ DSSL is a style language that replaces CSS. The engine lives in `public/dssl/` (parser,
4
+ resolver, engine, boot, utils), the site in `src/`, the styles that drive the site in
5
+ `public/styles/`, tests in `scripts/test-v*.mjs`, the editor extension in `dssl-vscode/`.
6
+ The HF mirror of this repo carries the full docs in its README.
7
+
8
+ ## Commands
9
+
10
+ ```sh
11
+ npm run dev # Next dev server (site)
12
+ node scripts/dssl-styles-check.mjs # canonical gate: every .dssl file, 0 errors required
13
+ node scripts/test-v050.mjs # engine suites: run ALL test-v0*.mjs, all must pass
14
+ node scripts/test-v060.mjs
15
+ node scripts/test-v070.mjs
16
+ node scripts/test-v071.mjs
17
+ node dssl-vscode/test/run.mjs # extension analysis tests
18
+ node scripts/sync-extension-engine.mjs # REQUIRED after touching public/dssl/*.js
19
+ ```
20
+
21
+ ## Rules of the house
22
+
23
+ 1. The engine stays the engine. No compile-to-CSS pivot, no hybrid output. Inline styles
24
+ applied live are the architecture; pseudo-elements compile to one shadow stylesheet and
25
+ that is the only CSS the engine ever writes. Do not propose otherwise; the owner has
26
+ ruled twice.
27
+ 2. New engine surface needs tests in `scripts/test-vNNN.mjs` (numbered by version) and a
28
+ changelog entry in `src/components/dssl/views/ChangelogView.tsx`.
29
+ 3. The site dogfoods the engine: site styling lives in `public/styles/*.dssl` and MUST
30
+ pass styles-check with 0 errors. If you add a view: create its `.dssl`, add it to
31
+ `DSSL_SHEETS` in `src/app/layout.tsx`, add the route to `SiteRoot.tsx` (ViewName, VIEWS,
32
+ render line, `[data-view="x"] .nl-x` nav-active rule in site.dssl).
33
+ 4. After ANY change to `public/dssl/*.js`, run `scripts/sync-extension-engine.mjs` so the
34
+ editor extension bundles the same compiler, then re-run the extension tests.
35
+ 5. Never commit tokens or credentials. Not to this repo, not to the HF mirror, not into
36
+ the site's public/ folder. Backups live OUTSIDE tracked files.
37
+ 6. Deploys: `vercel deploy --prod --yes --token "$(cat /home/z/.vercel_token)"` for the
38
+ site; the HF mirror pushes via a tokenized remote URL that gets washed back to clean
39
+ immediately after the push. If the tokens are missing, they can be restored from the
40
+ backup file inside the project's .git/backups/ directory (that spot survives resets).
41
+ 7. Verify ships with raw curl (cache busters), not only fetchers: CDN caches have shown
42
+ stale content minutes after a successful deploy.
43
+
44
+ ## Conventions
45
+
46
+ - Errors are loud, with did-you-mean built from edit distance over known properties,
47
+ tokens and motions. Warnings (v0.7+) compile fine but get printed; `ok` stays `true`.
48
+ - The token registry folds across files in load order; `tokens*.dssl` sorts first in the
49
+ editor for the same reason.
50
+ - Numbers are bare and mean pixels. No semicolons required. Comments `//` and `/* */`.
51
+ - The site is the test suite: when the site needs something the language cannot express,
52
+ the language grows. Keep that loop.
PARITY.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PARITY.md — DSSL vs CSS, honestly
2
+
3
+ The point of this table is to stop two bad arguments: "DSSL is missing everything" and
4
+ "DSSL replaces all of CSS". Both are false. Here is the ledger. Status: shipped / partial
5
+ / planned / by-design-no.
6
+
7
+ | CSS feature | In DSSL | Status | Notes |
8
+ |---|---|---|---|
9
+ | Type / class / id / attribute selectors | native (rides browser matching) | shipped | |
10
+ | Combinators (`>`, `+`, `~`, descendant) | native | shipped | |
11
+ | Nesting | native, `&` substitution | shipped | predates CSS nesting; no build step needed |
12
+ | `:nth-child()`, `:nth-of-type()`, `:not()`, `:is()`, `:where()`, `:has()` | native | shipped | the browser does the matching; v0.7 docs say so loudly |
13
+ | Pseudo-classes (`:hover`, `:focus`...) | engine-native states as nested blocks | shipped | real events, enter + leave, descendants re-evaluated; `seen` (first viewport entry) has no CSS equivalent |
14
+ | Pseudo-elements | one compiled shadow stylesheet | shipped | the ONE place DSSL emits real CSS; inline styles cannot reach them |
15
+ | `@media` queries | `@media (max-width: 860)`, sugar: `dark`, `light`, `reduced-motion`, bare widths, `<=` ranges | shipped | |
16
+ | `@container` queries | `@container (min-width: 480)` | shipped | nearest ancestor (or self) with container-type |
17
+ | `@layer` cascade layers | `@layer base\|components\|utilities` + `pin {}` | shipped | cascade is (layer, source order); no specificity, no !important |
18
+ | `@supports` | not implemented | planned | low priority: the engine feature-detects itself |
19
+ | `@font-face` | not implemented | planned | `font.*` tokens can name any loaded family today |
20
+ | `@keyframes` | `motion name { from {} to {} }` via WAAPI | shipped | `animate: name 600 ease-out infinite alternate` |
21
+ | View transitions | not implemented | planned | |
22
+ | Scroll-driven animations | not implemented | planned | pairs naturally with `seen` + `stagger` |
23
+ | Custom properties (`--*`) | pass through unvalidated, plus dotted token re-declaration per subtree | shipped | `--color.accent: color.err` re-themes a subtree via inheritance |
24
+ | Design tokens | first-class: `tokens { }`, numeric keys, alpha suffix `/85`, scoped per file and subtree | shipped | no CSS-native equivalent at this ergonomics level |
25
+ | Flexbox / Grid | raw properties pass through + `row:` `column:` `grid:` `stack:` sugar | shipped | shorthands write `display`; the compiler warns when that clobbers an explicit `display:` |
26
+ | `place-items` / alignment | `center: both` | shipped | the engine promotes a formatting context when missing, so alignment cannot silently no-op |
27
+ | Transitions | per-property: `transition: 200 snap transform` | shipped | honors `reduced-motion` |
28
+ | print styles (`@media print`) | not implemented | planned | |
29
+ | `!important` | by design no | by-design-no | `pin { }` is the must-win block; specificity wars are the thing DSSL deletes |
30
+ | Cascade | (layer, source order) | shipped | later wins; identical selectors resolve by order, deterministically |
31
+ | Static extraction / view-source styling | live engine only today; prerender snapshot planned | planned | freeze() already makes big static pages zero-cost after first paint |
32
+ | DevTools story | `DSSL.explain(el)` returns the cascade as data | shipped | matched rules with file/line, winning declaration per property, pseudo rules; browser-extension panel planned |
33
+ | Work without JavaScript | not possible for first paint (today) | planned (prerender) | the honest trade; content pages should wait for prerender or pick another tool |
34
+
35
+ ## The exit story
36
+
37
+ Abandoning DSSL leaves you: a DOM of valid inline styles, one small `<style>` element
38
+ (pseudo-element rules), and token values sitting in `--dssl-*` custom properties. The page
39
+ keeps working with the engine removed. No other styling replacement can say that, because
40
+ no other replacement styles with the platform's own primitives.
41
+
42
+ ## What DSSL adds that CSS does not have
43
+
44
+ - live tokens and signals: change one value or fire one bridge call, the whole DOM re-resolves
45
+ - `seen` reveal-on-scroll and `stagger` choreography with zero JavaScript
46
+ - the deform family composing into the transform channel
47
+ - gradients as sentences with compile-time stop validation
48
+ - the overlap policy: translucent boxes refuse to cover things unless you say so
49
+ - loud errors with did-you-mean, and a warnings channel for cascade-order footguns
50
+ - `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
README.md CHANGED
@@ -68,6 +68,10 @@ Both drop a `dssl-engine/` folder next to you: the engine, a starter token set,
68
 
69
  Prefer to click: [dssl-engine.zip](https://dssl-sable.vercel.app/dssl-engine.zip) or [dssl-engine.tar.gz](https://dssl-sable.vercel.app/dssl-engine.tar.gz). Same package, straight from the browser.
70
 
 
 
 
 
71
  Then wire it into a page with two tags:
72
 
73
  ```html
@@ -389,7 +393,7 @@ const rep = DSSL.explain(document.querySelector('.doc-card'))
389
 
390
  The matcher also pre-filters every rule through a cheap class/tag/id gate extracted from the selector's rightmost compound, so a recompute skips obvious non-matches instead of asking the browser rule by rule. The gate only ever says no; anything it does not fully understand falls back to native matching.
391
 
392
- ## What works today (v0.7.0)
393
 
394
  - the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
395
  - flexbox and grid with `row:` / `column:` / `grid:` / `stack:` / `center:` sugar
@@ -404,6 +408,8 @@ The matcher also pre-filters every rule through a cheap class/tag/id gate extrac
404
  - compiler warnings for cascade-order footguns
405
  - `DSSL.freeze()` / `DSSL.unfreeze()` / `DSSL.explain()`
406
  - bare font names, `hyphens: manual`, loud did-you-mean errors
 
 
407
 
408
  ## Repo layout
409
 
@@ -412,10 +418,12 @@ The matcher also pre-filters every rule through a cheap class/tag/id gate extrac
412
  - `package/` what ships in the download: engine, starter files, usage guide, installers
413
  - `src/` the Next.js site (docs, playground, changelog)
414
  - `scripts/` smoke tests for the engine
 
 
415
 
416
  ## Status
417
 
418
- v0.7.0, experimental, moving fast. The language grows whenever the site needs something it cannot express yet, so the site doubles as the test suite. Honest ledger: great for creative sites, prototypes, docs, playgrounds and internal tools; if a page must work without JavaScript, a style runtime is not the right tool for it. Freeze makes big static pages viable; live interaction is what the engine is for.
419
 
420
  ## License
421
 
 
68
 
69
  Prefer to click: [dssl-engine.zip](https://dssl-sable.vercel.app/dssl-engine.zip) or [dssl-engine.tar.gz](https://dssl-sable.vercel.app/dssl-engine.tar.gz). Same package, straight from the browser.
70
 
71
+ Writing your own .dssl? The official editor extension puts the real compiler in your editor: the squiggles are the same errors the browser console prints, completions come from your own token registry, and hovers explain the semantic vocabulary. One package for VS Code and VSCodium (same extension API): grab `dssl-vscode/dssl-vscode-0.7.1.vsix` from the repo source, or build it with `npx @vscode/vsce package` inside `dssl-vscode/`, then Extensions panel -> ... -> Install from VSIX.
72
+
73
+ AI agents and machine reviewers: this repo ships `llms.txt` (dense, structured facts), `AGENTS.md` (how to work on the codebase) and `PARITY.md` (the honest feature matrix against CSS). The site serves the same `llms.txt` at https://dssl-sable.vercel.app/llms.txt
74
+
75
  Then wire it into a page with two tags:
76
 
77
  ```html
 
393
 
394
  The matcher also pre-filters every rule through a cheap class/tag/id gate extracted from the selector's rightmost compound, so a recompute skips obvious non-matches instead of asking the browser rule by rule. The gate only ever says no; anything it does not fully understand falls back to native matching.
395
 
396
+ ## What works today (v0.7.1)
397
 
398
  - the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
399
  - flexbox and grid with `row:` / `column:` / `grid:` / `stack:` / `center:` sugar
 
408
  - compiler warnings for cascade-order footguns
409
  - `DSSL.freeze()` / `DSSL.unfreeze()` / `DSSL.explain()`
410
  - bare font names, `hyphens: manual`, loud did-you-mean errors
411
+ - honest line numbers: a v0.7.1 parser bug (every `//` comment shifted later lines +1, so diagnostics pointed one line late) is fixed and regression-tested
412
+ - the editor extension bundles the real compiler, so editor squiggles and browser console agree
413
 
414
  ## Repo layout
415
 
 
418
  - `package/` what ships in the download: engine, starter files, usage guide, installers
419
  - `src/` the Next.js site (docs, playground, changelog)
420
  - `scripts/` smoke tests for the engine
421
+ - `dssl-vscode/` the editor extension (VS Code + VSCodium, bundles the real parser/resolver)
422
+ - `llms.txt`, `AGENTS.md`, `PARITY.md` for machine reviewers and contributors
423
 
424
  ## Status
425
 
426
+ v0.7.1, experimental, moving fast. The language grows whenever the site needs something it cannot express yet, so the site doubles as the test suite. Honest ledger: great for creative sites, prototypes, docs, playgrounds and internal tools; if a page must work without JavaScript, a style runtime is not the right tool for it. Freeze makes big static pages viable; live interaction is what the engine is for.
427
 
428
  ## License
429
 
dssl-vscode/.vscodeignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ test/**
2
+ .vscode/**
3
+ node_modules/**
4
+ scripts/**
5
+ **/*.map
6
+ .gitignore
dssl-vscode/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DSSL. inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dssl-vscode/README.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DSSL Language Support (VS Code + VSCodium)
2
+
3
+ The DSSL style language in your editor. Not a re-implementation: this extension bundles
4
+ the **real parser and resolver** that ship in the engine, so the diagnostics you see are
5
+ the same errors the browser console prints, did-you-mean included.
6
+
7
+ Works in **VS Code** and **VSCodium** (same extension API; install the `.vsix` in either
8
+ via Extensions panel → ⋯ → *Install from VSIX*).
9
+
10
+ ## Features
11
+
12
+ - **Real-compiler diagnostics.** Errors AND warnings (the v0.7 warnings channel included:
13
+ the `display: none` clobbered by a later `column:` shows up as a squiggle, not a mystery).
14
+ - **Completions from your registry.** Every `.dssl` file in the workspace is parsed and the
15
+ token registry folded across files (`tokens*.dssl` first). Type `color.` and get your
16
+ palette with resolved values. Type `animate:` and get your motions first.
17
+ - **Hover docs** for the semantic vocabulary (`row:`, `bend:`, `stretch:`, `seen`, `pin`...),
18
+ for tokens (resolved value), for workspace motions and behaviors.
19
+ - **Syntax highlighting** via a TextMate grammar for `.dssl`.
20
+
21
+ ## What it does not do (yet)
22
+
23
+ - No formatter. No go-to-definition for behaviors. No LSP process (diagnostics run in the
24
+ extension host; a language server is the next step if files get huge).
25
+
26
+ ## Configuration
27
+
28
+ | Setting | Default | Meaning |
29
+ |---|---|---|
30
+ | `dssl.diagnostics.enable` | `true` | run the compiler on change and save |
31
+ | `dssl.diagnostics.debounce` | `250` | ms to wait after a keystroke |
32
+
33
+ ## How it works
34
+
35
+ `lib/engine/` holds a synced copy of `public/dssl/{parser,resolver,utils}.js` from the
36
+ main repo (keep them identical with `scripts/sync-extension-engine.mjs`). On change, every
37
+ `.dssl` file in the workspace is parsed once, tokens fold in load order, and the real
38
+ `resolve()` produces the diagnostics. Completions and hovers read the folded registry.
39
+
40
+ ## Links
41
+
42
+ - Site: https://dssl-sable.vercel.app
43
+ - Repo + full docs: https://huggingface.co/EHS-Studio/dssl
44
+ - Feature matrix: PARITY.md in the repo
45
+
46
+ MIT license.
dssl-vscode/extension.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. */
5
+
6
+ const vscode = require('vscode');
7
+
8
+ 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,
15
+ value: vscode.CompletionItemKind.Value,
16
+ keyword: vscode.CompletionItemKind.Keyword,
17
+ block: vscode.CompletionItemKind.Snippet,
18
+ };
19
+
20
+ function debounceMs() {
21
+ return vscode.workspace.getConfiguration('dssl').get('diagnostics.debounce', 250);
22
+ }
23
+
24
+ /* Fold the whole workspace: every .dssl file parsed once, tokens chained in
25
+ * docs-first order (tokens*.dssl before the rest, then alphabetical). */
26
+ async function runAnalysis() {
27
+ if (!mod) return;
28
+ const uris = await vscode.workspace.findFiles('**/*.dssl', '**/node_modules/**');
29
+ const docs = new Map();
30
+ await Promise.all(uris.map(async (uri) => {
31
+ try {
32
+ const doc = await vscode.workspace.openTextDocument(uri);
33
+ docs.set(uri.fsPath, doc.getText());
34
+ } catch { /* file vanished mid-scan */ }
35
+ }));
36
+
37
+ analysis = mod.analyzeWorkspace(docs);
38
+
39
+ const next = new Map();
40
+ for (const [p] of analysis.perFile) {
41
+ next.set(vscode.Uri.file(p), mod.diagnosticsFor(analysis, p).map((d) => {
42
+ const range = new vscode.Range(d.line, d.col, d.line, 400);
43
+ const sev = d.severity === 'error' ? vscode.DiagnosticSeverity.Error : vscode.DiagnosticSeverity.Warning;
44
+ const diag = new vscode.Diagnostic(range, d.message, sev);
45
+ diag.source = 'dssl';
46
+ return diag;
47
+ }));
48
+ }
49
+ // clear stale files (deleted from disk or emptied) then set fresh entries
50
+ diags.clear();
51
+ for (const [uri, items] of next) diags.set(uri, items);
52
+ }
53
+
54
+ function scheduleAnalysis() {
55
+ clearTimeout(debounceTimer);
56
+ debounceTimer = setTimeout(() => { runAnalysis().catch(() => {}); }, debounceMs());
57
+ }
58
+
59
+ function registerCompletion() {
60
+ return vscode.languages.registerCompletionItemProvider(
61
+ { language: 'dssl' },
62
+ {
63
+ provideCompletionItems(document, position) {
64
+ if (!analysis || !mod) return [];
65
+ const text = document.getText();
66
+ const offset = document.offsetAt(position);
67
+ const result = mod.completionsAt(analysis, document.uri.fsPath, text, offset);
68
+ return result.items.map((it) => {
69
+ const item = new vscode.CompletionItem(it.label, KIND[it.kind] || vscode.CompletionItemKind.Text);
70
+ item.detail = it.detail;
71
+ if (it.documentation) item.documentation = new vscode.MarkdownString(it.documentation);
72
+ return item;
73
+ });
74
+ },
75
+ },
76
+ '.', ':', ' ',
77
+ );
78
+ }
79
+
80
+ function registerHover() {
81
+ return vscode.languages.registerHoverProvider({ language: 'dssl' }, {
82
+ provideHover(document, position) {
83
+ if (!analysis || !mod) return null;
84
+ const text = document.getText();
85
+ const offset = document.offsetAt(position);
86
+ const h = mod.hoverAt(analysis, document.uri.fsPath, text, offset);
87
+ if (!h) return null;
88
+ return new vscode.Hover(new vscode.MarkdownString(h.contents));
89
+ },
90
+ });
91
+ }
92
+
93
+ async function activate(context) {
94
+ mod = await import('./lib/analysis.js');
95
+ diags = vscode.languages.createDiagnosticCollection('dssl');
96
+ context.subscriptions.push(diags);
97
+
98
+ context.subscriptions.push(
99
+ vscode.workspace.onDidChangeTextDocument((e) => {
100
+ if (e.document.languageId === 'dssl') scheduleAnalysis();
101
+ }),
102
+ vscode.workspace.onDidOpenTextDocument((d) => { if (d.languageId === 'dssl') scheduleAnalysis(); }),
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();
110
+ }
111
+
112
+ function deactivate() {
113
+ clearTimeout(debounceTimer);
114
+ }
115
+
116
+ module.exports = { activate, deactivate };
dssl-vscode/language-configuration.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "comments": {
3
+ "lineComment": "//",
4
+ "blockComment": ["/*", "*/"]
5
+ },
6
+ "brackets": [
7
+ ["{", "}"],
8
+ ["[", "]"],
9
+ ["(", ")"]
10
+ ],
11
+ "autoClosingPairs": [
12
+ { "open": "{", "close": "}" },
13
+ { "open": "[", "close": "]" },
14
+ { "open": "(", "close": ")" },
15
+ { "open": "\"", "close": "\"", "notIn": ["string", "comment"] },
16
+ { "open": "'", "close": "'", "notIn": ["string", "comment"] }
17
+ ],
18
+ "surroundingPairs": [
19
+ ["{", "}"],
20
+ ["[", "]"],
21
+ ["(", ")"],
22
+ ["\"", "\""],
23
+ ["'", "'"]
24
+ ],
25
+ "folding": {
26
+ "markers": {
27
+ "start": "^\\s*\\{",
28
+ "end": "^\\s*\\}"
29
+ }
30
+ },
31
+ "wordPattern": "[a-zA-Z0-9_][a-zA-Z0-9_.-]*"
32
+ }
dssl-vscode/lib/analysis.js ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
5
+ * real token registry folded across the workspace. */
6
+
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;
18
+ const tb = /tokens[^/]*\.dssl$/.test(b) ? 0 : 1;
19
+ return ta - tb || a.localeCompare(b);
20
+ });
21
+
22
+ const registry = { ...DEFAULT_TOKENS };
23
+ const motions = new Set();
24
+ const behaviors = new Set();
25
+ const perFile = new Map();
26
+
27
+ for (const p of paths) {
28
+ const text = docs.get(p);
29
+ const { ast, errors: parseErrors } = parse(text, p);
30
+ const doc = resolve(ast, p, { ...registry });
31
+ for (const [k, v] of Object.entries(doc.tokens || {})) registry[k] = v;
32
+ for (const m of Object.keys(doc.motions || {})) motions.add(m);
33
+ collectBehaviors(text, behaviors);
34
+ perFile.set(p, { errors: [...parseErrors, ...doc.errors], warnings: doc.warnings || [], doc });
35
+ }
36
+ return { registry, motions, behaviors, perFile };
37
+ }
38
+
39
+ function collectBehaviors(text, into) {
40
+ const re = /^\s*behavior\s+([\w-]+)/gm;
41
+ let m;
42
+ while ((m = re.exec(text))) into.add(m[1]);
43
+ }
44
+
45
+ /* ---- diagnostics ------------------------------------------------------- */
46
+
47
+ /* Returns [{ line (0-based), col, message, severity: 'error'|'warning', hint, source }] */
48
+ export function diagnosticsFor(analysis, path) {
49
+ const file = analysis.perFile.get(path);
50
+ if (!file) return [];
51
+ const out = [];
52
+ for (const e of file.errors) {
53
+ out.push({
54
+ line: Math.max(0, (e.line ?? 1) - 1),
55
+ col: 0,
56
+ message: e.message + (e.hint ? ` (did you mean "${e.hint}"?)` : ''),
57
+ severity: 'error',
58
+ hint: e.hint,
59
+ source: 'dssl',
60
+ });
61
+ }
62
+ for (const w of file.warnings) {
63
+ out.push({
64
+ line: Math.max(0, (w.line ?? 1) - 1),
65
+ col: 0,
66
+ message: w.message + (w.hint ? ` ${w.hint}` : ''),
67
+ severity: 'warning',
68
+ hint: w.hint,
69
+ source: 'dssl',
70
+ });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /* ---- context detection ------------------------------------------------- */
76
+
77
+ /* Strip comments and strings so brace/paren tracking and line inspection
78
+ * never trip on them. Returns masked text (same offsets). */
79
+ function maskCode(text) {
80
+ let out = '';
81
+ let i = 0;
82
+ let mode = null; // 'line' | 'block' | 'dq' | 'sq'
83
+ while (i < text.length) {
84
+ const c = text[i];
85
+ const n = text[i + 1];
86
+ if (mode === null) {
87
+ if (c === '/' && n === '/') { mode = 'line'; out += ' '; i += 2; continue; }
88
+ if (c === '/' && n === '*') { mode = 'block'; out += ' '; i += 2; continue; }
89
+ if (c === '"') { mode = 'dq'; out += ' '; i += 1; continue; }
90
+ if (c === "'") { mode = 'sq'; out += ' '; i += 1; continue; }
91
+ out += c; i += 1; continue;
92
+ }
93
+ if (mode === 'line') { if (c === '\n') { mode = null; out += '\n'; } else out += ' '; i += 1; continue; }
94
+ if (mode === 'block') { if (c === '*' && n === '/') { mode = null; out += ' '; i += 2; } else { out += c === '\n' ? '\n' : ' '; i += 1; } continue; }
95
+ // strings: terminate or newline kills them
96
+ if ((mode === 'dq' && c === '"') || (mode === 'sq' && c === "'")) { mode = null; out += ' '; i += 1; continue; }
97
+ if (c === '\n') { mode = null; out += '\n'; i += 1; continue; }
98
+ out += ' '; i += 1;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /* Returns { depth, inDecl, prop, valuePrefix, lineStart, lineToCursor } */
104
+ export function contextAt(text, offset) {
105
+ const masked = maskCode(text.slice(0, offset));
106
+ let depth = 0;
107
+ let paren = 0;
108
+ for (const c of masked) {
109
+ if (c === '{') depth++;
110
+ else if (c === '}') depth = Math.max(0, depth - 1);
111
+ else if (c === '(') paren++;
112
+ else if (c === ')') paren = Math.max(0, paren - 1);
113
+ }
114
+ const nl = masked.lastIndexOf('\n');
115
+ const lineToCursor = masked.slice(nl + 1);
116
+ const tokenDot = lineToCursor.match(/([\w-]+)\.([\w-]*)$/);
117
+ // declaration line: "prop: value..." anchored at line start, but a line
118
+ // that also opens a block ("a:hover {") is a selector, not a declaration
119
+ let declMatch = lineToCursor.match(/^(\s*)(-{0,2}[.\w-]+)\s*:\s*([\s\S]*)$/);
120
+ if (declMatch && declMatch[3].includes('{')) declMatch = null;
121
+ // the value prefix is the trailing word segment: after "gap " (typed +
122
+ // space) it is empty, so completions re-open for the next word
123
+ const valueRaw = declMatch ? declMatch[3] : '';
124
+ const tail = valueRaw.match(/([^\s{;,]*)$/);
125
+ return {
126
+ depth,
127
+ paren,
128
+ inDecl: !!declMatch,
129
+ prop: declMatch ? declMatch[2] : null,
130
+ valuePrefix: tail ? tail[0] : '',
131
+ tokenGroup: tokenDot ? tokenDot[1] : null,
132
+ tokenPartial: tokenDot ? tokenDot[2] : null,
133
+ lineToCursor,
134
+ };
135
+ }
136
+
137
+ /* ---- completions ------------------------------------------------------- */
138
+
139
+ /* kind strings map to vscode.CompletionItemKind in extension.js */
140
+ export function completionsAt(analysis, path, text, offset) {
141
+ const ctx = contextAt(text, offset);
142
+ const items = [];
143
+
144
+ // token dot-completion: color., space., anything the registry knows
145
+ if (ctx.tokenGroup) {
146
+ const prefix = ctx.tokenGroup + '.';
147
+ const seen = new Set();
148
+ for (const key of Object.keys(analysis.registry)) {
149
+ if (key.startsWith(prefix)) {
150
+ const rest = key.slice(prefix.length);
151
+ if (!rest.includes('.') && !seen.has(rest)) {
152
+ seen.add(rest);
153
+ items.push({
154
+ label: rest,
155
+ kind: 'value',
156
+ detail: String(analysis.registry[key]),
157
+ documentation: `token ${prefix}${rest} = ${analysis.registry[key]}`,
158
+ });
159
+ }
160
+ }
161
+ }
162
+ if (items.length) return { items, context: 'token' };
163
+ }
164
+
165
+ // inside a declaration: values
166
+ if (ctx.inDecl) {
167
+ const prop = (ctx.prop || '').replace(/^-/, '');
168
+ // motions first for animate:, plus the animate-only grammar words
169
+ if (prop === 'animate') {
170
+ for (const m of analysis.motions) {
171
+ items.push({ label: m, kind: 'value', detail: 'motion', documentation: `motion defined in the workspace` });
172
+ }
173
+ for (const k of ['infinite', 'alternate', 'reverse', 'alternate-reverse', 'snap', 'essential', 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'forwards', 'backwards', 'both']) {
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
+ }
180
+ for (const key of Object.keys(analysis.registry)) {
181
+ items.push({ label: key, kind: 'value', detail: 'token = ' + analysis.registry[key], documentation: `token ${key} = ${analysis.registry[key]}` });
182
+ }
183
+ return { items, context: 'value' };
184
+ }
185
+
186
+ // property / block position
187
+ for (const p of PROPERTY_NAMES) {
188
+ items.push({ label: p, kind: 'property', detail: SEMANTIC[p] ? 'semantic' : 'css', documentation: SEMANTIC[p] || CSS[p] });
189
+ }
190
+ for (const [s, doc] of Object.entries(STATES)) {
191
+ items.push({ label: s, kind: 'block', detail: 'state block', documentation: doc });
192
+ }
193
+ for (const [b, doc] of Object.entries(BLOCKS)) {
194
+ items.push({ label: b, kind: 'block', detail: 'block', documentation: doc });
195
+ }
196
+ for (const [a, doc] of Object.entries(AT_RULES)) {
197
+ items.push({ label: a, kind: 'block', detail: 'at-rule', documentation: doc });
198
+ }
199
+ for (const b of analysis.behaviors) {
200
+ items.push({ label: 'use ' + b, kind: 'keyword', detail: 'behavior', documentation: 'apply the behavior "' + b + '"' });
201
+ }
202
+ for (const p of PSEUDO_ELEMENTS) {
203
+ items.push({ label: '::' + p, kind: 'keyword', detail: 'pseudo-element', documentation: 'compiles to real shadow CSS' });
204
+ }
205
+ return { items, context: 'property' };
206
+ }
207
+
208
+ const ANIMATE_EXTRAS = {
209
+ infinite: 'loop forever',
210
+ alternate: 'ease back on every other cycle',
211
+ snap: 'stepped timing',
212
+ essential: 'run even under reduced-motion',
213
+ };
214
+
215
+ function startsWith(s, prefix) {
216
+ if (!prefix) return true;
217
+ return s.startsWith(prefix) || s.includes(prefix);
218
+ }
219
+
220
+ /* ---- hovers ------------------------------------------------------------ */
221
+
222
+ export function hoverAt(analysis, path, text, offset) {
223
+ const before = text.slice(0, offset);
224
+ const after = text.slice(offset);
225
+ const m = before.match(/[\w.@-]+$/);
226
+ const n = after.match(/^[\w.-]*/);
227
+ const word = (m ? m[0] : '') + (n ? n[0] : '');
228
+ if (!word) return null;
229
+
230
+ const clean = word.replace(/^@/, '');
231
+
232
+ // token (possibly dotted)
233
+ if (clean.includes('.')) {
234
+ const v = analysis.registry[clean];
235
+ if (v !== undefined) {
236
+ return { contents: `**${clean}** \u2192 \`${v}\`\n\n_design token_ (value resolved from the workspace registry)` };
237
+ }
238
+ }
239
+ if (SEMANTIC[clean]) return { contents: `**${clean}** \u2014 ${SEMANTIC[clean]}` };
240
+ if (STATES[clean]) return { contents: `**${clean}** _state_ \u2014 ${STATES[clean]}` };
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
+ }
247
+ if (analysis.behaviors.has(clean)) {
248
+ return { contents: `**${clean}** _behavior_ \u2014 apply with \`use ${clean}\`.` };
249
+ }
250
+ return null;
251
+ }
dssl-vscode/lib/engine/parser.js ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 `}`
7
+ * - declarations are `prop: value` (newline- or `;`-terminated)
8
+ * - one-liners like `.card { padding: 12 }` are allowed
9
+ * - comments: `// ...` and block comments
10
+ *
11
+ * Produces a node tree; classification of headers happens here, value
12
+ * resolution happens in resolver.js.
13
+ * ========================================================================= */
14
+
15
+ const STATE_WORDS = new Set([
16
+ 'hover', 'focus', 'focus-visible', 'active', 'disabled', 'checked',
17
+ 'empty', 'target', 'required', 'invalid', 'valid', 'open', 'closed',
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
+ ]);
32
+
33
+ // v0.4.2 — vendor pseudo-elements pass through verbatim (::-webkit-scrollbar
34
+ // and friends). The DOM gives you no other way to reach native scrollbars,
35
+ // so they ride the same shadow-CSS exception as ::before/::after. The name
36
+ // may carry trailing state (::hover, :horizontal) — it is emitted as-is.
37
+ export const VENDOR_PSEUDO = /^-(webkit|moz|ms)-/;
38
+
39
+ /** find index of first top-level occurrence of `ch` (skips strings + parens) */
40
+ function scanFor(str, ch, from = 0) {
41
+ let depth = 0, q = null;
42
+ for (let i = from; i < str.length; i++) {
43
+ const c = str[i];
44
+ if (q) { if (c === q && str[i - 1] !== '\\') q = null; continue; }
45
+ if (c === '"' || c === "'") { q = c; continue; }
46
+ if (c === '(' || c === '[') { depth++; continue; }
47
+ if (c === ')' || c === ']') { depth = Math.max(0, depth - 1); continue; }
48
+ if (depth === 0 && c === ch) return i;
49
+ }
50
+ return -1;
51
+ }
52
+
53
+ /** split a string on a top-level separator */
54
+ function splitTop(str, sep) {
55
+ const out = [];
56
+ let cur = '', depth = 0, q = null;
57
+ for (let i = 0; i < str.length; i++) {
58
+ const c = str[i];
59
+ if (q) { cur += c; if (c === q && str[i - 1] !== '\\') q = null; continue; }
60
+ if (c === '"' || c === "'") { q = c; cur += c; continue; }
61
+ if (c === '(' || c === '[') { depth++; cur += c; continue; }
62
+ if (c === ')' || c === ']') { depth = Math.max(0, depth - 1); cur += c; continue; }
63
+ if (depth === 0 && c === sep) { out.push(cur); cur = ''; continue; }
64
+ cur += c;
65
+ }
66
+ out.push(cur);
67
+ return out;
68
+ }
69
+
70
+ /** strip comments (respecting strings), newlines preserved so lines survive */
71
+ function stripComments(src) {
72
+ let out = '', q = null;
73
+ for (let i = 0; i < src.length; i++) {
74
+ const c = src[i], n = src[i + 1];
75
+ if (q) {
76
+ out += c;
77
+ if (c === q && src[i - 1] !== '\\') q = null;
78
+ continue;
79
+ }
80
+ if (c === '"' || c === "'") { q = c; out += c; continue; }
81
+ if (c === '/' && n === '/') {
82
+ // v0.7.1 bugfix: this handler used to emit its own '\n' AND rewind so
83
+ // the main loop emitted the same newline again — every // comment
84
+ // doubled a line, and every line number after the first comment
85
+ // drifted +1 (diagnostics pointed one line late). The newline is now
86
+ // emitted exactly once by the main loop.
87
+ while (i < src.length && src[i] !== '\n') i++;
88
+ if (i < src.length) i--; // reprocess the newline once, below
89
+ continue;
90
+ }
91
+ if (c === '/' && n === '*') {
92
+ let j = i + 2;
93
+ while (j < src.length && !(src[j] === '*' && src[j + 1] === '/')) {
94
+ out += src[j] === '\n' ? '\n' : ' ';
95
+ j++;
96
+ }
97
+ i = j + 1; // skip past the closing */
98
+ continue;
99
+ }
100
+ out += c;
101
+ }
102
+ return out;
103
+ }
104
+
105
+ export function parse(source, file = 'inline.dssl') {
106
+ const errors = [];
107
+ const err = (line, message, hint) => errors.push({ file, line, message, hint });
108
+ const root = { kind: 'root', children: [] };
109
+ const stack = [root];
110
+
111
+ const clean = stripComments(source);
112
+ const lines = clean.split('\n');
113
+
114
+ const classify = (header, line) => {
115
+ const h = header.trim();
116
+ if (h === 'tokens') return { kind: 'tokens' };
117
+ if (h === 'pin') return { kind: 'pin' };
118
+ if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
119
+ if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
120
+ if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
121
+ if (h.startsWith('@media ')) return { kind: 'media', query: h.slice(7).trim() };
122
+ if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
123
+ if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
124
+ if (h.startsWith('@')) {
125
+ err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'try @media, @container, @signal or @layer');
126
+ return { kind: 'bogus' };
127
+ }
128
+ if (h.startsWith('::')) {
129
+ const name = h.slice(2).split(/[\s(]/)[0];
130
+ if (!PSEUDO_ELEMENTS.has(name) && !VENDOR_PSEUDO.test(name)) {
131
+ err(line, `unknown pseudo-element "::${name}"`, 'try ::before, ::after, ::selection, or a vendor one like ::-webkit-scrollbar-thumb');
132
+ return { kind: 'bogus' };
133
+ }
134
+ return { kind: 'pseudo', name };
135
+ }
136
+ if (STATE_WORDS.has(h)) return { kind: 'state', name: h };
137
+ return { kind: 'selector', sel: h };
138
+ };
139
+
140
+ /** process a chunk, walking top-level braces so one-liners work */
141
+ const processChunk = (chunk, line) => {
142
+ let rest = chunk;
143
+ while (rest.length) {
144
+ rest = rest.trim();
145
+ if (!rest) return;
146
+
147
+ const open = scanFor(rest, '{');
148
+ const close = scanFor(rest, '}');
149
+
150
+ if (close !== -1 && (open === -1 || close < open)) {
151
+ const before = rest.slice(0, close).trim();
152
+ if (before) processContent(before, line);
153
+ if (stack.length === 1) err(line, 'unexpected "}"');
154
+ else stack.pop();
155
+ rest = rest.slice(close + 1);
156
+ continue;
157
+ }
158
+
159
+ if (open !== -1) {
160
+ const header = rest.slice(0, open).trim();
161
+ if (!header) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
162
+ const meta = classify(header, line);
163
+ const node = { ...meta, line, children: [] };
164
+ stack[stack.length - 1].children.push(node);
165
+ if (meta.kind !== 'bogus') stack.push(node);
166
+ rest = rest.slice(open + 1);
167
+ continue;
168
+ }
169
+
170
+ processContent(rest, line);
171
+ return;
172
+ }
173
+ };
174
+
175
+ const processContent = (content, line) => {
176
+ for (const piece of splitTop(content, ';')) {
177
+ const part = piece.trim();
178
+ if (!part) continue;
179
+ const colon = scanFor(part, ':');
180
+ if (colon === -1) {
181
+ // `use behavior-name` — keyword declaration with a name argument
182
+ const useMatch = part.match(/^use\s+([A-Za-z][\w-]*)$/);
183
+ if (useMatch) {
184
+ stack[stack.length - 1].children.push({ kind: 'decl', prop: 'use', value: useMatch[1], line });
185
+ continue;
186
+ }
187
+ // valueless semantic property (e.g. `truncate`) — resolver decides if legal
188
+ if (/^[a-z][a-z0-9-]*$/i.test(part)) {
189
+ stack[stack.length - 1].children.push({ kind: 'decl', prop: part.toLowerCase(), value: '', line });
190
+ } else {
191
+ err(line, `expected ":" in "${part.slice(0, 40)}"`, 'declarations look like prop: value');
192
+ }
193
+ continue;
194
+ }
195
+ const prop = part.slice(0, colon).trim().toLowerCase();
196
+ const value = part.slice(colon + 1).trim();
197
+ if (!prop) { err(line, 'declaration is missing a property name'); continue; }
198
+ // v0.7.0 — numeric token keys. `tokens { space { 9: 96 } }` is legal:
199
+ // the built-in spacing scale stops at 8, and real pages need 9, 10, 12…
200
+ // Numbers are keys ONLY inside a tokens subtree — anywhere else a bare
201
+ // numeric property is a typo, and the error now says exactly that
202
+ // instead of the old "not a valid property name" shrug.
203
+ if (/^[0-9]+(\.[0-9]+)?$/.test(prop)) {
204
+ if (stack.some(n => n.kind === 'tokens')) {
205
+ stack[stack.length - 1].children.push({ kind: 'decl', prop, value, line });
206
+ continue;
207
+ }
208
+ err(line, `"${prop}" is not a valid property name`,
209
+ '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');
210
+ continue;
211
+ }
212
+ // v0.4.0: dotted custom properties are legal — `--color.accent: …` is
213
+ // scoped-token sugar (resolver maps it to the engine's token variable)
214
+ if (!/^(-{1,2}[a-z][a-z0-9.-]*|[a-z][a-z0-9-]*)$/i.test(prop)) {
215
+ err(line, `"${prop}" is not a valid property name`,
216
+ 'property names are lower-case words with dashes (my-prop), or dotted tokens (color.accent)');
217
+ continue;
218
+ }
219
+ stack[stack.length - 1].children.push({ kind: 'decl', prop, value, line });
220
+ }
221
+ };
222
+
223
+ // ---- line loop with continuation buffering ----
224
+ // A logical chunk grows across lines while:
225
+ // - parens/brackets are unbalanced (multi-line values)
226
+ // - it ends with a top-level comma (multi-line selector lists)
227
+ // - it ends with a top-level colon (value on the next line)
228
+ let buf = '', bufLine = 0;
229
+
230
+ const needsMore = (s) => {
231
+ let depth = 0, q = null;
232
+ for (let i = 0; i < s.length; i++) {
233
+ const c = s[i];
234
+ if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
235
+ if (c === '"' || c === "'") { q = c; continue; }
236
+ if (c === '(' || c === '[') depth++;
237
+ else if (c === ')' || c === ']') depth--;
238
+ }
239
+ if (depth !== 0) return true;
240
+ const t = s.trimEnd();
241
+ const last = t[t.length - 1];
242
+ return last === ',' || last === ':';
243
+ };
244
+
245
+ lines.forEach((raw, idx) => {
246
+ const line = idx + 1;
247
+ const trimmed = raw.trim();
248
+ if (!trimmed) return; // blank lines never split a pending chunk
249
+ const candidate = buf ? buf + ' ' + trimmed : raw;
250
+ if (buf && needsMore(candidate)) { buf = candidate; return; }
251
+ if (buf) { processChunk(candidate, bufLine || line); buf = ''; return; }
252
+ if (needsMore(candidate)) { buf = candidate; bufLine = line; return; }
253
+ processChunk(candidate, line);
254
+ });
255
+ if (buf.trim()) processChunk(buf, bufLine);
256
+
257
+ if (stack.length > 1) {
258
+ err(lines.length, `${stack.length - 1} block(s) were never closed with "}"`, 'every "{" needs a matching "}"');
259
+ }
260
+
261
+ return { ast: root, errors };
262
+ }
dssl-vscode/lib/engine/resolver.js ADDED
@@ -0,0 +1,1367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================================
2
+ * DSSL — resolver.js
3
+ * Walks the parsed tree and produces flat, engine-ready rules:
4
+ *
5
+ * rule = {
6
+ * id, sel, // full structural selector (may contain native pseudo-classes)
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
+ * }
14
+ *
15
+ * Also resolves tokens (→ custom properties on <html>), semantic properties,
16
+ * behaviors (`use`), motions (→ Web Animations keyframes) and pseudo-element
17
+ * rules (→ generated "shadow" CSS, the only thing inline styles can't reach).
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 } from './parser.js';
24
+
25
+ /* ------------------------------------------------------------------ *
26
+ * Vocabulary
27
+ * ------------------------------------------------------------------ */
28
+
29
+ // exported for the editor extension: completions ride the same list the
30
+ // compiler validates against, so the two can never drift apart
31
+ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,auto,currentcolor,transparent,' +
32
+ 'black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
33
+ 'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' +
34
+ 'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' +
35
+ 'ghostwhite,ivory,beige,linen,seashell,snow,honeydew,mintcream,azure,aliceblue,lavender,' +
36
+ 'canvas,canvastext,linktext,visitedtext,activetext,buttonface,buttontext,buttonborder,field,fieldtext,highlight,highlighttext,' +
37
+ 'solid,dashed,dotted,double,groove,ridge,inset,outset,hidden,visible,' +
38
+ 'block,inline,inline-block,flex,inline-flex,grid,inline-grid,flow-root,contents,table,table-row,table-cell,list-item,' +
39
+ 'relative,absolute,fixed,sticky,static,sticky,' +
40
+ 'start,end,center,left,right,between,around,evenly,baseline,stretch,normal,space-between,space-around,space-evenly,' +
41
+ 'wrap,nowrap,reverse,row,column,row-reverse,column-reverse,' +
42
+ 'bold,bolder,lighter,italic,oblique,small-caps,uppercase,lowercase,capitalize,underline,overline,line-through,blink,' +
43
+ 'ellipsis,clip,break-word,anywhere,pre,pre-wrap,pre-line,subpixel-antialiased,antialiased,' +
44
+ 'pointer,progress,default,move,text,wait,help,grab,grabbing,not-allowed,zoom-in,zoom-out,copy,alias,crosshair,' +
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
+ 'contain,cover,fill,fit-content,min-content,max-content,minmax,repeat,clamp,calc,var,url,env,attr,' +
49
+ 'auto-fill,auto-fit,subgrid,dense,stretch,span,' +
50
+ 'scaleX,scaleY,translateX,translateY,translate,rotate,skew,skewX,skewY,matrix,' +
51
+ 'ease,ease-in,ease-out,ease-in-out,linear,step-start,step-end,steps,cubic-bezier,spring,bounce,smooth,snap,' +
52
+ 'forwards,backwards,both,infinite,alternate,alternate-reverse,running,paused,reverse,' +
53
+ 'safe,unsafe,legacy,loose,strict,spell-out,letters,isolate,isolation,blend,multiply,screen,overlay,darken,lighten,color-dodge,color-burn,hard-light,soft-light,difference,exclusion,hue,saturation,color,luminosity,plus-lighter,' +
54
+ 'blur,brightness,contrast,grayscale,invert,opacity,sepia,saturate,drop-shadow,' +
55
+ 'pointer coarse fine hover interactive,first,last,odd,even,all,portrait,landscape,print,screen,and,not,only,' +
56
+ 'inline-size,size,scroll-state,normal,snapped,physical,logical,' +
57
+ 'in,oklab,oklch,srgb,srgb-linear,display-p3,a98-rgb,prophoto-rgb,rec2020,xyz,xyz-d50,xyz-d65,shorter,longer,deg,grad,rad,turn,' +
58
+ 'to,top,bottom,left,right,above,below,closest-side,farthest-side,ellipse,circle,at,padding-box,border-box,' +
59
+ 'isolate,plaintext,sideways,upright,mixed,ltr,rtl,transform,auto,' +
60
+ '-webkit-box,-webkit-inline-box,' +
61
+ 'thin,thick,medium,separate,collapse,vertical,horizontal,balance,pretty,orphans,' +
62
+ // v0.5.0 — hyphens: manual used to be rejected ("unknown value") even though
63
+ // the property existed; plus the rendering keywords people actually type.
64
+ 'manual,pixelated,crisp-edges,optimizeSpeed,optimizeLegibility,geometricPrecision,' +
65
+ 'flex-start,flex-end,self-start,self-end,snap-block,snap-inline').split(','));
66
+
67
+ const UNITLESS_PROPS = new Set([
68
+ 'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
69
+ 'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
70
+ 'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
71
+ 'shape-image-threshold', 'animation-delay', 'transition-delay',
72
+ // grid line placement takes integers, not lengths: grid-column: span 2
73
+ 'grid-row', 'grid-column', 'grid-area',
74
+ ]);
75
+
76
+ // A practical whitelist of standard CSS properties (loud errors beat silent ones)
77
+ const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
78
+ 'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
79
+ '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,' +
80
+ 'border-bottom,border-bottom-color,border-bottom-left-radius,border-bottom-right-radius,border-bottom-style,border-bottom-width,' +
81
+ 'border-collapse,border-color,border-end-end-radius,border-end-start-radius,border-image,border-image-outset,border-image-repeat,border-image-slice,border-image-source,border-image-width,' +
82
+ 'border-inline,border-inline-color,border-inline-end,border-inline-end-color,border-inline-end-style,border-inline-end-width,border-inline-start,border-inline-start-color,border-inline-start-style,border-inline-start-width,border-inline-style,border-inline-width,' +
83
+ 'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
84
+ 'border-spacing,border-start-end-radius,border-start-start-radius,border-style,border-top,border-top-color,border-top-left-radius,border-top-right-radius,border-top-style,border-top-width,border-width,' +
85
+ 'bottom,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' +
86
+ 'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' +
87
+ 'animation,animation-composition,animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-range,animation-timeline,animation-timing-function,' +
88
+ 'view-transition-name,interpolate-size,text-box,text-box-edge,text-box-trim,dynamic-range-limit,field-sizing,anchor-name,position-anchor,position-area,scroll-timeline,scroll-timeline-axis,scroll-timeline-name,timeline-scope,view-timeline,view-timeline-axis,view-timeline-name,' +
89
+ '-webkit-background-clip,-webkit-text-fill-color,-webkit-box-orient,-webkit-line-clamp,-webkit-tap-highlight-color,-webkit-font-smoothing,-moz-osx-font-smoothing,-webkit-box-shadow,-webkit-text-stroke').split(','));
90
+
91
+ /* ------------------------------------------------------------------ *
92
+ * Built-in token defaults (overridable via `tokens { }`)
93
+ * ------------------------------------------------------------------ */
94
+
95
+ export const DEFAULT_TOKENS = {
96
+ 'color.ink': '#16161d',
97
+ 'color.paper': '#faf7f2',
98
+ 'color.card': '#ffffff',
99
+ 'color.muted': '#6f6a7a',
100
+ 'color.line': '#e5ded2',
101
+ 'color.accent': 'oklch(62% 0.19 35)',
102
+ 'color.accent-ink': '#fff6ee',
103
+ 'color.ok': '#2e7d4f',
104
+ 'color.err': '#b3372e',
105
+ 'space.1': '4', 'space.2': '8', 'space.3': '12', 'space.4': '16',
106
+ 'space.5': '24', 'space.6': '32', 'space.7': '48', 'space.8': '64',
107
+ 'radius.sm': '8', 'radius.md': '14', 'radius.lg': '22', 'radius.full': '999',
108
+ 'shadow.sm': '0 1 2 color.ink/8, 0 1 3 color.ink/6',
109
+ 'shadow.md': '0 2 4 color.ink/10, 0 4 12 color.ink/8',
110
+ 'shadow.lg': '0 4 8 color.ink/12, 0 12 32 color.ink/12',
111
+ 'shadow.xl': '0 8 16 color.ink/14, 0 24 64 color.ink/16',
112
+ 'font.sans': 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
113
+ 'font.mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
114
+ };
115
+
116
+ const EASINGS = {
117
+ snap: 'cubic-bezier(0.2, 0.9, 0.3, 1)',
118
+ smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
119
+ bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
120
+ linear: 'linear',
121
+ ease: 'ease',
122
+ 'ease-in': 'ease-in',
123
+ 'ease-out': 'ease-out',
124
+ 'ease-in-out': 'ease-in-out',
125
+ };
126
+
127
+ const PLACE_WORDS = {
128
+ center: ['center', 'center'], start: ['start', 'flex-start'], end: ['end', 'flex-end'],
129
+ between: ['center', 'space-between'], around: ['center', 'space-around'],
130
+ evenly: ['center', 'space-evenly'], stretch: ['stretch', 'flex-start'],
131
+ baseline: ['baseline', 'flex-start'],
132
+ };
133
+
134
+ /* ------------------------------------------------------------------ *
135
+ * Value resolution
136
+ * ------------------------------------------------------------------ */
137
+
138
+ const IDENT_RE = /^-?(?:[A-Za-z_]|--[A-Za-z_])[A-Za-z0-9_.\\-]*/;
139
+
140
+ // functions whose numeric arguments are NOT lengths — numbers stay bare
141
+ const UNITLESS_FN = new Set(('oklch,oklab,lab,lch,hsl,hsla,rgb,rgba,hwb,color,color-mix,light-dark,' +
142
+ 'brightness,saturate,contrast,grayscale,sepia,invert,opacity,' +
143
+ 'scale,scalex,scaley,scalez,scale3d,steps,cubic-bezier,linear,radial,conic').split(','));
144
+ // functions whose bare numbers ARE lengths (px)
145
+ const LENGTH_FN = new Set('calc,min,max,clamp,round,mod,rem,abs,sign,hypot,pow,sqrt,log,exp,blur,drop-shadow'.split(','));
146
+
147
+ /**
148
+ * Resolve a raw value string into its final form:
149
+ * - bare numbers get `px` (unless ctx.unitless)
150
+ * - token references become var(--dssl-…), `token/NN` becomes color-mix
151
+ * - unknown identifiers are errors (with did-you-mean)
152
+ */
153
+ function resolveValue(raw, ctx) {
154
+ let out = '';
155
+ let i = 0;
156
+ const n = raw.length;
157
+
158
+ const err = (msg, hint) => ctx.errors.push({ file: ctx.file, line: ctx.line, message: msg, hint });
159
+
160
+ while (i < n) {
161
+ const c = raw[i];
162
+
163
+ if (/\s/.test(c)) { out += c; i++; continue; }
164
+
165
+ if (c === '"' || c === "'") {
166
+ let j = i + 1;
167
+ while (j < n && raw[j] !== c) j++;
168
+ out += raw.slice(i, j + 1);
169
+ i = j + 1;
170
+ continue;
171
+ }
172
+
173
+ if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(raw[i + 1] || '')) || ((c === '-' || c === '+') && /[0-9.]/.test(raw[i + 1] || ''))) {
174
+ let j = i + 1;
175
+ while (j < n && /[0-9.]/.test(raw[j])) j++;
176
+ const num = raw.slice(i, j);
177
+ // optional unit
178
+ let unit = '';
179
+ if (j < n && /[a-z%]/i.test(raw[j])) {
180
+ let k = j;
181
+ while (k < n && /[a-z%]/i.test(raw[k])) k++;
182
+ unit = raw.slice(j, k).toLowerCase();
183
+ j = k;
184
+ }
185
+ out += num + (unit ? unit : (ctx.unitless ? '' : 'px'));
186
+ i = j;
187
+ continue;
188
+ }
189
+
190
+ const idm = raw.slice(i).match(IDENT_RE);
191
+ if (idm) {
192
+ let ident = idm[0];
193
+ i += ident.length;
194
+
195
+ if (ident.startsWith('--')) { out += ident; continue; } // custom property
196
+
197
+ // function call — resolve the inside
198
+ if (raw[i] === '(') {
199
+ let depth = 1, j = i + 1;
200
+ while (j < n && depth > 0) {
201
+ if (raw[j] === '(') depth++;
202
+ if (raw[j] === ')') depth--;
203
+ if (depth > 0 && (raw[j] === '"' || raw[j] === "'")) {
204
+ const q = raw[j]; j++;
205
+ while (j < n && raw[j] !== q) j++;
206
+ }
207
+ j++;
208
+ }
209
+ const inner = raw.slice(i + 1, j - 1);
210
+ const fname = ident.toLowerCase();
211
+ // v0.3.2 fix: repeat(COUNT, tracks) — the leading count is an integer,
212
+ // never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
213
+ // which the browser rejected and the whole grid silently collapsed.
214
+ if (fname === 'repeat') {
215
+ const rm = inner.match(/^\s*(\d+(?:\.\d+)?)\s*,([\s\S]*)$/);
216
+ if (rm) {
217
+ out += ident + '(' + rm[1] + ',' + resolveValue(rm[2], { ...ctx, unitless: ctx.unitless }) + ')';
218
+ i = j;
219
+ continue;
220
+ }
221
+ // auto-fill / auto-fit / nested weirdness — fall through to generic
222
+ }
223
+ // color-ish functions keep bare numbers bare; calc-family gets px
224
+ const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
225
+ const resolvedInner = resolveValue(inner, { ...ctx, unitless: innerUnitless });
226
+ out += ident + '(' + resolvedInner + ')';
227
+ i = j;
228
+ continue;
229
+ }
230
+
231
+ // token reference?
232
+ const tokenKey = findToken(ctx.tokens, ident);
233
+ if (tokenKey) {
234
+ const varRef = 'var(--dssl-' + tokenKey.replace(/\./g, '-') + ')';
235
+ // alpha modifier: ink/20
236
+ let rest = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
237
+ if (rest) {
238
+ out += `color-mix(in oklab, ${varRef} ${rest[1]}%, transparent)`;
239
+ i += rest[0].length;
240
+ continue;
241
+ }
242
+ out += varRef;
243
+ continue;
244
+ }
245
+
246
+ // known keyword?
247
+ if (CSS_KEYWORDS.has(ident) || CSS_KEYWORDS.has(ident.toLowerCase())) {
248
+ out += ident;
249
+ continue;
250
+ }
251
+
252
+ // v0.5.0 — font names are proper nouns: JetBrains Mono, IBM Plex Serif.
253
+ // In font-family values and font.* tokens, bare multi-word names pass
254
+ // through instead of demanding quotes. Everywhere else stays loud.
255
+ if (ctx.bareIdents && /^[A-Za-z][A-Za-z0-9_-]*$/.test(ident)) {
256
+ out += ident;
257
+ continue;
258
+ }
259
+
260
+ err(`unknown value "${ident}" for property "${ctx.prop}"`,
261
+ didYouMean(ident, [...ctx.tokenNames, ...CSS_KEYWORDS]));
262
+ out += ident; // keep going so the rest still resolves
263
+ continue;
264
+ }
265
+
266
+ // punctuation: copy (handles / , + * # etc.)
267
+ if (c === '#') { // hex color
268
+ let j = i + 1;
269
+ while (j < n && /[0-9a-fA-F]/.test(raw[j])) j++;
270
+ out += raw.slice(i, j);
271
+ i = j;
272
+ continue;
273
+ }
274
+ out += c;
275
+ i++;
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /** paren-aware split on the `->` arrow — gradient stops use it as a separator */
281
+ function splitArrow(s) {
282
+ const out = [];
283
+ let cur = '', depth = 0, q = null;
284
+ for (let i = 0; i < s.length; i++) {
285
+ const c = s[i];
286
+ if (q) { cur += c; if (c === q && s[i - 1] !== '\\') q = null; continue; }
287
+ if (c === '"' || c === "'") { q = c; cur += c; continue; }
288
+ if (c === '(' || c === '[') depth++;
289
+ else if (c === ')' || c === ']') depth = Math.max(0, depth - 1);
290
+ if (depth === 0 && c === '-' && s[i + 1] === '>') { out.push(cur); cur = ''; i++; continue; }
291
+ cur += c;
292
+ }
293
+ out.push(cur);
294
+ return out;
295
+ }
296
+
297
+ /** split on a top-level separator (parens-aware) — commas inside fn() survive */
298
+ function topSplit(str, sep) {
299
+ const out = [];
300
+ let cur = '', depth = 0;
301
+ for (const ch of str) {
302
+ if (ch === '(') depth++;
303
+ else if (ch === ')') depth = Math.max(0, depth - 1);
304
+ if (ch === sep && depth === 0) { out.push(cur); cur = ''; continue; }
305
+ cur += ch;
306
+ }
307
+ out.push(cur);
308
+ return out;
309
+ }
310
+
311
+ function findToken(tokens, ident) {
312
+ if (tokens.has(ident)) return ident;
313
+ // try progressive dot paths (a.b.c defined, user typed a.b.c — exact only, plus flat fallback)
314
+ if (ident.includes('.')) {
315
+ const parts = ident.split('.');
316
+ for (let k = parts.length - 1; k > 0; k--) {
317
+ const key = parts.slice(0, k).join('.');
318
+ if (tokens.has(key)) return key; // hmm — partial match would change meaning; require exact
319
+ }
320
+ }
321
+ return null;
322
+ }
323
+
324
+ /* ------------------------------------------------------------------ *
325
+ * Semantic properties
326
+ * ------------------------------------------------------------------ */
327
+
328
+ const len = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: false });
329
+ const num = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: true });
330
+
331
+ // v0.6.0 — the deform family speaks degrees, not lengths: bare numbers are
332
+ // angles and `20deg` is accepted for people with CSS muscle memory.
333
+ const degNum = (raw, what, ctx) => {
334
+ const t = String(raw).trim();
335
+ const m = t.match(/^(-?\d*\.?\d+)(deg)?$/i);
336
+ if (!m) {
337
+ ctx.errors.push({
338
+ file: ctx.file, line: ctx.line,
339
+ message: `${what} takes a bare number of degrees`,
340
+ hint: `try ${what}: 20 or ${what}: -12 — bare numbers are degrees here`,
341
+ });
342
+ return null;
343
+ }
344
+ return parseFloat(m[1]);
345
+ };
346
+
347
+ /** v0.6.0 — shared grammar for gradient / gradient-text: kind · prefix · stops.
348
+ * Stops may be arrow-joined (`a -> b -> c`) or comma-joined (`linear 135, a, b`);
349
+ * the prefix is whatever the grammar calls direction/shape — everything else is a stop. */
350
+ // Stops are colors by construction, so a bare word that is not a color name is
351
+ // a typo the browser would only mutter about at apply time. Say it NOW instead.
352
+ const COLOR_WORDS = new Set(('black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
353
+ 'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' +
354
+ 'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' +
355
+ 'ghostwhite,ivory,beige,linen,seashell,snow,honeydew,mintcream,azure,aliceblue,lavender,' +
356
+ 'canvas,canvastext,linktext,visitedtext,activetext,buttonface,buttontext,buttonborder,field,fieldtext,highlight,highlighttext,' +
357
+ 'currentcolor,transparent').split(','));
358
+
359
+ function buildGradient(v, ctx) {
360
+ const t = String(v).trim();
361
+ const m = t.match(/^(linear|radial|conic)\b([\s\S]*)$/i);
362
+ if (!m) {
363
+ ctx.errors.push({
364
+ file: ctx.file, line: ctx.line,
365
+ message: `gradient needs a kind: "linear", "radial" or "conic"`,
366
+ hint: 'try gradient: linear 135, color.accent -> color.ok',
367
+ });
368
+ return null;
369
+ }
370
+ const kind = m[1].toLowerCase();
371
+ const rest = m[2].trim();
372
+ const DIR_RE = /^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i;
373
+ const isPrefixShaped = (seg) => {
374
+ const s = seg.toLowerCase();
375
+ if (kind === 'linear') return /^-?\d+(\.\d+)?(deg)?$/.test(s) || DIR_RE.test(s);
376
+ if (kind === 'conic') return /^from\s+-?\d+(\.\d+)?(deg)?(\s+at\s+.+)?$/.test(s) || /^at\s+.+$/.test(s);
377
+ // radial: every atom is shape/size/position vocabulary (possibly around `at`)
378
+ return s.split(/\s+/).every(a => a === 'at' ||
379
+ /^(circle|ellipse|center|top|bottom|left|right|closest-side|farthest-side|closest-corner|farthest-corner|-?\d+(\.\d+)?(%|px|em|rem)?)$/.test(a));
380
+ };
381
+ let head = '';
382
+ let stopStrs = [];
383
+ const arrowParts = splitArrow(rest).map(s => s.trim()).filter(Boolean);
384
+ if (arrowParts.length >= 2) {
385
+ head = arrowParts[0];
386
+ stopStrs = arrowParts.slice(1);
387
+ // mixed styles like `linear 135, a -> b`: the comma belongs to the stop list
388
+ const hc = topSplit(head, ',').map(s => s.trim()).filter(Boolean);
389
+ if (hc.length > 1) { head = hc[0]; stopStrs = [...hc.slice(1), ...stopStrs]; }
390
+ } else {
391
+ const segs = topSplit(rest, ',').map(s => s.trim()).filter(Boolean);
392
+ while (segs.length > 1 && isPrefixShaped(segs[0])) head = head ? head + ' ' + segs.shift() : segs.shift();
393
+ stopStrs = segs;
394
+ }
395
+ if (stopStrs.length < 2) {
396
+ ctx.errors.push({
397
+ file: ctx.file, line: ctx.line,
398
+ message: `gradient needs at least two stops`,
399
+ hint: 'try gradient: linear 135, #ff0055 -> #00d4ff — commas work too: linear 135, #ff0055, #00d4ff',
400
+ });
401
+ return null;
402
+ }
403
+ let lead = '';
404
+ const POS_ATOM = /^(center|top|bottom|left|right|closest-side|farthest-side|closest-corner|farthest-corner|-?\d+(?:\.\d+)?(%|px|em|rem)?)$/i;
405
+ if (kind === 'linear') {
406
+ if (!head) lead = '180deg';
407
+ else if (/^-?\d+(\.\d+)?$/.test(head)) lead = head + 'deg';
408
+ else if (/^-?\d+(\.\d+)?deg$/i.test(head)) lead = head.toLowerCase();
409
+ else if (DIR_RE.test(head)) lead = head.toLowerCase();
410
+ else {
411
+ ctx.errors.push({
412
+ file: ctx.file, line: ctx.line,
413
+ message: `"${head}" is not a linear gradient direction`,
414
+ hint: 'use an angle (linear 135, …) or words (linear to bottom right, …)',
415
+ });
416
+ return null;
417
+ }
418
+ } else if (kind === 'radial') {
419
+ if (head) {
420
+ for (const a of head.split(/\s+/)) {
421
+ if (a.toLowerCase() === 'at' || a.toLowerCase() === 'circle' || a.toLowerCase() === 'ellipse' || POS_ATOM.test(a)) continue;
422
+ ctx.errors.push({
423
+ file: ctx.file, line: ctx.line,
424
+ message: `"${a}" is not part of a radial gradient shape`,
425
+ hint: 'try gradient: radial circle at center, #fff -> #000',
426
+ });
427
+ return null;
428
+ }
429
+ lead = head;
430
+ }
431
+ } else { // conic
432
+ if (head) {
433
+ const fm = head.match(/^from\s+(-?\d+(?:\.\d+)?)(deg)?(?:\s+(at\s+[\s\S]+))?$/i);
434
+ if (fm) lead = 'from ' + fm[1] + 'deg' + (fm[3] ? ' ' + fm[3].trim() : '');
435
+ else if (/^at\s+[\s\S]+$/i.test(head)) lead = head;
436
+ else {
437
+ ctx.errors.push({
438
+ file: ctx.file, line: ctx.line,
439
+ message: `"${head}" is not a conic gradient prefix`,
440
+ hint: 'use from <angle> and/or at <position> — try conic from 45 at center, …',
441
+ });
442
+ return null;
443
+ }
444
+ }
445
+ }
446
+ const stops = [];
447
+ for (const s of stopStrs) {
448
+ let colorPart = s, pos = '';
449
+ const pm = s.match(/\s+(\d+(?:\.\d+)?)%$/); // trailing per-stop position
450
+ if (pm) { pos = pm[1] + '%'; colorPart = s.slice(0, pm.index).trim(); }
451
+ if (!colorPart) {
452
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `a gradient stop is missing its color (got "${s}")` });
453
+ return null;
454
+ }
455
+ const val = resolveValue(colorPart, ctx);
456
+ if (/^[a-z-]+$/i.test(val) && !COLOR_WORDS.has(val.toLowerCase())) {
457
+ ctx.errors.push({
458
+ file: ctx.file, line: ctx.line,
459
+ message: `gradient stop "${s}" is not a color`,
460
+ hint: (didYouMean(val, [...COLOR_WORDS]) || 'colors, tokens (color.ink) and alpha mixes (ink/40) all work'),
461
+ });
462
+ return null;
463
+ }
464
+ stops.push(pos ? `${val} ${pos}` : val);
465
+ }
466
+ const fn = kind === 'linear' ? 'linear-gradient' : kind === 'radial' ? 'radial-gradient' : 'conic-gradient';
467
+ return `${fn}(${[lead, ...stops].filter(Boolean).join(', ')})`;
468
+ }
469
+
470
+ function parseFlexWords(value, ctx, dir) {
471
+ const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
472
+ const atoms = value.split(/\s+/).filter(Boolean);
473
+ for (let k = 0; k < atoms.length; k++) {
474
+ const a = atoms[k];
475
+ if (a === 'gap') {
476
+ const v = atoms[++k];
477
+ if (v === undefined) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'gap needs a value' }); break; }
478
+ out.push({ prop: 'gap', value: len(v, ctx) });
479
+ continue;
480
+ }
481
+ if (a === 'wrap') { out.push({ prop: 'flex-wrap', value: 'wrap' }); continue; }
482
+ if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; }
483
+ if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; }
484
+ if (a === 'inline') { out[0].value = 'inline-flex'; continue; }
485
+ if (PLACE_WORDS[a]) {
486
+ out.push({ prop: 'align-items', value: PLACE_WORDS[a][0] });
487
+ out.push({ prop: 'justify-content', value: PLACE_WORDS[a][1] });
488
+ continue;
489
+ }
490
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown word "${a}" in ${dir}`, hint: didYouMean(a, ['gap', 'center', 'start', 'end', 'between', 'around', 'evenly', 'stretch', 'baseline', 'wrap', 'nowrap', 'reverse', 'inline']) });
491
+ }
492
+ return out;
493
+ }
494
+
495
+ // v0.6.0 — deform members all land in the transform channel, so asking for
496
+ // `transition: 200 snap bend` should just work and mean transform.
497
+ const DEFORM_TO_TRANSFORM = {
498
+ bend: 'transform', 'bend-y': 'transform', 'bend-up': 'transform',
499
+ fold: 'transform', stretch: 'transform',
500
+ };
501
+
502
+ const SEMANTIC = {
503
+ size(v, ctx) {
504
+ const parts = v.split(/\s+/).filter(Boolean);
505
+ const w = len(parts[0] || 'auto', ctx);
506
+ const h = parts[1] !== undefined ? len(parts[1], ctx) : w;
507
+ return [{ prop: 'width', value: w }, { prop: 'height', value: h }];
508
+ },
509
+ row: (v, ctx) => parseFlexWords(v, ctx, 'row'),
510
+ column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
511
+ grid(v, ctx) {
512
+ const atoms = v.split(/\s+/).filter(Boolean);
513
+ let cols;
514
+ if (atoms.length === 1 && /^[0-9]+$/.test(atoms[0])) {
515
+ cols = `repeat(${atoms[0]}, 1fr)`;
516
+ } else if ((atoms[0] === 'auto-fill' || atoms[0] === 'auto-fit') && atoms[1]) {
517
+ cols = `repeat(${atoms[0]}, minmax(${len(atoms[1], ctx)}, 1fr))`;
518
+ } else {
519
+ cols = atoms.map(a => len(a, ctx)).join(' ');
520
+ }
521
+ return [{ prop: 'display', value: 'grid' }, { prop: 'grid-template-columns', value: cols }];
522
+ },
523
+ stack(v, ctx) {
524
+ const word = (v || 'center').trim();
525
+ const p = PLACE_WORDS[word];
526
+ if (!p) {
527
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown place "${word}" in stack`, hint: didYouMean(word, Object.keys(PLACE_WORDS)) });
528
+ return [{ prop: '@stack', value: '1' }];
529
+ }
530
+ return [{ prop: 'display', value: 'grid' }, { prop: 'place-items', value: `${p[0]} ${p[1]}` }, { prop: '@stack', value: '1' }];
531
+ },
532
+ place(v, ctx) {
533
+ const p = PLACE_WORDS[v.trim()];
534
+ if (!p) {
535
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown place "${v}"`, hint: didYouMean(v, Object.keys(PLACE_WORDS)) });
536
+ return [];
537
+ }
538
+ return [{ prop: 'place-items', value: `${p[0]} ${p[1]}` }, { prop: 'place-content', value: `${p[0]} ${p[1]}` }];
539
+ },
540
+ // v0.3.4 — the one-word centering primitive, because "how do I center this
541
+ // thing" is the first question everyone asks. Emits the alignment pair only;
542
+ // the ENGINE guarantees a formatting context for alignment declarations
543
+ // (inline elements → inline-grid, blocks → grid), so `center:` can never
544
+ // silently no-op the way raw place-items did on spans and buttons.
545
+ // center: both axes · center: x horizontal only · center: y vertical only
546
+ center(v, ctx) {
547
+ const mode = (v || '').trim().toLowerCase() || 'both';
548
+ if (mode === 'x') {
549
+ return [{ prop: 'justify-items', value: 'center' }, { prop: 'justify-content', value: 'center' }];
550
+ }
551
+ if (mode === 'y') {
552
+ return [{ prop: 'align-items', value: 'center' }, { prop: 'align-content', value: 'center' }];
553
+ }
554
+ if (mode !== 'both') {
555
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown center axis "${v}"`, hint: didYouMean(mode, ['both', 'x', 'y']) });
556
+ return [];
557
+ }
558
+ return [{ prop: 'place-items', value: 'center center' }, { prop: 'place-content', value: 'center center' }];
559
+ },
560
+ cover(v, ctx) {
561
+ const pos = (v || 'absolute').trim();
562
+ return [{ prop: 'position', value: pos }, { prop: 'inset', value: '0' }];
563
+ },
564
+ sticky(v, ctx) {
565
+ return [{ prop: 'position', value: 'sticky' }, { prop: 'top', value: len(v || '0', ctx) }];
566
+ },
567
+ font(v, ctx) {
568
+ const out = [];
569
+ const slash = v.split('/');
570
+ const head = slash[0].trim().split(/\s+/).filter(Boolean);
571
+ out.push({ prop: 'font-size', value: len(head[0] || '16', ctx) });
572
+ if (slash[1]) {
573
+ const tail = slash[1].trim().split(/\s+/).filter(Boolean);
574
+ out.push({ prop: 'line-height', value: num(tail[0] || '1.5', ctx) });
575
+ if (tail.length > 1) out.push({ prop: 'color', value: resolveValue(tail.slice(1).join(' '), ctx) });
576
+ } else if (head.length > 1) {
577
+ out.push({ prop: 'color', value: resolveValue(head.slice(1).join(' '), ctx) });
578
+ }
579
+ return out;
580
+ },
581
+ fluid(v, ctx) {
582
+ const parts = v.split(',').map(s => s.trim());
583
+ const px = (s) => {
584
+ const m = String(s).match(/^([0-9.]+)\s*(px|rem|em|%)?$/);
585
+ if (!m) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" is not a size` }); return '0px'; }
586
+ return m[2] ? `${m[1]}${m[2]}` : `${m[1]}px`;
587
+ };
588
+ const min = px(parts[0]); const max = px(parts[1]);
589
+ const vmin = px(parts[2] || '360'); const vmax = px(parts[3] || '1200');
590
+ const mv = parseFloat(min), xv = parseFloat(max);
591
+ const slope = ((xv - mv) / (parseFloat(vmax) - parseFloat(vmin))) * 100;
592
+ return [{ prop: 'font-size', value: `clamp(${min}, calc(${min} + ${slope.toFixed(3)}vw), ${max})` }];
593
+ },
594
+ shadow(v, ctx) {
595
+ const t = v.trim();
596
+ if (['sm', 'md', 'lg', 'xl', 'none'].includes(t)) {
597
+ return [{ prop: 'box-shadow', value: t === 'none' ? 'none' : `var(--dssl-shadow-${t})` }];
598
+ }
599
+ return [{ prop: 'box-shadow', value: resolveValue(v, ctx) }];
600
+ },
601
+ radius(v, ctx) {
602
+ const atoms = v.trim().split(/\s+/).filter(Boolean);
603
+ const out = atoms.map(a => {
604
+ if (['sm', 'md', 'lg', 'full', 'xl'].includes(a)) return `var(--dssl-radius-${a})`;
605
+ return resolveValue(a, ctx);
606
+ });
607
+ return [{ prop: 'border-radius', value: out.join(' ') }];
608
+ },
609
+ circle(v, ctx) {
610
+ const s = len(v, ctx);
611
+ return [
612
+ { prop: 'width', value: s },
613
+ { prop: 'height', value: s },
614
+ { prop: 'border-radius', value: '50%' },
615
+ ];
616
+ },
617
+ align(v, ctx) {
618
+ const map = { center: 'center', start: 'start', end: 'end', stretch: 'stretch', baseline: 'baseline' };
619
+ const val = map[v.trim()];
620
+ if (!val) {
621
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown align "${v}"`, hint: didYouMean(v, Object.keys(map)) });
622
+ return [];
623
+ }
624
+ return [{ prop: 'align-items', value: val }];
625
+ },
626
+ z(v, ctx) { return [{ prop: 'z-index', value: num(v, ctx) }]; },
627
+ grow(v, ctx) { return [{ prop: 'flex-grow', value: num(v || '1', ctx) }]; },
628
+ shrink(v, ctx) { return [{ prop: 'flex-shrink', value: num(v || '1', ctx) }]; },
629
+ basis(v, ctx) { return [{ prop: 'flex-basis', value: len(v, ctx) }]; },
630
+ truncate() {
631
+ return [
632
+ { prop: 'overflow', value: 'hidden' },
633
+ { prop: 'text-overflow', value: 'ellipsis' },
634
+ { prop: 'white-space', value: 'nowrap' },
635
+ ];
636
+ },
637
+ // v0.4.0 — the accessibility classic: visually hidden, still announced by
638
+ // screen readers. One word instead of nine lines of clip folklore.
639
+ 'visually-hidden'() {
640
+ return [
641
+ { prop: 'position', value: 'absolute' },
642
+ { prop: 'width', value: '1px' },
643
+ { prop: 'height', value: '1px' },
644
+ { prop: 'padding', value: '0' },
645
+ { prop: 'margin', value: '-1px' },
646
+ { prop: 'overflow', value: 'hidden' },
647
+ { prop: 'clip-path', value: 'inset(50%)' },
648
+ { prop: 'white-space', value: 'nowrap' },
649
+ { prop: 'border', value: '0' },
650
+ ];
651
+ },
652
+ // v0.4.0 — transition grew per-property specs. Each comma part is
653
+ // duration [easing] [property] (property omitted → all)
654
+ // The one-value form keeps working exactly as before:
655
+ // transition: 200 snap
656
+ // transition: 150 snap background-color, 300 transform
657
+ transition(v, ctx) {
658
+ const t = v.trim();
659
+ if (t === 'none') return [{ prop: 'transition', value: 'none' }];
660
+ const parts = topSplit(t, ',').map(s => s.trim()).filter(Boolean);
661
+ const out = [];
662
+ for (const part of parts) {
663
+ const atoms = part.split(/\s+/);
664
+ const dur = atoms[0];
665
+ if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
666
+ 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' });
667
+ continue;
668
+ }
669
+ const ms = /^\d+$/.test(dur) ? dur + 'ms' : dur;
670
+ let k = 1;
671
+ let easing = 'ease';
672
+ if (atoms[k] && EASINGS[atoms[k]]) { easing = EASINGS[atoms[k]]; k++; }
673
+ else if (atoms[k] && /^(cubic-bezier|steps)\(/.test(atoms[k])) {
674
+ easing = resolveValue(atoms[k], { ...ctx, unitless: true });
675
+ k++;
676
+ }
677
+ let prop = 'all';
678
+ if (atoms.length > k) {
679
+ const raw = atoms.slice(k).join(' ').toLowerCase();
680
+ const p = DEFORM_TO_TRANSFORM[raw] || raw;
681
+ if (p !== 'all' && !KNOWN_PROPS.has(p)) {
682
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown transition property "${raw}"`, hint: didYouMean(raw, [...KNOWN_PROPS, ...Object.keys(DEFORM_TO_TRANSFORM)]) });
683
+ continue;
684
+ }
685
+ prop = p;
686
+ }
687
+ out.push(`${prop} ${ms} ${easing}`);
688
+ }
689
+ if (!out.length) return [];
690
+ return [{ prop: 'transition', value: out.join(', ') }];
691
+ },
692
+ animate(v, ctx) {
693
+ const t = v.trim();
694
+ if (t === 'none') return [{ prop: '@animate', value: null }];
695
+ const atoms = t.split(/\s+/);
696
+ const spec = { motion: atoms[0], duration: 400, easing: EASINGS.smooth, fill: 'both', delay: 0 };
697
+ for (let k = 1; k < atoms.length; k++) {
698
+ const a = atoms[k];
699
+ if (/^\d+(ms|s)?$/.test(a)) {
700
+ const ms = a.endsWith('s') && !a.endsWith('ms') ? parseFloat(a) * 1000 : parseFloat(a);
701
+ if (k === 1) spec.duration = ms; else spec.delay = ms;
702
+ continue;
703
+ }
704
+ if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
705
+ if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
706
+ // v0.5.0 — looping: `infinite` runs forever, `alternate` ping-pongs.
707
+ // animate: pulse 1600 infinite alternate — was impossible before.
708
+ if (a === 'infinite') { spec.iterations = Infinity; continue; }
709
+ if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
710
+ 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'])) });
711
+ }
712
+ return [{ prop: '@animate', value: spec }];
713
+ },
714
+ // v0.4.0 — choreography: a parent with `stagger: 60` offsets each element
715
+ // child's animation delay by its DOM position. Pairs with `seen` + `animate`
716
+ // on the children: grids and lists cascade in as they scroll into view.
717
+ stagger(v, ctx) {
718
+ const t = (v || '').trim();
719
+ if (t === 'none') return [{ prop: '@stagger', value: null }];
720
+ if (!/^\d+(?:\.\d+)?(?:ms|s)?$/.test(t)) {
721
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `stagger needs a duration like "60" or "120ms", or "none"`, hint: 'stagger offsets each child\'s animation delay by its position' });
722
+ return [];
723
+ }
724
+ const ms = t.endsWith('s') && !t.endsWith('ms') ? parseFloat(t) * 1000 : parseFloat(t);
725
+ return [{ prop: '@stagger', value: ms }];
726
+ },
727
+ // v0.5.0 — overlap policy. CSS can stack transparent things all day and say
728
+ // nothing; DSSL refuses. `deny` makes the element always yield out of any
729
+ // overlap; `auto` yields only while the element sits below 100% opacity.
730
+ // Both inherit down the tree, so `body { overlap: auto }` is the site-wide
731
+ // "nothing transparent may overlap anything" switch. Intentional overlays
732
+ // (scrims, veils, translucent navs) opt out with `overlap: allow`.
733
+ overlap(v, ctx) {
734
+ const t = (v || '').trim().toLowerCase();
735
+ if (t === 'allow' || t === 'none') return [{ prop: '@overlap', value: 'allow' }];
736
+ if (t === 'deny') return [{ prop: '@overlap', value: 'deny' }];
737
+ if (t === 'auto') return [{ prop: '@overlap', value: 'auto' }];
738
+ ctx.errors.push({
739
+ file: ctx.file, line: ctx.line,
740
+ message: `unknown overlap policy "${(v || '').trim()}"`,
741
+ hint: 'overlap is allow (default) · deny (always yield out of overlaps) · auto (yield only while below 100% opacity)',
742
+ });
743
+ return [];
744
+ },
745
+
746
+ /* ---------------------------------------------------------------- *
747
+ * v0.6.0 — the deform family: bend, bend-y, bend-up, fold, stretch,
748
+ * bend-around. All are engine-native (@-props) that compose into the
749
+ * transform channel, so `transition: 200 snap transform` tweens them
750
+ * and `:active { bend: 14 }` makes a button visibly flex under the
751
+ * pointer. bend-around is the odd one out: it curves a CONTAINER's
752
+ * children along an arc instead of deforming the element itself.
753
+ * ---------------------------------------------------------------- */
754
+ bend(v, ctx) { const n = degNum(v, 'bend', ctx); return n === null ? [] : [{ prop: '@bend', value: n }]; },
755
+ 'bend-y'(v, ctx) { const n = degNum(v, 'bend-y', ctx); return n === null ? [] : [{ prop: '@bend-y', value: n }]; },
756
+ 'bend-up'(v, ctx) { const n = degNum(v, 'bend-up', ctx); return n === null ? [] : [{ prop: '@bend-up', value: n }]; },
757
+ fold(v, ctx) { const n = degNum(v, 'fold', ctx); return n === null ? [] : [{ prop: '@fold', value: n }]; },
758
+ 'bend-around'(v, ctx) {
759
+ const n = degNum(v, 'bend-around', ctx);
760
+ if (n === null) return [];
761
+ if (Math.abs(n) > 170) {
762
+ ctx.errors.push({
763
+ file: ctx.file, line: ctx.line,
764
+ message: `bend-around caps at ±170° — past that the arc folds back on itself`,
765
+ hint: 'try 30..120 for a visible arc',
766
+ });
767
+ return [];
768
+ }
769
+ return [{ prop: '@bend-around', value: n }];
770
+ },
771
+ stretch(v, ctx) {
772
+ const t = String(v).trim();
773
+ const m = t.match(/^(\d*\.?\d+)(?:\s+(horizontal|vertical|x|y))?$/i);
774
+ if (!m) {
775
+ ctx.errors.push({
776
+ file: ctx.file, line: ctx.line,
777
+ message: `stretch needs a positive factor, like "stretch: 1.2" or "stretch: 1.4 vertical"`,
778
+ hint: '1 is neutral · the other axis shrinks so volume stays constant',
779
+ });
780
+ return [];
781
+ }
782
+ const f = parseFloat(m[1]);
783
+ if (f <= 0) {
784
+ ctx.errors.push({
785
+ file: ctx.file, line: ctx.line,
786
+ message: `stretch: ${m[1]} would flatten the element to nothing`,
787
+ hint: 'use a factor above 0 — 1.2 is a gentle pull, 2 is cartoonish',
788
+ });
789
+ return [];
790
+ }
791
+ const axisRaw = (m[2] || 'horizontal').toLowerCase();
792
+ const axis = axisRaw === 'y' ? 'vertical' : axisRaw === 'x' ? 'horizontal' : axisRaw;
793
+ return [{ prop: '@stretch', value: { factor: f, axis } }];
794
+ },
795
+
796
+ /* v0.6.0 — gradient tools. `gradient` replaces the CSS function soup with
797
+ * kind · prefix · stop -> stop; stops may be colors, tokens, alpha mixes
798
+ * (ink/40) and carry a trailing % position. gradient-text clips the same
799
+ * gradient into the glyphs. gradient-flow loops background-position so a
800
+ * gradient drifts forever — one line instead of keyframe folklore. */
801
+ gradient(v, ctx) {
802
+ const img = buildGradient(v, ctx);
803
+ return img ? [{ prop: 'background-image', value: img }] : [];
804
+ },
805
+ 'gradient-text'(v, ctx) {
806
+ const img = buildGradient(v, ctx);
807
+ if (!img) return [];
808
+ return [
809
+ { prop: 'background-image', value: img },
810
+ { prop: 'background-clip', value: 'text' },
811
+ { prop: '-webkit-background-clip', value: 'text' },
812
+ { prop: 'color', value: 'transparent' },
813
+ { prop: '-webkit-text-fill-color', value: 'transparent' },
814
+ ];
815
+ },
816
+ 'gradient-flow'(v, ctx) {
817
+ const t = (v || '').trim();
818
+ if (!t || t === 'none') return [{ prop: '@gradient-flow', value: null }];
819
+ const atoms = t.split(/\s+/);
820
+ const dur = atoms[0];
821
+ if (!/^\d+(\.\d+)?(ms|s)?$/.test(dur)) {
822
+ ctx.errors.push({
823
+ file: ctx.file, line: ctx.line,
824
+ message: `gradient-flow takes a duration like "6s" or "6000ms", or "none"`,
825
+ hint: 'gradient-flow loops the element\'s gradient sideways forever',
826
+ });
827
+ return [];
828
+ }
829
+ const ms = dur.endsWith('s') && !dur.endsWith('ms') ? parseFloat(dur) * 1000 : parseFloat(dur);
830
+ const spec = { duration: ms, easing: 'linear', direction: 'normal' };
831
+ for (let k = 1; k < atoms.length; k++) {
832
+ const a = atoms[k];
833
+ if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
834
+ if (a === 'alternate' || a === 'alternate-reverse') { spec.direction = a; continue; }
835
+ ctx.errors.push({
836
+ file: ctx.file, line: ctx.line,
837
+ message: `unknown gradient-flow part "${a}"`,
838
+ hint: didYouMean(a, [...Object.keys(EASINGS), 'alternate', 'alternate-reverse']),
839
+ });
840
+ }
841
+ return [{ prop: '@gradient-flow', value: spec }];
842
+ },
843
+ };
844
+
845
+ /* ------------------------------------------------------------------ *
846
+ * resolveDecl — one declaration → zero or more engine decls
847
+ * ------------------------------------------------------------------ */
848
+
849
+ function resolveDecl(prop, value, ctx) {
850
+ const errors = ctx.errors;
851
+ const ctx2 = { ...ctx, prop, errors };
852
+
853
+ if (prop.startsWith('--')) {
854
+ // v0.4.0 — scoped tokens. Dotted names are token re-declarations:
855
+ // .danger { --color.accent: color.err }
856
+ // sets --dssl-color-accent ON the element, and custom properties inherit,
857
+ // so every var() reference in the subtree re-resolves. Plain names
858
+ // (--brand) stay free-form custom properties, passed through unvalidated.
859
+ const outProp = prop.includes('.')
860
+ ? '--dssl-' + prop.slice(2).replace(/\./g, '-')
861
+ : prop;
862
+ return [{ prop: outProp, value: resolveValue(value, { ...ctx2, unitless: false, bareIdents: true }) }];
863
+ }
864
+
865
+ const sem = SEMANTIC[prop];
866
+ if (sem) return sem(value, ctx2);
867
+
868
+ if (!KNOWN_PROPS.has(prop)) {
869
+ errors.push({
870
+ file: ctx.file, line: ctx.line,
871
+ message: `unknown property "${prop}"`,
872
+ hint: didYouMean(prop, [...KNOWN_PROPS, ...Object.keys(SEMANTIC)]),
873
+ });
874
+ return [];
875
+ }
876
+ return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
877
+ }
878
+
879
+ /* ------------------------------------------------------------------ *
880
+ * Media / container / signal normalization
881
+ * ------------------------------------------------------------------ */
882
+
883
+ function normalizeMedia(query, ctx, line) {
884
+ let q = query.trim();
885
+ if (!q) {
886
+ ctx.errors.push({ file: ctx.file, line, message: '@media needs a condition', hint: 'e.g. @media 768 or @media dark or @media (width >= 768)' });
887
+ return '(min-width: 0)';
888
+ }
889
+ // ---- v0.3 one-word sugar (the conditions everyone writes) ----
890
+ if (/^dark$/i.test(q)) return '(prefers-color-scheme: dark)';
891
+ if (/^light$/i.test(q)) return '(prefers-color-scheme: light)';
892
+ if (/^reduced-motion$/i.test(q)) return '(prefers-reduced-motion: reduce)';
893
+ // ---- bare-width sugar ----
894
+ // @media 768 → (min-width: 768px) "this wide or wider"
895
+ // @media <= 768 → (max-width: 768px) "narrower than this"
896
+ // @media 480..960 → min + max range
897
+ const m = q.match(/^(<=|>=)?\s*(\d+(?:\.\d+)?)\s*(?:\.\.\s*(\d+(?:\.\d+)?))?$/);
898
+ if (m) {
899
+ const a = m[2] + 'px';
900
+ if (m[3] !== undefined) return `(min-width: ${a}) and (max-width: ${m[3]}px)`;
901
+ if (m[1] === '<=') return `(max-width: ${a})`;
902
+ return `(min-width: ${a})`;
903
+ }
904
+ // bare numbers become px: (width >= 768) → (width >= 768px)
905
+ q = q.replace(/([>=<]\s*)(\d+(?:\.\d+)?)\b(?!\s*(?:px|%|em|rem|vh|vw|vmin|vmax|s|ms|deg|dppx|x))/g, '$1$2px');
906
+ return q;
907
+ }
908
+
909
+ function parseSignal(expr, ctx, line) {
910
+ const m = expr.match(/^([A-Za-z_][\w-]*)\s*(==|!=)\s*(.+)$/);
911
+ if (m) {
912
+ let v = m[3].trim();
913
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
914
+ return { name: m[1], op: m[2], value: v };
915
+ }
916
+ if (/^[A-Za-z_][\w-]*$/.test(expr.trim())) return { name: expr.trim(), op: 'truthy', value: null };
917
+ ctx.errors.push({ file: ctx.file, line, message: `bad @signal expression "${expr}"`, hint: 'try: @signal theme == dark or @signal compact' });
918
+ return { name: expr.trim(), op: 'truthy', value: null };
919
+ }
920
+
921
+ /* ------------------------------------------------------------------ *
922
+ * The flattener
923
+ * ------------------------------------------------------------------ */
924
+
925
+ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
926
+ const errors = [];
927
+ const rules = [];
928
+ const shadowRules = [];
929
+ const motions = {};
930
+ const behaviors = {};
931
+ // v0.7.0 — compile-time warnings: things that compile and run but are
932
+ // probably not what the author meant. Separate from errors — ok stays ok —
933
+ // but the engine prints them loudly so nobody debugs cascade order by hand
934
+ // ever again.
935
+ const warnings = [];
936
+ let tokenMap = new Map(Object.entries(baseTokens || DEFAULT_TOKENS));
937
+ const tokenLines = new Map(); // for docs/debug
938
+ let order = 0;
939
+ let ruleId = 0;
940
+
941
+ /* ---- pass 1: collect tokens / behaviors / motions ---- */
942
+
943
+ const collectTokens = (node, path, acc) => {
944
+ for (const child of node.children) {
945
+ if (child.kind === 'decl') {
946
+ const key = path ? path + '.' + child.prop : child.prop;
947
+ tokenMap.set(key, child.value);
948
+ tokenLines.set(key, child.line);
949
+ if (acc) acc.push(key);
950
+ } else if (child.kind === 'selector' || child.kind === 'tokens') {
951
+ // nested groups written as bare words: `color { ink: ... }`
952
+ collectTokens(child, path ? path + '.' + child.sel : child.sel, acc);
953
+ } else {
954
+ errors.push({ file, line: child.line, message: `unexpected "${child.kind}" inside tokens` });
955
+ }
956
+ }
957
+ };
958
+
959
+ const prepass = (nodes) => {
960
+ for (const node of nodes) {
961
+ if (node.kind === 'tokens') collectTokens(node, '');
962
+ else if (node.kind === 'behavior') {
963
+ if (behaviors[node.name]) errors.push({ file, line: node.line, message: `behavior "${node.name}" is defined twice` });
964
+ behaviors[node.name] = node;
965
+ } else if (node.kind === 'motion') {
966
+ motions[node.name] = node;
967
+ } else {
968
+ for (const child of node.children) if (child.kind !== 'decl') prepass([child]);
969
+ }
970
+ }
971
+ };
972
+ prepass(ast.children);
973
+
974
+ /* ---- motions → keyframes ---- */
975
+
976
+ const parseMotion = (name) => {
977
+ const node = motions[name];
978
+ if (!node) return null;
979
+ if (node._parsed) return node._parsed;
980
+ node._parsed = { essential: false, frames: [] };
981
+ const frameOf = (label) => {
982
+ if (label === 'from') return 0;
983
+ if (label === 'to') return 100;
984
+ const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
985
+ return m ? parseFloat(m[1]) : null;
986
+ };
987
+ const ctxBase = { file, line: node.line, errors, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
988
+ for (const child of node.children) {
989
+ if (child.kind === 'decl' && child.prop === 'essential') {
990
+ node._parsed.essential = child.value === '' || child.value === 'true';
991
+ continue;
992
+ }
993
+ if (child.kind === 'decl') {
994
+ errors.push({ file, line: child.line, message: `"${child.prop}" is not allowed inside motion`, hint: 'motions take from/to/at blocks and `essential`' });
995
+ continue;
996
+ }
997
+ if (child.kind === 'selector') {
998
+ const offset = frameOf(child.sel);
999
+ if (offset === null) {
1000
+ errors.push({ file, line: child.line, message: `bad keyframe selector "${child.sel}"`, hint: 'use from, to, or at 40%' });
1001
+ continue;
1002
+ }
1003
+ const props = {};
1004
+ const deform = {}; // v0.6.0 — bend/stretch/fold in keyframes compose into transform
1005
+ const DEFORM_KEY = { '@bend': 'bend', '@bend-y': 'bendY', '@fold': 'fold', '@bend-up': 'bendUp' };
1006
+ for (const d of child.children) {
1007
+ if (d.kind !== 'decl') continue;
1008
+ const resolved = resolveDecl(d.prop, d.value, { ...ctxBase, line: d.line });
1009
+ for (const r of resolved) {
1010
+ if (r.prop.startsWith('--')) continue;
1011
+ if (r.prop.startsWith('@')) {
1012
+ if (r.prop === '@stretch' && r.value) {
1013
+ deform.stretch = typeof r.value === 'object' ? Number(r.value.factor) : parseFloat(r.value);
1014
+ deform.stretchAxis = (r.value && r.value.axis) || 'horizontal';
1015
+ } else if (DEFORM_KEY[r.prop]) deform[DEFORM_KEY[r.prop]] = Number(r.value) || 0;
1016
+ continue;
1017
+ }
1018
+ const camel = r.prop.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
1019
+ props[camel] = r.value;
1020
+ }
1021
+ }
1022
+ if (deform.bend !== undefined || deform.bendY !== undefined || deform.stretch !== undefined ||
1023
+ deform.fold !== undefined || deform.bendUp !== undefined) {
1024
+ // no box exists at resolve time, so edge-pinned members ride a
1025
+ // transformOrigin keyframe property instead of baked translates.
1026
+ // Presence (not truthiness) — `bend: 0` in a frame is a real reset.
1027
+ const c = composeDeform(deform, props.transform || '', null);
1028
+ props.transform = c.transform;
1029
+ if (c.origin) props.transformOrigin = c.origin;
1030
+ }
1031
+ node._parsed.frames.push({ offset, props });
1032
+ }
1033
+ }
1034
+ node._parsed.frames.sort((a, b) => a.offset - b.offset);
1035
+ return node._parsed;
1036
+ };
1037
+
1038
+ /* ---- token emission ---- */
1039
+
1040
+ const tokenNames = () => [...tokenMap.keys()];
1041
+
1042
+ const resolveTokenDecls = (keys, conds) => {
1043
+ const decls = [];
1044
+ const ctxBase = { file, line: 0, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false };
1045
+ for (const key of keys) {
1046
+ const raw = tokenMap.get(key);
1047
+ const prop = '--dssl-' + key.replace(/\./g, '-');
1048
+ // font.* tokens hold font stacks — proper nouns, not keywords (v0.5.0)
1049
+ decls.push({ prop, value: resolveValue(raw, { ...ctxBase, prop: key, bareIdents: /^font/.test(key) }) });
1050
+ }
1051
+ if (!decls.length) return null;
1052
+ return {
1053
+ id: ruleId++, sel: ':root', media: conds.media, signals: conds.signals,
1054
+ containers: conds.containers, layer: 'tokens', order: order++,
1055
+ decls, file, line: 0, pseudo: null, states: new Set(),
1056
+ };
1057
+ };
1058
+
1059
+ /** used by index.js to install the shared registry as a base bundle */
1060
+ // Built immediately, before the walk, so its cascade order sorts below every
1061
+ // author rule (engine cascade: layer first, then source order).
1062
+ const registryRule = resolveTokenDecls([...tokenMap.keys()], { media: [], signals: [], containers: [] });
1063
+
1064
+ /* ---- main recursive walk ---- */
1065
+
1066
+ const walk = (children, ctx, ctxRule) => {
1067
+ for (const node of children) {
1068
+ switch (node.kind) {
1069
+ case 'decl': {
1070
+ if (node.prop === 'use') {
1071
+ const name = node.value.trim();
1072
+ const bh = behaviors[name];
1073
+ if (!bh) {
1074
+ errors.push({ file, line: node.line, message: `unknown behavior "${name}"`, hint: didYouMean(name, Object.keys(behaviors)) });
1075
+ break;
1076
+ }
1077
+ if (bh._active) { errors.push({ file, line: node.line, message: `behavior cycle involving "${name}"` }); break; }
1078
+ bh._active = true;
1079
+ walk(bh.children, ctx, ctxRule);
1080
+ bh._active = false;
1081
+ break;
1082
+ }
1083
+ const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
1084
+ for (const r of resolved) {
1085
+ // v0.7.0 — remember WHICH source property wrote a display value,
1086
+ // so the ordering linter can tell an explicit "display:" from a
1087
+ // shorthand that expanded into one (column: → display: flex).
1088
+ if (r.prop === 'display' && node.prop !== 'display') r.via = node.prop;
1089
+ ctxRule.decls.push({ ...r, line: node.line });
1090
+ }
1091
+ break;
1092
+ }
1093
+ case 'selector': {
1094
+ const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
1095
+ const childCtx = { ...ctx, sel };
1096
+ const sub = { decls: [] };
1097
+ walk(node.children, childCtx, sub);
1098
+ flush(sub, childCtx, node.line);
1099
+ break;
1100
+ }
1101
+ case 'state': {
1102
+ if (!ctx.sel) {
1103
+ errors.push({ file, line: node.line, message: `"${node.name}" needs a selector around it` });
1104
+ break;
1105
+ }
1106
+ const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
1107
+ const sub = { decls: [] };
1108
+ walk(node.children, childCtx, sub);
1109
+ flush(sub, childCtx, node.line);
1110
+ break;
1111
+ }
1112
+ case 'media': {
1113
+ const q = normalizeMedia(node.query, { errors, file, line: node.line }, node.line);
1114
+ const sub = { decls: [] };
1115
+ walk(node.children, { ...ctx, media: [...ctx.media, q] }, sub);
1116
+ flush(sub, ctx, node.line);
1117
+ break;
1118
+ }
1119
+ case 'container': {
1120
+ const q = normalizeMedia(node.query || '(min-width: 0)', { errors, file, line: node.line }, node.line);
1121
+ const sub = { decls: [] };
1122
+ walk(node.children, { ...ctx, containers: [...ctx.containers, q] }, sub);
1123
+ flush(sub, ctx, node.line);
1124
+ break;
1125
+ }
1126
+ case 'signal': {
1127
+ const sig = parseSignal(node.expr, { errors, file, line: node.line }, node.line);
1128
+ const sub = { decls: [] };
1129
+ walk(node.children, { ...ctx, signals: [...ctx.signals, sig] }, sub);
1130
+ flush(sub, ctx, node.line);
1131
+ break;
1132
+ }
1133
+ case 'layer':
1134
+ case 'pin': {
1135
+ const layer = node.kind === 'pin' ? 'pin' : (['tokens', 'base', 'components', 'utilities', 'pin'].includes(node.name) ? node.name : 'components');
1136
+ const sub = { decls: [] };
1137
+ walk(node.children, { ...ctx, layer }, sub);
1138
+ flush(sub, ctx, node.line);
1139
+ break;
1140
+ }
1141
+ case 'pseudo': {
1142
+ const base = ctx.sel || '*'; // top-level ::selection etc. means every element
1143
+ const srule = {
1144
+ id: ruleId++, sel: base, pseudo: node.name,
1145
+ media: ctx.media, signals: ctx.signals, containers: ctx.containers,
1146
+ layer: ctx.layer, order: order++, decls: [], file, line: node.line,
1147
+ };
1148
+ for (const d of node.children) {
1149
+ if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
1150
+ if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
1151
+ const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
1152
+ // v0.5.0 — engine-native properties have no pseudo-element story:
1153
+ // there is no DOM handle to nudge, animate, stack or stagger. They
1154
+ // used to leak into the shadow sheet as junk CSS the browser
1155
+ // silently dropped — exactly the silence this language refuses.
1156
+ for (const r of resolved) {
1157
+ if (r.prop.startsWith('@')) {
1158
+ 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' });
1159
+ continue;
1160
+ }
1161
+ srule.decls.push({ ...r, line: d.line });
1162
+ }
1163
+ }
1164
+ if (srule.decls.length) shadowRules.push(srule);
1165
+ break;
1166
+ }
1167
+ case 'tokens': {
1168
+ if (ctx.sel) { errors.push({ file, line: node.line, message: 'tokens must be top-level or inside @media / @signal' }); break; }
1169
+ // structural tracking: only keys THIS block declares are emitted,
1170
+ // so conditional palettes don't shadow unrelated tokens
1171
+ const ownKeys = [];
1172
+ collectTokens(node, '', ownKeys);
1173
+ const tr = ownKeys.length ? resolveTokenDecls(ownKeys, ctx) : null;
1174
+ if (tr) rules.push(tr);
1175
+ break;
1176
+ }
1177
+ case 'behavior':
1178
+ case 'motion':
1179
+ if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` });
1180
+ break;
1181
+ default:
1182
+ break;
1183
+ }
1184
+ }
1185
+ };
1186
+
1187
+ function flush(sub, ctx, line) {
1188
+ if (!sub.decls.length) return;
1189
+ if (!ctx.sel) {
1190
+ 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' });
1191
+ return;
1192
+ }
1193
+ // v0.5.0 — the pseudo-element trap fix. A full selector like `.card::before`
1194
+ // used to compile ok and then match NOTHING: inline styles can never reach
1195
+ // pseudo-elements. Now the selector is split into element + pseudo halves
1196
+ // and the rule rides the shadow-CSS pipeline — exactly what the nested
1197
+ // form (`.card { ::before { } }`) always did. Both forms are now one thing.
1198
+ const ps = analyzePseudo(ctx.sel);
1199
+ if (ps.mode === 'all') {
1200
+ // parity with the nested path: bare `content:` becomes content: ""
1201
+ // v0.5.0 — and engine-native properties fail loud here too (see the
1202
+ // nested pseudo path): no DOM handle means no nudge, animate or stack.
1203
+ const decls = [];
1204
+ for (const d of sub.decls) {
1205
+ if (d.prop && d.prop.startsWith('@')) {
1206
+ 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' });
1207
+ continue;
1208
+ }
1209
+ decls.push(d.prop === 'content' && d.value === '' ? { ...d, value: '""' } : d);
1210
+ }
1211
+ for (const { el, pseudo } of ps.parts) {
1212
+ const name = pseudo.split(/[\s:(]/)[0];
1213
+ if (!PSEUDO_ELEMENTS.has(name) && !VENDOR_PSEUDO.test(name)) {
1214
+ errors.push({
1215
+ file, line,
1216
+ message: `unknown pseudo-element "::${pseudo}"`,
1217
+ hint: 'try ::before, ::after, ::selection, or a vendor one like ::-webkit-scrollbar-thumb',
1218
+ });
1219
+ continue;
1220
+ }
1221
+ if (/:seen\b/.test(el)) {
1222
+ errors.push({
1223
+ file, line,
1224
+ message: `the engine-native "seen" state can't combine with "::${name}" in shadow CSS`,
1225
+ hint: 'style the element itself in a seen { } block instead',
1226
+ });
1227
+ continue;
1228
+ }
1229
+ // v0.5.0 — parity with the nested path: if every declaration was
1230
+ // rejected (engine-native props, bad values), emit NOTHING rather
1231
+ // than an empty ghost rule that matches and styles zero things.
1232
+ if (!decls.length) continue;
1233
+ shadowRules.push({
1234
+ id: ruleId++, sel: el, pseudo,
1235
+ media: ctx.media, signals: ctx.signals, containers: ctx.containers,
1236
+ layer: ctx.layer, order: order++, decls, file, line,
1237
+ });
1238
+ }
1239
+ return;
1240
+ }
1241
+ if (ps.mode === 'mixed') {
1242
+ 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' });
1243
+ return;
1244
+ }
1245
+ warnDisplayClash(sub.decls);
1246
+ rules.push({
1247
+ id: ruleId++, sel: ctx.sel, media: ctx.media, signals: ctx.signals,
1248
+ containers: ctx.containers, layer: ctx.layer, order: order++,
1249
+ decls: sub.decls, file, line, pseudo: null,
1250
+ states: scanStates(ctx.sel),
1251
+ });
1252
+ }
1253
+
1254
+ /* v0.7.0 — the ordering linter. Within one rule, later wins; that is the
1255
+ * whole cascade model. But "column: gap 2" quietly expanding into
1256
+ * display: flex after an explicit "display: none" reads like a menu
1257
+ * toggle and behaves like a bug report — a real reviewer lost an evening
1258
+ * to exactly this ("their own source comments warn about this, but it
1259
+ * should be a compiler warning, not a comment"). Agreed. When a
1260
+ * shorthand's expansion clobbers an explicit display written earlier in
1261
+ * the SAME rule, say so at compile time. Cross-rule overrides are the
1262
+ * documented cascade at work, not an accident — no warning there. */
1263
+ function warnDisplayClash(decls) {
1264
+ let explicit = null;
1265
+ for (const d of decls) {
1266
+ if (d.prop !== 'display') continue;
1267
+ if (!d.via) { explicit = d; continue; }
1268
+ if (explicit && explicit.value !== d.value && warnings.length < 400) {
1269
+ warnings.push({
1270
+ file, line: d.line,
1271
+ 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`,
1272
+ 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`,
1273
+ });
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ /* ---- go ---- */
1279
+
1280
+ const topCtx = { sel: '', media: [], signals: [], containers: [], layer: 'components', depth: 0 };
1281
+ const rootRule = { decls: [] };
1282
+ walk(ast.children, topCtx, rootRule);
1283
+ flush(rootRule, topCtx, 0);
1284
+
1285
+ // parse all motions that were referenced
1286
+ for (const r of rules) {
1287
+ for (const d of r.decls) {
1288
+ if (d.prop === '@animate' && d.value && d.value.motion) {
1289
+ const m = parseMotion(d.value.motion);
1290
+ if (!m) {
1291
+ errors.push({
1292
+ file, line: d.line,
1293
+ message: `unknown motion "${d.value.motion}"`,
1294
+ hint: didYouMean(d.value.motion, Object.keys(motions)),
1295
+ });
1296
+ d.value.broken = true;
1297
+ } else {
1298
+ d.value.frames = m.frames;
1299
+ d.value.essential = m.essential;
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ const mediaQueries = new Set();
1306
+ for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
1307
+
1308
+ return { rules, shadowRules, motions, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
1309
+ }
1310
+
1311
+ /** naive `&` substitution + descendant join */
1312
+ function combine(parent, child) {
1313
+ if (child.includes('&')) return child.split('&').join(parent);
1314
+ return parent + ' ' + child;
1315
+ }
1316
+
1317
+ /* v0.5.0 — where does a selector carry a pseudo-element?
1318
+ * 'none' — plain selector, inline-style path
1319
+ * 'all' — every comma-part has one: { parts: [{ el, pseudo }] } → shadow CSS
1320
+ * 'mixed' — some parts have one, some don't → loud error (split the list)
1321
+ * The `::` scan is string- and paren-aware so [data-x="::a"] can't false-hit. */
1322
+ function analyzePseudo(sel) {
1323
+ // top-level comma split
1324
+ const parts = [];
1325
+ let depth = 0, q = null, cur = '';
1326
+ for (const ch of sel) {
1327
+ if (q) { cur += ch; if (ch === q) q = null; continue; }
1328
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
1329
+ if (ch === '(' || ch === '[') depth++;
1330
+ else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
1331
+ if (ch === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
1332
+ cur += ch;
1333
+ }
1334
+ parts.push(cur);
1335
+
1336
+ const splits = [];
1337
+ let withPseudo = 0;
1338
+ for (const part of parts) {
1339
+ let idx = -1; depth = 0; q = null;
1340
+ for (let i = 0; i < part.length - 1; i++) {
1341
+ const ch = part[i];
1342
+ if (q) { if (ch === q && part[i - 1] !== '\\') q = null; continue; }
1343
+ if (ch === '"' || ch === "'") { q = ch; continue; }
1344
+ if (ch === '(' || ch === '[') depth++;
1345
+ else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
1346
+ else if (depth === 0 && ch === ':' && part[i + 1] === ':') { idx = i; break; }
1347
+ }
1348
+ if (idx === -1) { splits.push(null); continue; }
1349
+ withPseudo++;
1350
+ const el = part.slice(0, idx).trim();
1351
+ const pseudo = part.slice(idx + 2).trim();
1352
+ splits.push(el && pseudo ? { el, pseudo } : null);
1353
+ }
1354
+ if (withPseudo === 0) return { mode: 'none' };
1355
+ if (withPseudo !== parts.length || splits.some(s => !s)) return { mode: 'mixed', parts: splits };
1356
+ return { mode: 'all', parts: splits };
1357
+ }
1358
+
1359
+ const STATE_SCAN = /:(hover|focus-visible|focus|active|disabled|checked|empty|target|invalid|valid|required|open|closed|read-only|read-write|placeholder-shown|first-child|last-child|only-child|seen)\b/g;
1360
+ function scanStates(sel) {
1361
+ const s = new Set();
1362
+ if (!sel) return s;
1363
+ let m;
1364
+ const re = new RegExp(STATE_SCAN.source, 'g');
1365
+ while ((m = re.exec(sel))) s.add(m[1]);
1366
+ return s;
1367
+ }
dssl-vscode/lib/engine/utils.js ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* DSSL — utils.js — tiny shared helpers */
2
+
3
+ export function levenshtein(a, b) {
4
+ if (a === b) return 0;
5
+ const m = a.length, n = b.length;
6
+ if (!m) return n;
7
+ if (!n) return m;
8
+ let prev = new Array(n + 1);
9
+ let cur = new Array(n + 1);
10
+ for (let j = 0; j <= n; j++) prev[j] = j;
11
+ for (let i = 1; i <= m; i++) {
12
+ cur[0] = i;
13
+ for (let j = 1; j <= n; j++) {
14
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
15
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
16
+ }
17
+ [prev, cur] = [cur, prev];
18
+ }
19
+ return prev[n];
20
+ }
21
+
22
+ /** best "did you mean ..." suggestion, or undefined */
23
+ export function didYouMean(word, candidates) {
24
+ if (!word || !candidates || !candidates.length) return undefined;
25
+ let best = undefined, bestDist = Infinity;
26
+ const w = word.toLowerCase();
27
+ for (const c of candidates) {
28
+ if (!c) continue;
29
+ const d = levenshtein(w, String(c).toLowerCase());
30
+ if (d < bestDist) { bestDist = d; best = c; }
31
+ }
32
+ const tolerance = Math.max(2, Math.floor(w.length / 3));
33
+ return bestDist <= tolerance ? String(best) : undefined;
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
+ * ------------------------------------------------------------------ */
47
+
48
+ const r2 = (v) => Math.round(v * 100) / 100;
49
+ 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 = [];
60
+ let origin = null;
61
+ const has3D = d.bend || d.bendY || d.fold || d.bendUp;
62
+ if (has3D) parts.push('perspective(900px)');
63
+ if (d.bend) parts.push(`rotateX(${d.bend}deg)`);
64
+ if (d.bendY) parts.push(`rotateY(${d.bendY}deg)`);
65
+ const h = box && box.h ? box.h : 0;
66
+ if (d.fold) { // top edge pinned, bottom folds up toward the viewer
67
+ if (h > 0) parts.push(`translateY(${r2(-h / 2)}px)`, `rotateX(${d.fold}deg)`, `translateY(${r2(h / 2)}px)`);
68
+ else { parts.push(`rotateX(${d.fold}deg)`); origin = '50% 0%'; }
69
+ }
70
+ if (d.bendUp) { // bottom edge pinned, the element rocks back like a drawbridge
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})`
78
+ : `scaleX(${d.stretch}) scaleY(${inv})`);
79
+ }
80
+ const baseT = base && base !== 'none' ? String(base).trim() : '';
81
+ const all = baseT ? [...parts, baseT] : parts;
82
+ return { transform: all.length ? all.join(' ') : 'none', origin };
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
90
+ * child, or null when there is nothing to arc (fewer than 2 children, no
91
+ * width, or a near-zero angle).
92
+ */
93
+ export function arcLayout(n, width, deg) {
94
+ if (!n || n < 2 || !deg || !width || width <= 0) return null;
95
+ const half = (Math.abs(deg) / 2) * Math.PI / 180;
96
+ const sin = Math.sin(half);
97
+ if (sin < 1e-4) return null;
98
+ const R = r2((width / 2) / sin);
99
+ const angles = [];
100
+ for (let i = 0; i < n; i++) angles.push(r2(-deg / 2 + (deg * i) / (n - 1)));
101
+ return { R, angles };
102
+ }
dssl-vscode/lib/props-docs.js ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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`.',
15
+ gradient: 'gradient as a sentence: `gradient: linear 135, color.accent -> color.ok`. Stops can be hex, name, token, alpha mix, or percent-positioned. A stop that is not a color is a compile error.',
16
+ 'gradient-text': 'clips the gradient grammar into the glyphs: `gradient-text: linear to right, color.accent, color.ink`.',
17
+ 'gradient-flow': 'the gradient drifts forever through WAAPI: `gradient-flow: 6s alternate`. Holds still under reduced-motion.',
18
+ bend: 'bows the element in 3D. Positive tips the top away, negative leans toward you. Bare numbers are degrees. Composes into the one transform channel.',
19
+ 'bend-y': 'sideways bow. `bend-y: -18`.',
20
+ 'bend-up': 'rocks back on its planted bottom edge, drawbridge-style. `bend-up: 14`.',
21
+ fold: 'swings up over its top edge like a closing page. `fold: 14`.',
22
+ stretch: 'cartoon squash-and-stretch that keeps volume: `stretch: 0.94` squeezes to 0.94 wide and honestly grows to 1/0.94 tall.',
23
+ 'bend-around': 'curves a container\u2019s CHILDREN along a circular arc instead of deforming one element: `bend-around: -22` opens into a smile. Caps at \u00b1170\u00b0. Children keep their own hover bends.',
24
+ animate: 'run a motion: `animate: name duration [easing] [delay] [fill] [infinite] [alternate]`. Example: `animate: pulse 1600 infinite alternate`.',
25
+ stagger: 'choreographed children: `stagger: 70` fires each child\u2019s animation 70ms after its older sibling.',
26
+ transition: 'per-property transition: `transition: 200`, or `transition: 200 snap transform`. Honors reduced-motion.',
27
+ overlap: 'the anti-overlap policy: `auto` (engine shoves translucent elements apart), `deny` (this one never sits on anything), `allow` (exempt). Nothing below 100% opacity overlaps anything unless you say so.',
28
+ truncate: 'one-line ellipsis. Expands to overflow hidden, nowrap, text-overflow ellipsis.',
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
+
35
+ export const CSS = {
36
+ display: 'passthrough CSS. Note: semantic shorthands (row, column, grid, stack) also write display \u2014 order matters, and the compiler warns when a shorthand clobbers an explicit display.',
37
+ position: 'passthrough CSS: static, relative, absolute, fixed, sticky.',
38
+ width: 'passthrough CSS. Bare numbers are pixels.',
39
+ height: 'passthrough CSS. Bare numbers are pixels.',
40
+ margin: 'passthrough CSS. Shorthand with 1-4 values.',
41
+ padding: 'passthrough CSS. Shorthand with 1-4 values.',
42
+ background: 'passthrough CSS, but prefer `gradient:` sentences for gradients.',
43
+ color: 'text color. Values: token (color.ink), hex, CSS color name, alpha mix (color.ink/85).',
44
+ border: 'passthrough CSS shorthand: `border: 1 solid color.line`.',
45
+ 'border-radius': 'passthrough CSS \u2014 prefer `radius:`.',
46
+ 'font-size': 'passthrough CSS. Bare numbers are pixels.',
47
+ 'font-family': 'passthrough CSS. Bare multi-word font names are legal since v0.5.',
48
+ 'line-height': 'passthrough CSS.',
49
+ 'letter-spacing': 'passthrough CSS.',
50
+ 'text-align': 'passthrough CSS: left, center, right, justify.',
51
+ 'text-transform': 'passthrough CSS: uppercase, lowercase, capitalize.',
52
+ 'white-space': 'passthrough CSS: nowrap, pre, pre-wrap.',
53
+ opacity: 'passthrough CSS. Below 100% the overlap policy wakes up (see `overlap`).',
54
+ transform: 'passthrough CSS \u2014 deform (bend/stretch/fold) composes into this channel.',
55
+ translate: 'the transform channel the engine itself uses for overlap escapes; animating translate never fights the engine.',
56
+ rotate: 'passthrough CSS.',
57
+ scale: 'passthrough CSS.',
58
+ filter: 'passthrough CSS: blur(10), brightness(1.1).',
59
+ 'backdrop-filter': 'passthrough CSS: blur(10).',
60
+ outline: 'passthrough CSS: `outline: 2 solid color.accent`.',
61
+ cursor: 'passthrough CSS: pointer, default, not-allowed.',
62
+ 'z-index': 'passthrough CSS.',
63
+ overflow: 'passthrough CSS: hidden, auto, scroll, clip.',
64
+ 'object-fit': 'passthrough CSS: cover, contain.',
65
+ gap: 'passthrough CSS \u2014 or let row:/column: set it inline.',
66
+ 'flex-direction': 'passthrough CSS \u2014 row:/column: set it.',
67
+ 'flex-wrap': 'passthrough CSS: wrap, nowrap.',
68
+ 'justify-content': 'passthrough CSS \u2014 row:/column: can set it.',
69
+ 'align-items': 'passthrough CSS \u2014 row:/column: can set it.',
70
+ 'grid-template-columns': 'passthrough CSS \u2014 `grid: 3` is the sugar.',
71
+ 'grid-template-rows': 'passthrough CSS.',
72
+ 'align-content': 'passthrough CSS.',
73
+ 'box-shadow': 'passthrough CSS \u2014 prefer `shadow:`.',
74
+ inset: 'passthrough CSS.',
75
+ 'aspect-ratio': 'passthrough CSS: `aspect-ratio: 16 9`.',
76
+ 'user-select': 'passthrough CSS: none stops text selection (pairs with pointer-events: none for veils).',
77
+ 'pointer-events': 'passthrough CSS: none lets clicks and selection fall through overlays.',
78
+ 'scrollbar-color': 'passthrough CSS for themed scrollbars.',
79
+ 'scrollbar-width': 'passthrough CSS.',
80
+ 'accent-color': 'passthrough CSS for native controls.',
81
+ 'will-change': 'passthrough CSS.',
82
+ content: 'pseudo-element content, required for ::before/::after. Empty is auto-corrected to "".',
83
+ top: 'passthrough CSS.',
84
+ right: 'passthrough CSS.',
85
+ bottom: 'passthrough CSS.',
86
+ left: 'passthrough CSS.',
87
+ };
88
+
89
+ export const STATES = {
90
+ hover: 'engine-native hover: real events, enters and leaves, re-evaluates descendants too.',
91
+ focus: 'engine-native focus state.',
92
+ 'focus-visible': 'keyboard-focus only. The focus-ring behavior\u2019s home.',
93
+ active: 'mouse/touch down.',
94
+ press: 'alias for active.',
95
+ pressed: 'alias for active.',
96
+ disabled: 'matches while the element is disabled.',
97
+ checked: 'matches while the element is checked.',
98
+ empty: 'matches an element with no children.',
99
+ target: 'matches when the element is the URL fragment target.',
100
+ seen: 'fires once, first time the element scrolls into view. Zero-JS reveal-on-scroll; composes with stagger.',
101
+ };
102
+
103
+ export const AT_RULES = {
104
+ '@media': 'conditions: `@media (max-width: 860)`, sugar forms `@media dark`, `@media light`, `@media reduced-motion`, bare widths, `<=` ranges.',
105
+ '@container': 'evaluates against the nearest ancestor (or self) with a container-type: `@container (min-width: 480)`.',
106
+ '@signal': 'the JS bridge: `@signal menuOpen { ... }` re-evaluates when DSSL.signal(\u0027menuOpen\u0027, true) fires. Mirrors onto data-dssl-signal-* attributes.',
107
+ '@layer': 'cascade layers: `@layer base { }`, `@layer components { }`, `@layer utilities { }`. Cascade is (layer, source order) \u2014 no specificity, no !important.',
108
+ };
109
+
110
+ export const BLOCKS = {
111
+ tokens: 'define design tokens: `tokens { color { ink: #16161d } radius { md: 14 } space { 9: 96 } }`. Numeric keys are legal. Referenced as color.ink, radius.md, space.9. Trailing /n adds alpha.',
112
+ behavior: 'a named rule group you can `use` anywhere: `behavior focus-ring { focus-visible { ... } }`.',
113
+ motion: 'a named keyframe animation, run with `animate:`: `motion pulse { from { } to { } }`.',
114
+ pin: 'must-win declarations. The cascade ranks tokens < base < components < utilities < pin. This is the ONLY way to force a win \u2014 there is no !important.',
115
+ };
116
+
117
+ export const PROPERTY_NAMES = [...Object.keys(SEMANTIC), ...Object.keys(CSS)];
dssl-vscode/package.json ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.7.1",
6
+ "publisher": "EHS-Studio",
7
+ "license": "MIT",
8
+ "engines": {
9
+ "vscode": "^1.82.0"
10
+ },
11
+ "categories": [
12
+ "Programming Languages",
13
+ "Linters"
14
+ ],
15
+ "keywords": [
16
+ "dssl",
17
+ "css",
18
+ "styling",
19
+ "design tokens",
20
+ "style language"
21
+ ],
22
+ "activationEvents": [
23
+ "onLanguage:dssl"
24
+ ],
25
+ "main": "./extension.js",
26
+ "contributes": {
27
+ "languages": [
28
+ {
29
+ "id": "dssl",
30
+ "aliases": [
31
+ "DSSL",
32
+ "dssl"
33
+ ],
34
+ "extensions": [
35
+ ".dssl"
36
+ ],
37
+ "configuration": "./language-configuration.json"
38
+ }
39
+ ],
40
+ "grammars": [
41
+ {
42
+ "language": "dssl",
43
+ "scopeName": "source.dssl",
44
+ "path": "./syntaxes/dssl.tmLanguage.json"
45
+ }
46
+ ],
47
+ "configuration": {
48
+ "title": "DSSL",
49
+ "properties": {
50
+ "dssl.diagnostics.enable": {
51
+ "type": "boolean",
52
+ "default": true,
53
+ "description": "Run the real DSSL compiler on save and change, and surface errors and warnings as diagnostics."
54
+ },
55
+ "dssl.diagnostics.debounce": {
56
+ "type": "number",
57
+ "default": 250,
58
+ "description": "Milliseconds to wait after a keystroke before re-running the compiler."
59
+ }
60
+ }
61
+ }
62
+ },
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "https://huggingface.co/EHS-Studio/dssl"
66
+ }
67
+ }
dssl-vscode/syntaxes/dssl.tmLanguage.json ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "DSSL",
3
+ "scopeName": "source.dssl",
4
+ "patterns": [
5
+ { "include": "#comments" },
6
+ { "include": "#at-rules" },
7
+ { "include": "#block-keywords" },
8
+ { "include": "#use" },
9
+ { "include": "#state-headers" },
10
+ { "include": "#strings" },
11
+ { "include": "#properties" },
12
+ { "include": "#values" },
13
+ { "include": "#selectors" }
14
+ ],
15
+ "repository": {
16
+ "comments": {
17
+ "patterns": [
18
+ {
19
+ "name": "comment.line.double-slash.dssl",
20
+ "match": "//.*$"
21
+ },
22
+ {
23
+ "name": "comment.block.dssl",
24
+ "begin": "/\\*",
25
+ "end": "\\*/",
26
+ "patterns": [{ "include": "#values" }]
27
+ }
28
+ ]
29
+ },
30
+ "strings": {
31
+ "patterns": [
32
+ { "name": "string.quoted.double.dssl", "begin": "\"", "end": "\"", "patterns": [{ "include": "#values" }] },
33
+ { "name": "string.quoted.single.dssl", "begin": "'", "end": "'", "patterns": [{ "include": "#values" }] }
34
+ ]
35
+ },
36
+ "at-rules": {
37
+ "patterns": [
38
+ {
39
+ "name": "meta.at-rule.dssl",
40
+ "match": "(@)(media|container|signal|layer|supports)\\b([^\\n{]*)",
41
+ "captures": {
42
+ "1": { "name": "punctuation.definition.at-rule.dssl" },
43
+ "2": { "name": "keyword.control.at-rule.dssl" },
44
+ "3": {
45
+ "patterns": [
46
+ { "include": "#values" },
47
+ { "name": "entity.name.condition.dssl", "match": "[\\w-]+\\s*\\(" },
48
+ { "name": "variable.other.signal.dssl", "match": "\\b[a-z][\\w-]*\\b" }
49
+ ]
50
+ }
51
+ }
52
+ }
53
+ ]
54
+ },
55
+ "block-keywords": {
56
+ "patterns": [
57
+ {
58
+ "name": "meta.tokens-block.dssl",
59
+ "match": "^\\s*(tokens|pin)(?=\\s*\\{)",
60
+ "captures": { "1": { "name": "keyword.control.block.dssl" } }
61
+ },
62
+ {
63
+ "name": "meta.named-block.dssl",
64
+ "match": "^\\s*(behavior|motion)\\s+([\\w-]+)(?=\\s*\\{)",
65
+ "captures": {
66
+ "1": { "name": "keyword.control.block.dssl" },
67
+ "2": { "name": "entity.name.function.dssl" }
68
+ }
69
+ }
70
+ ]
71
+ },
72
+ "use": {
73
+ "patterns": [
74
+ {
75
+ "name": "meta.use-behavior.dssl",
76
+ "match": "(?<=^\\s*|;\\s*)(use)\\s+([\\w-]+)",
77
+ "captures": {
78
+ "1": { "name": "keyword.control.use.dssl" },
79
+ "2": { "name": "entity.name.function.dssl" }
80
+ }
81
+ }
82
+ ]
83
+ },
84
+ "state-headers": {
85
+ "patterns": [
86
+ {
87
+ "name": "meta.state-block.dssl",
88
+ "match": "^\\s*(hover|focus|focus-visible|active|press|pressed|disabled|checked|empty|target|seen|before|after)(?=\\s*\\{)",
89
+ "captures": { "1": { "name": "keyword.control.state.dssl" } }
90
+ }
91
+ ]
92
+ },
93
+ "properties": {
94
+ "patterns": [
95
+ {
96
+ "name": "meta.property.dssl",
97
+ "match": "(?<=^\\s*|;\\s*|\\{\\s*)(--?[\\w-]+)\\s*:(?![^;\\n]*\\{)",
98
+ "captures": {
99
+ "1": {
100
+ "patterns": [
101
+ {
102
+ "name": "support.type.property-name.custom.dssl",
103
+ "match": "^--"
104
+ },
105
+ {
106
+ "name": "support.type.property-name.dssl",
107
+ "match": ".+"
108
+ }
109
+ ]
110
+ }
111
+ }
112
+ }
113
+ ]
114
+ },
115
+ "values": {
116
+ "patterns": [
117
+ { "include": "#comments" },
118
+ { "include": "#strings" },
119
+ {
120
+ "name": "constant.numeric.token-ref.dssl",
121
+ "match": "(?<=[\\s:(,])([\\w-]+\\.[\\w-]+(?:\\.[\\w-]+)*)",
122
+ "captures": { "1": { "name": "variable.other.token.dssl" } }
123
+ },
124
+ {
125
+ "name": "constant.other.color.hex.dssl",
126
+ "match": "#[0-9a-fA-F]{3,8}\\b"
127
+ },
128
+ {
129
+ "name": "constant.numeric.dssl",
130
+ "match": "(?<![\\w.#-])-?\\d+(?:\\.\\d+)?(?:%|px|s|ms|deg|fr|em|rem|vh|vw|ch)?"
131
+ },
132
+ {
133
+ "name": "support.constant.keyword.dssl",
134
+ "match": "\\b(infinite|alternate|alternate-reverse|reverse|snap|essential|linear|ease|ease-in|ease-out|ease-in-out|from|to|odd|even|flex-start|flex-end|center|stretch|baseline|nowrap|wrap|bold|manual|pixelated|crisp-edges|solid|dashed|dotted|hidden|visible|sticky|absolute|relative|fixed|block|inline|flex|grid|left|right|top|bottom|both|forwards|backwards|none|auto|inherit|initial|unset|revert)\\b"
135
+ },
136
+ {
137
+ "name": "keyword.operator.arrow.dssl",
138
+ "match": "->"
139
+ },
140
+ {
141
+ "name": "keyword.operator.alpha.dssl",
142
+ "match": "(?<=\\w)/\\d+"
143
+ },
144
+ {
145
+ "name": "punctuation.separator.comma.dssl",
146
+ "match": ","
147
+ }
148
+ ]
149
+ },
150
+ "selectors": {
151
+ "patterns": [
152
+ {
153
+ "name": "meta.selector.dssl",
154
+ "match": "(?<![\\w-])\\.[\\w-]+",
155
+ "captures": { "0": { "name": "entity.other.attribute-name.class.dssl" } }
156
+ },
157
+ {
158
+ "name": "meta.selector.id.dssl",
159
+ "match": "(?<![\\w-])#[\\w-]+",
160
+ "captures": { "0": { "name": "entity.other.attribute-name.id.dssl" } }
161
+ },
162
+ {
163
+ "name": "keyword.operator.parent.dssl",
164
+ "match": "&"
165
+ },
166
+ {
167
+ "name": "keyword.operator.combinator.dssl",
168
+ "match": ">"
169
+ },
170
+ {
171
+ "name": "entity.name.tag.dssl",
172
+ "match": "(?<![\\w.\\-#])([a-zA-Z][\\w-]*)(?=[\\s,{>:])"
173
+ },
174
+ {
175
+ "name": "entity.other.pseudo-class.dssl",
176
+ "match": "::?(-webkit-|-moz-|-ms-)?[\\w-]+(?![\\w-])"
177
+ },
178
+ {
179
+ "name": "punctuation.section.block.begin.dssl",
180
+ "match": "\\{"
181
+ },
182
+ {
183
+ "name": "punctuation.section.block.end.dssl",
184
+ "match": "\\}"
185
+ }
186
+ ]
187
+ }
188
+ }
189
+ }
public/dssl-engine.tar.gz ADDED
Binary file (52.6 kB). View file
 
public/dssl-engine.zip ADDED
Binary file (57.5 kB). View file
 
public/dssl/index.js CHANGED
@@ -18,7 +18,7 @@ import { parse } from './parser.js';
18
  import { resolve, DEFAULT_TOKENS } from './resolver.js';
19
  import { DsslEngine } from './engine.js';
20
 
21
- export const VERSION = '0.7.0';
22
 
23
  let counter = 0;
24
  const engines = new Map();
 
18
  import { resolve, DEFAULT_TOKENS } from './resolver.js';
19
  import { DsslEngine } from './engine.js';
20
 
21
+ export const VERSION = '0.7.1';
22
 
23
  let counter = 0;
24
  const engines = new Map();
public/dssl/parser.js CHANGED
@@ -79,8 +79,14 @@ function stripComments(src) {
79
  }
80
  if (c === '"' || c === "'") { q = c; out += c; continue; }
81
  if (c === '/' && n === '/') {
 
 
 
 
 
82
  while (i < src.length && src[i] !== '\n') i++;
83
- out += '\n'; i--; continue;
 
84
  }
85
  if (c === '/' && n === '*') {
86
  let j = i + 2;
 
79
  }
80
  if (c === '"' || c === "'") { q = c; out += c; continue; }
81
  if (c === '/' && n === '/') {
82
+ // v0.7.1 bugfix: this handler used to emit its own '\n' AND rewind so
83
+ // the main loop emitted the same newline again — every // comment
84
+ // doubled a line, and every line number after the first comment
85
+ // drifted +1 (diagnostics pointed one line late). The newline is now
86
+ // emitted exactly once by the main loop.
87
  while (i < src.length && src[i] !== '\n') i++;
88
+ if (i < src.length) i--; // reprocess the newline once, below
89
+ continue;
90
  }
91
  if (c === '/' && n === '*') {
92
  let j = i + 2;
public/dssl/resolver.js CHANGED
@@ -26,7 +26,9 @@ import { PSEUDO_ELEMENTS, VENDOR_PSEUDO } from './parser.js';
26
  * Vocabulary
27
  * ------------------------------------------------------------------ */
28
 
29
- const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,auto,currentcolor,transparent,' +
 
 
30
  'black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
31
  'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' +
32
  'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' +
 
26
  * Vocabulary
27
  * ------------------------------------------------------------------ */
28
 
29
+ // exported for the editor extension: completions ride the same list the
30
+ // compiler validates against, so the two can never drift apart
31
+ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,auto,currentcolor,transparent,' +
32
  'black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
33
  'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' +
34
  'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' +
public/llms.txt ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DSSL
2
+
3
+ > DSSL is a style language for the web that replaces CSS. You write `.dssl` files, the page loads a ~20 kB engine, and the engine walks the DOM in real time applying every matched rule as inline styles. Nothing compiles to CSS. Elements added later are styled the moment they appear; signals, states and motion share one grammar.
4
+
5
+ - Site (docs, playground, changelog, bench): https://dssl-sable.vercel.app
6
+ - Repo + full docs in the README: https://huggingface.co/EHS-Studio/dssl
7
+ - Install: `curl -fsSL https://dssl-sable.vercel.app/install.sh | sh` (or the zip: https://dssl-sable.vercel.app/dssl-engine.zip)
8
+ - Version: 0.7.1 (engine), MIT license
9
+ - Feature matrix vs CSS: see PARITY.md in the repo
10
+ - For agents working ON the codebase: see AGENTS.md in the repo
11
+
12
+ ## The five things reviewers ask about
13
+
14
+ 1. No build step. Styles are source files linked with `<link rel="stylesheet/dssl">`. The engine auto-boots and restyles the live DOM, including nodes added later.
15
+ 2. Honest trade: it is a JS runtime. If JavaScript is unavailable the page renders unstyled. For content pages that must not happen, use prerendering (snapshot the engine's first pass to static HTML; planned, tracked in PARITY.md) or choose another tool. The engine's own docs say this plainly.
16
+ 3. Exit cost is zero. Abandoning DSSL leaves you a page of valid inline styles and one small shadow stylesheet for pseudo-elements. There is nothing to un-install from your markup.
17
+ 4. Speed has a handbrake. `DSSL.freeze()` detaches every observer and listener after first paint and leaves the resolved look at zero engine cost; `unfreeze()` re-arms. A gate prefilter skips obvious non-matches before native matching. The Bench page on the site measures 100 / 1,000 / 10,000 nodes on your machine: CSS `<style>` vs DSSL live vs DSSL frozen.
18
+ 5. The cascade is (layer, source order). No specificity, no !important. `pin { }` is the only must-win block. The compiler refuses to guess: unknown tokens, properties and values fail loudly with did-you-mean; `--*` custom properties pass through unvalidated on purpose.
19
+
20
+ ## Language surface (v0.7.1)
21
+
22
+ - Selectors: the full CSS surface rides native matching, including `:nth-child()`, `:not()`, `:is()`, `:where()`, `:has()`, combinators, attributes, nesting with `&`.
23
+ - States as nested blocks: `hover`, `focus`, `focus-visible`, `active`, `disabled`, `checked`, `empty`, `target`, plus `seen` (fires once on first viewport entry, composes with `stagger:`).
24
+ - Tokens built in: `color.*`, `space.1-8`, `radius.*`, `shadow.*`, `font.*`; custom `tokens { }` blocks with numeric keys (`space { 9: 96 }`), alpha suffixes (`color.ink/85`), per-subtree re-declaration (`--color.accent: color.err`).
25
+ - Layout sugar: `row: gap space.4 between center`, `column:`, `grid: 3 gap 12`, `stack: center`, `center: both` (promotes a formatting context so alignment can never silently no-op), `size: 40 20`.
26
+ - Pseudo-elements (`::before`, `::after`, `::placeholder`, `::selection`, `::marker`, `::backdrop`, `::first-line`, `::first-letter`, vendor ones) compile to one real shadow stylesheet; unknown pseudo names are compile errors.
27
+ - Conditions: `@media` (with `dark`, `light`, `reduced-motion` sugar and `<=` ranges), `@container`, `@signal menuOpen { }` bridged from `DSSL.signal()`, `@layer base|components|utilities`, `pin { }`.
28
+ - Motion: `motion name { from { } to { } }` via WAAPI; `animate: pulse 1600 infinite alternate`; `stagger: 70`; reduced-motion respected unless a motion declares `essential`.
29
+ - Deform family: `bend`, `bend-y`, `bend-up`, `fold`, `stretch` (volume-honest squash), `bend-around` (children ride an arc). Compose into the one transform channel, state- and transition-friendly.
30
+ - Gradients as sentences: `gradient: linear 135, color.accent -> color.ok`, `gradient-text:`, `gradient-flow: 6s alternate`. Non-color stops are compile errors.
31
+ - Overlap policy: nothing below 100% opacity overlaps anything unless `overlap: allow`; `deny` is unconditional; pairs with `pointer-events: none` so veils stop eating clicks and text selection.
32
+ - Behaviors: named rule groups applied with `use focus-ring`.
33
+ - Warnings channel (v0.7): things that compile but are probably wrong, e.g. a layout shorthand expanding to `display` and clobbering an earlier explicit `display:` in the same rule.
34
+
35
+ ## JS API
36
+
37
+ ```js
38
+ DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
39
+ DSSL.apply(src, { scope, name }) // scoped engines for widgets
40
+ DSSL.compile(src) // parse + resolve, no DOM (powers editors)
41
+ DSSL.signal('menuOpen', true) // the JS -> styles bridge
42
+ DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
43
+ DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
44
+ DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
45
+ ```
46
+
47
+ Wait for the `dssl-ready` event on `window` before measuring layout: the engine styles the DOM after DOMContentLoaded, and scripts that measure earlier read a half-styled world.
48
+
49
+ ## Editor support
50
+
51
+ The official extension (VS Code and VSCodium, same .vsix) bundles the real parser and resolver: diagnostics identical to the browser console, completions from the workspace token registry, hover docs for the semantic vocabulary. Source lives in `dssl-vscode/` in the repo.
public/styles/bench.dssl ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // bench.dssl — the benchmark view. The page measures the engine; the engine
2
+ // styles the page. tabular numbers come from the JS side (canvas of fonts
3
+ // varies), everything else lives here.
4
+
5
+ .bench-wrap {
6
+ max-width: 880
7
+ margin: 0 auto
8
+ }
9
+
10
+ .bench-title {
11
+ font-size: 30
12
+ font-weight: bold
13
+ }
14
+
15
+ .bench-sub {
16
+ color: color.muted
17
+ margin: 8 0 0
18
+ }
19
+
20
+ .bench-controls {
21
+ row: gap 10 wrap
22
+ margin: 24 0
23
+ }
24
+
25
+ .bench-btn {
26
+ padding: 10 18
27
+ radius: md
28
+ background: color.card
29
+ border: 1 solid color.line
30
+ color: color.ink
31
+ cursor: pointer
32
+ transition: 150
33
+ font-size: 14
34
+
35
+ hover {
36
+ background: color.accent/10
37
+ border-color: color.accent
38
+ translate: 0 -1
39
+ }
40
+ active { translate: 0 0 }
41
+ }
42
+
43
+ .bench-btn.on {
44
+ background: color.accent/16
45
+ border-color: color.accent
46
+ }
47
+
48
+ .bench-btn:disabled {
49
+ opacity: 0.55
50
+ cursor: default
51
+ hover {
52
+ background: color.card
53
+ border-color: color.line
54
+ translate: 0 0
55
+ }
56
+ }
57
+
58
+ .bench-table {
59
+ width: 100%
60
+ border-collapse: collapse
61
+ margin: 12 0
62
+ }
63
+
64
+ .bench-table th {
65
+ text-align: left
66
+ padding: 8 10
67
+ border-bottom: 2 solid color.line
68
+ font-size: 12
69
+ color: color.muted
70
+ text-transform: uppercase
71
+ }
72
+
73
+ .bench-table td {
74
+ padding: 8 10
75
+ border-bottom: 1 solid color.line
76
+ font-size: 14
77
+ }
78
+
79
+ .bench-table td.num {
80
+ text-align: right
81
+ }
82
+
83
+ .bench-lane-css { color: color.muted }
84
+ .bench-lane-live { }
85
+ .bench-lane-frozen { }
86
+
87
+ .bench-stage {
88
+ height: 300
89
+ overflow: hidden
90
+ border: 1 solid color.line
91
+ radius: md
92
+ margin: 12 0
93
+ position: relative
94
+ }
95
+
96
+ .bench-stage:empty { display: none }
97
+
98
+ .bench-stage-item {
99
+ padding: 5 10
100
+ border-bottom: 1 solid color.line
101
+ background: color.card
102
+ color: color.ink
103
+ font-size: 13
104
+ }
105
+
106
+ .bench-note {
107
+ font-size: 13
108
+ color: color.muted
109
+ margin: 6 0
110
+ }
111
+
112
+ .bench-freeze-stats {
113
+ font-size: 13
114
+ color: color.ok
115
+ margin: 4 0
116
+ }
117
+
118
+ .bench-err {
119
+ color: color.err
120
+ font-size: 13
121
+ }
122
+
123
+ @media (max-width: 700) {
124
+ .bench-table th, .bench-table td { padding: 6 6; font-size: 12 }
125
+ }
scripts/sync-extension-engine.mjs ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* sync-extension-engine.mjs — copy the real compiler into the editor
3
+ * extension so diagnostics in the editor are the SAME errors the browser
4
+ * shows. Run after any engine change, before packaging the .vsix. */
5
+ import { copyFileSync, readFileSync } from 'node:fs';
6
+ import { createHash } from 'node:crypto';
7
+ import { fileURLToPath } from 'node:url';
8
+ import path from 'node:path';
9
+
10
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+ const files = ['parser.js', 'resolver.js', 'utils.js'];
12
+
13
+ for (const f of files) {
14
+ const src = path.join(root, 'public', 'dssl', f);
15
+ const dst = path.join(root, 'dssl-vscode', 'lib', 'engine', f);
16
+ copyFileSync(src, dst);
17
+ const hash = (p) => createHash('sha256').update(readFileSync(p)).digest('hex').slice(0, 12);
18
+ const ok = hash(src) === hash(dst);
19
+ console.log(`${ok ? 'synced' : 'MISMATCH'} ${f}`);
20
+ }
21
+ console.log('engine copies now identical to public/dssl/');
scripts/test-v071.mjs ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /* test-v071.mjs — v0.7.1 regression: comment line numbers.
3
+ * The extension's test suite caught it: every `//` comment doubled a newline
4
+ * in the stripped source, so every line number after the first comment
5
+ * drifted +1 and diagnostics pointed one line late. Block comments were
6
+ * fine; line comments were lying. These tests pin the fix. */
7
+ import { parse } from '../public/dssl/parser.js';
8
+ import { resolve } from '../public/dssl/resolver.js';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ let passed = 0, failed = 0;
14
+ const ok = (cond, name) => {
15
+ if (cond) { passed++; console.log(` ok ${name}`); }
16
+ else { failed++; console.log(`FAIL ${name}`); }
17
+ };
18
+
19
+ console.log('1 — comment placements keep true line numbers');
20
+ {
21
+ const cases = [
22
+ ['leading', '// c\n.a {\n x: 1\n}', 3],
23
+ ['inner', '.a {\n // c\n x: 1\n}', 3],
24
+ ['trailing', '.a {\n x: 1 // c\n}', 2],
25
+ ['block leading', '/* c */\n.a {\n x: 1\n}', 3],
26
+ ['block inner', '.a {\n /* c */\n x: 1\n}', 3],
27
+ ['many comments', '// one\n// two\n.a {\n // three\n x: 1 // four\n}', 5],
28
+ ['comment at eof no newline', '.a {\n x: 1\n}\n// tail', null],
29
+ ['crlf', '// c\r\n.a {\r\n x: 1\r\n}', 3],
30
+ ];
31
+ for (const [name, src, want] of cases) {
32
+ const { ast, errors } = parse(src, 't.dssl');
33
+ const rule = ast.children.find((n) => n.kind === 'selector');
34
+ const got = rule ? rule.children[0].line : null;
35
+ const bad = errors.filter((e) => !/missing|not a valid|expected/.test(e.message));
36
+ ok((want === null || got === want) && bad.length === 0, `${name}: decl line ${got}${want === null ? '' : ' (want ' + want + ')'}`);
37
+ }
38
+ }
39
+
40
+ console.log('2 — errors and warnings carry honest lines after comments');
41
+ {
42
+ const src = '// header comment\n.nav-links {\n display: none\n column: gap 2\n}\n.bad {\n background: colr.accent\n}\n';
43
+ // lines 1-based: display=3, column=4, colr=7
44
+ const { ast } = parse(src, 't.dssl');
45
+ const doc = resolve(ast, 't.dssl', {});
46
+ const warn = doc.warnings.find((w) => w.message.includes('column'));
47
+ ok(warn && warn.line === 4, `ordering warning fires on the column: line (got ${warn && warn.line}, want 4)`);
48
+ const err = doc.errors.find((e) => e.message.includes('colr'));
49
+ ok(err && err.line === 7, `misspelled token error on its own line (got ${err && err.line}, want 7)`);
50
+ }
51
+
52
+ console.log('3 — the real site stylesheets: spot-check line honesty');
53
+ {
54
+ // parse a real stylesheet that opens with a comment block and verify a
55
+ // known rule lands where it lives on disk
56
+ import('node:fs').then(({ readFileSync }) => {
57
+ try {
58
+ const p = path.join(here, '..', 'public', 'styles', 'tokens.dssl');
59
+ const src = readFileSync(p, 'utf8');
60
+ const lines = src.split('\n');
61
+ // find a real declaration line with a known shape
62
+ const idx = lines.findIndex((l) => /--dssl-color-ink/.test(l));
63
+ if (idx >= 0) {
64
+ const { ast } = parse(src, 'tokens.dssl');
65
+ let found = null;
66
+ const walk = (n) => {
67
+ if (found) return;
68
+ for (const c of n.children || []) {
69
+ if (c.kind === 'decl' && /ink/.test(c.prop || '')) { found = c; return; }
70
+ walk(c);
71
+ }
72
+ };
73
+ walk(ast);
74
+ ok(found && found.line === idx + 1, `tokens.dssl decl on disk line ${idx + 1}, parser says ${found && found.line}`);
75
+ } else {
76
+ ok(true, 'tokens.dssl shape changed; spot-check skipped');
77
+ }
78
+ } catch (e) {
79
+ ok(false, 'site stylesheet spot-check threw: ' + e.message);
80
+ }
81
+ finish();
82
+ });
83
+ }
84
+
85
+ function finish() {
86
+ console.log(`\n${passed} passed, ${failed} failed`);
87
+ process.exit(failed ? 1 : 0);
88
+ }
src/app/layout.tsx CHANGED
@@ -28,7 +28,7 @@ const prePaint = `
28
  // executes (right after parse, before first paint), so there is no fetch
29
  // waterfall and no wait for React hydration. Server-side read also means a
30
  // browser refresh picks up .dssl edits instantly in dev.
31
- const DSSL_SHEETS = ["tokens", "site", "home", "docs", "playground", "changelog"] as const;
32
 
33
  function dsslInlineStyles(): { name: string; src: string }[] {
34
  return DSSL_SHEETS.map((name) => {
 
28
  // executes (right after parse, before first paint), so there is no fetch
29
  // waterfall and no wait for React hydration. Server-side read also means a
30
  // browser refresh picks up .dssl edits instantly in dev.
31
+ const DSSL_SHEETS = ["tokens", "site", "home", "docs", "playground", "changelog", "bench"] as const;
32
 
33
  function dsslInlineStyles(): { name: string; src: string }[] {
34
  return DSSL_SHEETS.map((name) => {
src/components/dssl/SiteRoot.tsx CHANGED
@@ -7,9 +7,10 @@ import CheatSheetView from "./views/CheatSheetView";
7
  import SpecView from "./views/SpecView";
8
  import PlaygroundView from "./views/PlaygroundView";
9
  import ChangelogView from "./views/ChangelogView";
 
10
  import { SunIcon, MoonIcon, MenuIcon } from "./icons";
11
 
12
- export type ViewName = "home" | "docs" | "cheat" | "spec" | "playground" | "changelog";
13
 
14
  const VIEWS: { name: ViewName; label: string }[] = [
15
  { name: "home", label: "Home" },
@@ -18,6 +19,7 @@ const VIEWS: { name: ViewName; label: string }[] = [
18
  { name: "spec", label: "Spec" },
19
  { name: "playground", label: "Playground" },
20
  { name: "changelog", label: "Changelog" },
 
21
  ];
22
 
23
  function viewFromHash(): ViewName {
@@ -120,7 +122,7 @@ export default function SiteRoot() {
120
  </nav>
121
 
122
  <div className="nav-side">
123
- <a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.7.0</a>
124
  <button type="button" className="theme-btn" onClick={cycleTheme} aria-label="Toggle color theme" title="Theme: auto / light / dark">
125
  <span className="icon-sun"><SunIcon /></span>
126
  <span className="icon-moon"><MoonIcon /></span>
@@ -142,13 +144,14 @@ export default function SiteRoot() {
142
  {view === "spec" && <SpecView />}
143
  {view === "playground" && <PlaygroundView />}
144
  {view === "changelog" && <ChangelogView />}
 
145
  </main>
146
 
147
  <footer className="footer">
148
  <div className="wrap footer-inner">
149
  <div className="footer-col">
150
  <div className="footer-note">
151
- <strong>DSSL</strong> — domain specific style language. Built v0.1 → v0.7.0 while
152
  building this site; every feature it needed, it grew, and every bug you sent got
153
  fixed for real. This page runs on its own engine, no CSS files attached —
154
  the engine styles this DOM directly.
 
7
  import SpecView from "./views/SpecView";
8
  import PlaygroundView from "./views/PlaygroundView";
9
  import ChangelogView from "./views/ChangelogView";
10
+ import BenchView from "./views/BenchView";
11
  import { SunIcon, MoonIcon, MenuIcon } from "./icons";
12
 
13
+ export type ViewName = "home" | "docs" | "cheat" | "spec" | "playground" | "changelog" | "bench";
14
 
15
  const VIEWS: { name: ViewName; label: string }[] = [
16
  { name: "home", label: "Home" },
 
19
  { name: "spec", label: "Spec" },
20
  { name: "playground", label: "Playground" },
21
  { name: "changelog", label: "Changelog" },
22
+ { name: "bench", label: "Bench" },
23
  ];
24
 
25
  function viewFromHash(): ViewName {
 
122
  </nav>
123
 
124
  <div className="nav-side">
125
+ <a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.7.1</a>
126
  <button type="button" className="theme-btn" onClick={cycleTheme} aria-label="Toggle color theme" title="Theme: auto / light / dark">
127
  <span className="icon-sun"><SunIcon /></span>
128
  <span className="icon-moon"><MoonIcon /></span>
 
144
  {view === "spec" && <SpecView />}
145
  {view === "playground" && <PlaygroundView />}
146
  {view === "changelog" && <ChangelogView />}
147
+ {view === "bench" && <BenchView />}
148
  </main>
149
 
150
  <footer className="footer">
151
  <div className="wrap footer-inner">
152
  <div className="footer-col">
153
  <div className="footer-note">
154
+ <strong>DSSL</strong> — domain specific style language. Built v0.1 → v0.7.1 while
155
  building this site; every feature it needed, it grew, and every bug you sent got
156
  fixed for real. This page runs on its own engine, no CSS files attached —
157
  the engine styles this DOM directly.
src/components/dssl/views/BenchView.tsx ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ /* The benchmark measures the engine while the engine styles the page.
6
+ * Three lanes, one question each:
7
+ * CSS — a real <style> tag, the platform baseline
8
+ * DSSL — the live engine, ambient reactivity on
9
+ * DSSL ❄ — the engine frozen right after first paint (v0.7 freeze())
10
+ * Metrics: first paint (build + full style application, median of 3) and
11
+ * restyle-all (one knob flipped, every node changes color).
12
+ * Honest by construction: synthetic nodes, one machine, your browser.
13
+ * The frozen lane's restyle is an explicit developer action (signal), not
14
+ * ambient churn — that is the whole point of freeze, and the note says so. */
15
+
16
+ declare global {
17
+ interface Window {
18
+ DSSL?: any;
19
+ }
20
+ }
21
+
22
+ const BENCH_SRC = `
23
+ .bench-stage-item {
24
+ padding: 5 10
25
+ border-bottom: 1 solid color.line
26
+ background: color.card
27
+ color: color.ink
28
+ font-size: 13
29
+ }
30
+ @signal benchStorm {
31
+ .bench-stage-item { background: #dbe7ff; color: #14243f }
32
+ }
33
+ `;
34
+
35
+ const CSS_SRC = `
36
+ .bench-stage-item { padding: 5px 10px; border-bottom: 1px solid var(--dssl-color-line); background: var(--dssl-color-card); color: var(--dssl-color-ink); font-size: 13px; }
37
+ .bench-stage.on .bench-stage-item { background: #dbe7ff; color: #14243f; }
38
+ `;
39
+
40
+ const SIZES = [100, 1000, 10000];
41
+ const LANES = [
42
+ { id: "css", label: "CSS <style>" },
43
+ { id: "live", label: "DSSL live" },
44
+ { id: "frozen", label: "DSSL frozen" },
45
+ ] as const;
46
+
47
+ type LaneId = (typeof LANES)[number]["id"];
48
+ type Row = { count: number; lane: LaneId; paint: number; restyle: number };
49
+
50
+ const settle = () =>
51
+ new Promise<void>((r) =>
52
+ requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 20)))
53
+ );
54
+
55
+ function buildNodes(host: HTMLElement, count: number) {
56
+ let html = "";
57
+ for (let i = 0; i < count; i++) html += `<div class="bench-stage-item">row ${i}</div>`;
58
+ host.innerHTML = html;
59
+ }
60
+
61
+ const median = (xs: number[]) => {
62
+ const s = [...xs].sort((a, b) => a - b);
63
+ return s[Math.floor(s.length / 2)];
64
+ };
65
+
66
+ export default function BenchView() {
67
+ const stageRef = useRef<HTMLDivElement | null>(null);
68
+ const styleRef = useRef<HTMLStyleElement | null>(null);
69
+ const [rows, setRows] = useState<Row[]>([]);
70
+ const [busy, setBusy] = useState<string | null>(null);
71
+ const [freezeStats, setFreezeStats] = useState<string | null>(null);
72
+ const [err, setErr] = useState<string | null>(null);
73
+
74
+ useEffect(() => {
75
+ return () => {
76
+ // leave the site exactly as we found it
77
+ if (styleRef.current) styleRef.current.remove();
78
+ if (stageRef.current) stageRef.current.innerHTML = "";
79
+ window.DSSL?.signal?.("benchStorm", null);
80
+ };
81
+ }, []);
82
+
83
+ /* The settle wait itself costs ~2 frames; every raw measurement includes
84
+ * that floor. So each lane gets a floor run on an EMPTY host with the
85
+ * exact same flow, and the reported number is raw minus floor: work only. */
86
+ const floorFor = useCallback(async (lane: LaneId) => {
87
+ const D = window.DSSL;
88
+ if (!D || !stageRef.current) return { paint: 0, restyle: 0 };
89
+ const host = stageRef.current;
90
+ const paints: number[] = [];
91
+ const restyles: number[] = [];
92
+ for (let rep = 0; rep < 3; rep++) {
93
+ host.innerHTML = "";
94
+ host.classList.remove("on");
95
+ D.signal("benchStorm", null);
96
+ await settle();
97
+
98
+ let t0 = performance.now();
99
+ if (lane === "css") {
100
+ const st = document.createElement("style");
101
+ document.head.appendChild(st);
102
+ styleRef.current = st;
103
+ await settle();
104
+ st.remove();
105
+ styleRef.current = null;
106
+ } else {
107
+ D.apply("", { name: "bench-floor", scope: host, replace: true });
108
+ await settle();
109
+ }
110
+ paints.push(performance.now() - t0);
111
+
112
+ t0 = performance.now();
113
+ if (lane === "css") {
114
+ host.classList.add("on");
115
+ await settle();
116
+ host.classList.remove("on");
117
+ } else {
118
+ D.signal("benchStorm", "on");
119
+ await settle();
120
+ D.signal("benchStorm", null);
121
+ }
122
+ restyles.push(performance.now() - t0);
123
+ await settle();
124
+ }
125
+ return { paint: median(paints), restyle: median(restyles) };
126
+ }, []);
127
+
128
+ const runConfig = useCallback(async (count: number, lane: LaneId) => {
129
+ const D = window.DSSL;
130
+ if (!D || !stageRef.current) return;
131
+ const host = stageRef.current;
132
+ const paints: number[] = [];
133
+ const restyles: number[] = [];
134
+
135
+ for (let rep = 0; rep < 3; rep++) {
136
+ host.innerHTML = "";
137
+ host.classList.remove("on");
138
+ D.signal("benchStorm", null);
139
+ await settle();
140
+
141
+ if (lane === "css") {
142
+ if (styleRef.current) styleRef.current.remove();
143
+ let t0 = performance.now();
144
+ const st = document.createElement("style");
145
+ st.textContent = CSS_SRC;
146
+ styleRef.current = st;
147
+ document.head.appendChild(st);
148
+ buildNodes(host, count);
149
+ await settle();
150
+ paints.push(performance.now() - t0);
151
+
152
+ t0 = performance.now();
153
+ host.classList.add("on");
154
+ await settle();
155
+ restyles.push(performance.now() - t0);
156
+ host.classList.remove("on");
157
+ st.remove();
158
+ styleRef.current = null;
159
+ } else {
160
+ let t0 = performance.now();
161
+ buildNodes(host, count);
162
+ D.apply(BENCH_SRC, { name: "bench-live", scope: host, replace: true });
163
+ await settle();
164
+ paints.push(performance.now() - t0);
165
+
166
+ if (lane === "frozen") {
167
+ const stats = D.freeze();
168
+ const s = Array.isArray(stats) ? stats.find((x: any) => x.engine !== "global") || stats[0] : stats;
169
+ setFreezeStats(
170
+ `freeze() → ${s.elements} elements, ${s.rules} rules, ${s.shadowRules} shadow rules — ambient cost now zero`
171
+ );
172
+ }
173
+
174
+ t0 = performance.now();
175
+ D.signal("benchStorm", "on");
176
+ await settle();
177
+ restyles.push(performance.now() - t0);
178
+ D.signal("benchStorm", null);
179
+
180
+ if (lane === "frozen") D.unfreeze();
181
+ }
182
+ await settle();
183
+ }
184
+
185
+ const floor = await floorFor(lane);
186
+ const net = (xs: number[], f: number) => Math.max(0.05, median(xs) - f);
187
+ return { paint: net(paints, floor.paint), restyle: net(restyles, floor.restyle) };
188
+ }, [floorFor]);
189
+
190
+ const run = useCallback(
191
+ async (count: number | "all") => {
192
+ const D = window.DSSL;
193
+ if (!D) {
194
+ setErr("engine not booted yet (no window.DSSL) — wait for dssl-ready and retry");
195
+ return;
196
+ }
197
+ setErr(null);
198
+ const counts = count === "all" ? SIZES : [count];
199
+ const tag = count === "all" ? "all" : String(count);
200
+ setBusy(tag);
201
+ try {
202
+ for (const c of counts) {
203
+ for (const lane of LANES) {
204
+ const m = await runConfig(c, lane.id);
205
+ if (m) {
206
+ const row: Row = { count: c, lane: lane.id, paint: m.paint, restyle: m.restyle };
207
+ // merge: replace this (count, lane), keep every other size's rows
208
+ setRows((prev) => [...prev.filter((r) => !(r.count === c && r.lane === lane.id)), row]);
209
+ }
210
+ }
211
+ }
212
+ } catch (e: any) {
213
+ setErr(e?.message || String(e));
214
+ } finally {
215
+ setBusy(null);
216
+ // full cleanup after every run
217
+ if (stageRef.current) stageRef.current.innerHTML = "";
218
+ D.signal("benchStorm", null);
219
+ await settle();
220
+ }
221
+ },
222
+ [runConfig]
223
+ );
224
+
225
+ const cell = (count: number, lane: LaneId, key: "paint" | "restyle") => {
226
+ const r = rows.find((x) => x.count === count && x.lane === lane);
227
+ if (!r) return "—";
228
+ return r[key] >= 100 ? Math.round(r[key]).toLocaleString() : r[key].toFixed(1);
229
+ };
230
+
231
+ return (
232
+ <div className="wrap">
233
+ <div className="bench-wrap">
234
+ <h2 className="bench-title">The bench</h2>
235
+ <p className="bench-sub">
236
+ Synthetic nodes styled three ways on this page, on this machine, in this browser.
237
+ Median of 3 runs. The point is not to win &mdash; it is to stop arguing from priors.
238
+ </p>
239
+
240
+ <div className="bench-controls">
241
+ {SIZES.map((s) => (
242
+ <button
243
+ key={s}
244
+ type="button"
245
+ className={"bench-btn" + (busy === String(s) ? " on" : "")}
246
+ disabled={!!busy}
247
+ onClick={() => run(s)}
248
+ >
249
+ {busy === String(s) ? "running…" : `${s.toLocaleString()} nodes`}
250
+ </button>
251
+ ))}
252
+ <button
253
+ type="button"
254
+ className={"bench-btn" + (busy === "all" ? " on" : "")}
255
+ disabled={!!busy}
256
+ onClick={() => run("all")}
257
+ >
258
+ {busy === "all" ? "running…" : "Run the full sweep"}
259
+ </button>
260
+ </div>
261
+
262
+ {freezeStats && <div className="bench-freeze-stats">{freezeStats}</div>}
263
+ {err && <div className="bench-err">{err}</div>}
264
+
265
+ <table className="bench-table">
266
+ <thead>
267
+ <tr>
268
+ <th>nodes</th>
269
+ <th>lane</th>
270
+ <th className="num">first paint (ms)</th>
271
+ <th className="num">restyle all (ms)</th>
272
+ </tr>
273
+ </thead>
274
+ <tbody>
275
+ {SIZES.flatMap((c) =>
276
+ LANES.map((l) => (
277
+ <tr key={`${c}-${l.id}`}>
278
+ <td>{c.toLocaleString()}</td>
279
+ <td className={"bench-lane-" + l.id}>{l.label}</td>
280
+ <td className="num">{cell(c, l.id, "paint")}</td>
281
+ <td className="num">{cell(c, l.id, "restyle")}</td>
282
+ </tr>
283
+ ))
284
+ )}
285
+ </tbody>
286
+ </table>
287
+
288
+ <p className="bench-note">
289
+ <strong>How to read this.</strong> First paint = build every node AND fully apply its
290
+ styling (CSS injects a stylesheet, DSSL runs its first matching pass). Restyle all =
291
+ flip one knob so every node changes color: for CSS a container class, for DSSL the
292
+ signal bridge. The frozen lane restyles through an explicit signal call &mdash; a
293
+ developer action, by design &mdash; while its ambient cost (scroll, hover bookkeeping,
294
+ mutations) is exactly zero after freeze(), which the green line above reports.
295
+ Numbers are floor-subtracted: each lane is also measured on an empty host with the
296
+ same flow, and that wait-only floor comes off every figure, so what you see is work.
297
+ </p>
298
+ <p className="bench-note">
299
+ A frozen DSSL page keeps its resolved inline styles forever; unfreeze() re-arms
300
+ everything. Live DSSL buys back that cost in what it can do: tokens, signals and
301
+ states without a build step. Pick the lane per page, not per project.
302
+ </p>
303
+ </div>
304
+
305
+ {/* the measured sandbox; nothing here is ever visible-height rendered */}
306
+ <div ref={stageRef} className="bench-stage" aria-hidden="true" />
307
+ </div>
308
+ );
309
+ }
src/components/dssl/views/ChangelogView.tsx CHANGED
@@ -11,6 +11,26 @@ const ENTRIES: {
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  {
15
  version: "v0.7.0",
16
  date: "growth phase · the engine gets a handbrake",
 
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
14
+ {
15
+ version: "v0.7.1",
16
+ date: "growth phase · the credibility drop",
17
+ title: "The language moves into the editor, the bench goes public, and a reviewer bug dies",
18
+ why: "several AI agents tested DSSL and reached the same verdict — good for prototypes — citing v0.x, JS-only, inline-style cost and a one-person ecosystem. This release answers all four without pivoting the architecture",
19
+ kind: "add",
20
+ body:
21
+ "The verdict said prototype, so this release aims straight at the verdict. The official editor extension arrived, and it is not a loose re-implementation: it bundles the real parser and resolver, so the squiggles in your editor are the same errors the browser console prints, including the did-you-mean and the new warnings. Completions ride the live token registry folded across the workspace (type color. and get your palette with resolved values), hovers explain the semantic vocabulary, and one grammar serves both VS Code and VSCodium because they share the same extension API — the .vsix installs in either, two clicks. The extension immediately paid for itself: its test suite caught a parser bug where every // comment doubled a line in the stripped source, so all line numbers after the first comment drifted +1 and every diagnostic — editor squiggle AND browser console — pointed one line late. Fixed, regression-tested, and honestly a little embarrassing. The repo gained the machine-reviewer layer: llms.txt at the site root and in the repo (dense, factual, structured for the agents that keep grading this project), AGENTS.md for contributors with a motor in their chest, and PARITY.md, the honest feature matrix against CSS. And the argument that used to happen from priors now happens from numbers: the Bench page runs 100 / 1,000 / 10,000 synthetic nodes three ways on this very page — a plain CSS stylesheet, the live engine, and the frozen engine — and reports first paint and restyle-all as medians of three. No build step appeared anywhere. The engine stays the engine.",
22
+ code: `// in your editor now, same compiler the browser runs
23
+ .bad { background: colr.accent }
24
+ // ^ unknown token "colr" (did you mean "color.accent"?) — as a squiggle
25
+
26
+ // completions come from YOUR registry, folded across the workspace
27
+ tokens { space { 9: 96 } }
28
+ .hero { padding: space.9 } // typing space. offers 9 = 96
29
+
30
+ // the bench, on this page, median of 3 — numbers come from YOUR machine
31
+ 10,000 nodes · CSS <style> · DSSL live · DSSL frozen
32
+ // first paint + restyle-all, per lane, per size`,
33
+ },
34
  {
35
  version: "v0.7.0",
36
  date: "growth phase · the engine gets a handbrake",
src/components/dssl/views/SpecView.tsx CHANGED
@@ -11,7 +11,7 @@ export default function SpecView() {
11
  <p className="page-sub">
12
  The v0.1 draft was put to a seven-point vote. This document is the ratified
13
  v0.2 result — every decision below is implemented by the engine serving this
14
- page. The engine itself has kept growing since: it is now at v0.7.0 (see the
15
  changelog for what the site demanded).
16
  </p>
17
  </div>
 
11
  <p className="page-sub">
12
  The v0.1 draft was put to a seven-point vote. This document is the ratified
13
  v0.2 result — every decision below is implemented by the engine serving this
14
+ page. The engine itself has kept growing since: it is now at v0.7.1 (see the
15
  changelog for what the site demanded).
16
  </p>
17
  </div>