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 +52 -0
- PARITY.md +50 -0
- README.md +10 -2
- dssl-vscode/.vscodeignore +6 -0
- dssl-vscode/LICENSE +21 -0
- dssl-vscode/README.md +46 -0
- dssl-vscode/extension.js +116 -0
- dssl-vscode/language-configuration.json +32 -0
- dssl-vscode/lib/analysis.js +251 -0
- dssl-vscode/lib/engine/parser.js +262 -0
- dssl-vscode/lib/engine/resolver.js +1367 -0
- dssl-vscode/lib/engine/utils.js +102 -0
- dssl-vscode/lib/props-docs.js +117 -0
- dssl-vscode/package.json +67 -0
- dssl-vscode/syntaxes/dssl.tmLanguage.json +189 -0
- public/dssl-engine.tar.gz +0 -0
- public/dssl-engine.zip +0 -0
- public/dssl/index.js +1 -1
- public/dssl/parser.js +7 -1
- public/dssl/resolver.js +3 -1
- public/llms.txt +51 -0
- public/styles/bench.dssl +125 -0
- scripts/sync-extension-engine.mjs +21 -0
- scripts/test-v071.mjs +88 -0
- src/app/layout.tsx +1 -1
- src/components/dssl/SiteRoot.tsx +6 -3
- src/components/dssl/views/BenchView.tsx +309 -0
- src/components/dssl/views/ChangelogView.tsx +20 -0
- src/components/dssl/views/SpecView.tsx +1 -1
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.
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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 — 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 — a
|
| 293 |
+
developer action, by design — 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.
|
| 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>
|