Z User commited on
Commit Β·
c3fdf10
1
Parent(s): b468333
v0.8.0 the exit drop: row/column axis roles (between center bug), unknown at-rules skip balanced (@font-face crash, @supports leak), url() atomic, !important loud, animation/transition teaching errors; raw { } escape hatch; dssl CLI (check + build) + DSSL.toCSS static emitter; headless-Chromium docs tests; fluid sugar; engine packages + 0.8.0 vsix (engine verified byte-identical inside)
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .gitignore +3 -0
- .vercelignore +16 -0
- AGENTS.md +0 -0
- Caddyfile +23 -0
- GUIDE.md +76 -9
- PARITY.md +40 -2
- README.md +37 -10
- cli/dssl.mjs +175 -0
- dssl-vscode/.vscodeignore +0 -0
- dssl-vscode/LICENSE +0 -0
- dssl-vscode/PUBLISHING.md +0 -0
- dssl-vscode/README.md +0 -0
- dssl-vscode/{dssl-vscode-0.7.2.vsix β dssl-vscode-0.8.0.vsix} +0 -0
- dssl-vscode/extension.js +0 -0
- dssl-vscode/icon.png +0 -0
- dssl-vscode/language-configuration.json +0 -0
- dssl-vscode/lib/analysis.js +0 -0
- dssl-vscode/lib/engine/parser.js +128 -5
- dssl-vscode/lib/engine/resolver.js +293 -44
- dssl-vscode/lib/props-docs.js +0 -0
- dssl-vscode/package.json +1 -1
- dssl-vscode/syntaxes/dssl.tmLanguage.json +0 -0
- engine-package/USAGE.md +119 -0
- engine-package/hello.dssl +39 -0
- engine-package/install.sh +19 -0
- examples/websocket/frontend.tsx +196 -0
- examples/websocket/server.ts +138 -0
- llms.txt +19 -6
- public/dssl-engine.tar.gz +0 -0
- public/dssl-engine.zip +0 -0
- public/dssl/engine.js +10 -0
- public/dssl/index.js +15 -2
- public/dssl/parser.js +90 -6
- public/dssl/resolver.js +189 -23
- public/dssl/static.js +133 -0
- public/llms.txt +22 -8
- public/styles/bench.dssl +0 -0
- public/styles/site.dssl +2 -1
- scripts/build-engine-package.sh +3 -2
- scripts/repro-v072.mjs +0 -0
- scripts/repro-v073.mjs +91 -0
- scripts/test-extension-lib.mjs +44 -0
- scripts/test-guide-browser.mjs +142 -0
- scripts/test-guide-examples.mjs +0 -0
- scripts/test-v072.mjs +4 -1
- scripts/test-v073.mjs +217 -0
- src/components/dssl/SiteRoot.tsx +2 -2
- src/components/dssl/views/BenchView.tsx +0 -0
- src/components/dssl/views/ChangelogView.tsx +44 -0
- src/components/dssl/views/SpecView.tsx +1 -1
.gitignore
CHANGED
|
@@ -58,3 +58,6 @@ server.log
|
|
| 58 |
|
| 59 |
.env
|
| 60 |
mini-services/
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
.env
|
| 60 |
mini-services/
|
| 61 |
+
|
| 62 |
+
# local screenshots, never published
|
| 63 |
+
.zscripts/
|
.vercelignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/download/
|
| 2 |
+
/upload/
|
| 3 |
+
/tool-results/
|
| 4 |
+
/scripts/
|
| 5 |
+
/tests/
|
| 6 |
+
/examples/
|
| 7 |
+
/mini-services/
|
| 8 |
+
/db/
|
| 9 |
+
/prisma/
|
| 10 |
+
/node_modules/
|
| 11 |
+
/worklog.md
|
| 12 |
+
/*.tar.gz
|
| 13 |
+
/dev.log
|
| 14 |
+
/server.log
|
| 15 |
+
/.vercel
|
| 16 |
+
/package/
|
AGENTS.md
CHANGED
|
File without changes
|
Caddyfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:81 {
|
| 2 |
+
@transform_port_query {
|
| 3 |
+
query XTransformPort=*
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
handle @transform_port_query {
|
| 7 |
+
reverse_proxy localhost:{query.XTransformPort} {
|
| 8 |
+
header_up Host {host}
|
| 9 |
+
header_up X-Forwarded-For {remote_host}
|
| 10 |
+
header_up X-Forwarded-Proto {scheme}
|
| 11 |
+
header_up X-Real-IP {remote_host}
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
handle {
|
| 16 |
+
reverse_proxy localhost:3000 {
|
| 17 |
+
header_up Host {host}
|
| 18 |
+
header_up X-Forwarded-For {remote_host}
|
| 19 |
+
header_up X-Forwarded-Proto {scheme}
|
| 20 |
+
header_up X-Real-IP {remote_host}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
GUIDE.md
CHANGED
|
@@ -109,6 +109,44 @@ Token overrides can be scoped under `@media` or `@signal` for themes.
|
|
| 109 |
.hero { center: both }
|
| 110 |
```
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
### The cascade is (layer, order)
|
| 113 |
|
| 114 |
No specificity, no `!important`. Later wins inside a layer. Layers sort
|
|
@@ -211,7 +249,7 @@ for liveness. `unfreeze()` re-arms everything.
|
|
| 211 |
|
| 212 |
Three ways to get it:
|
| 213 |
|
| 214 |
-
1. Download `dssl-vscode/dssl-vscode-0.
|
| 215 |
Extensions panel, `...`, Install from VSIX. Works in VS Code and VSCodium.
|
| 216 |
2. Build it: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
|
| 217 |
3. Registry listing: staged for Open VSX (the registry VSCodium searches).
|
|
@@ -222,10 +260,31 @@ did-you-mean included, line numbers accurate since the v0.7.1 parser fix),
|
|
| 222 |
completions folded from your workspace token registry, hover docs, a TextMate
|
| 223 |
grammar for `.dssl`.
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
## 6. Tooling and tests
|
| 226 |
|
| 227 |
```sh
|
| 228 |
-
node
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
node scripts/test-v071.mjs # parser line-number suite
|
| 230 |
node scripts/test-v070.mjs # v0.7 feature suite (60)
|
| 231 |
node scripts/test-v050.mjs # v0.5 feature suite (61)
|
|
@@ -242,16 +301,24 @@ run the sync script so the editor extension ships the same compiler.
|
|
| 242 |
|
| 243 |
Read this before choosing DSSL for a real site.
|
| 244 |
|
| 245 |
-
- **JavaScript is required
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
- **First paint.** Styles are applied by the engine after it loads, so large
|
| 248 |
-
pages can flash unstyled content (CLS). The
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
| 252 |
- **CSP.** Pseudo-elements compile to one real stylesheet the engine injects;
|
| 253 |
strict `style-src 'self'` blocks inline style attributes and that sheet.
|
| 254 |
-
DSSL wants `unsafe-inline` for styles today
|
|
|
|
| 255 |
- **Interop is invasive.** DSSL normal declarations beat external CSS, and
|
| 256 |
`pin` beats external `!important`. Mixing with an existing CSS design
|
| 257 |
system works but the engine wins by design.
|
|
|
|
| 109 |
.hero { center: both }
|
| 110 |
```
|
| 111 |
|
| 112 |
+
Each place word owns an axis (v0.8.0 β the example above used to silently
|
| 113 |
+
lose the `between` because every word wrote both axes):
|
| 114 |
+
|
| 115 |
+
- `between`, `around`, `evenly` are main-axis words (`justify-content`)
|
| 116 |
+
- `stretch`, `baseline` are cross-axis words (`align-items`)
|
| 117 |
+
- `center`, `start`, `end` fill whichever axes are still free
|
| 118 |
+
|
| 119 |
+
One distribution word + one alignment word is the whole grammar. A word
|
| 120 |
+
whose axes are already taken warns loudly ("has nowhere to go") instead of
|
| 121 |
+
overwriting, and a word written twice warns too.
|
| 122 |
+
|
| 123 |
+
### Sizing that moves: fluid
|
| 124 |
+
|
| 125 |
+
`font-size: fluid 16 24` (and any length-ish property: width, height, gap,
|
| 126 |
+
padding, marginβ¦) compiles to the linear `clamp()` between 360px and 1200px
|
| 127 |
+
viewports β the formula everyone writes wrong by hand. Custom bounds:
|
| 128 |
+
`font-size: fluid 16 24 400 800`. The comma form still works: `fluid: 16, 24`.
|
| 129 |
+
|
| 130 |
+
### raw { } β the escape hatch
|
| 131 |
+
|
| 132 |
+
DSSL has no sugar for `@font-face`, `@keyframes`, `@supports`, `@import` or
|
| 133 |
+
`@property` yet. `raw { }` (alias `@css { }`) drops verbatim CSS into the
|
| 134 |
+
page stylesheet, top-level only:
|
| 135 |
+
|
| 136 |
+
```dssl
|
| 137 |
+
raw {
|
| 138 |
+
@font-face {
|
| 139 |
+
font-family: Inter
|
| 140 |
+
src: url(/fonts/inter.woff2)
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
Unknown at-rules outside `raw { }` are skipped whole, with one clean error
|
| 146 |
+
and a pointer here β they never leak their inner rules (that was a real
|
| 147 |
+
0.7.2 bug: `@supports (display:grid) { .a { β¦ } }` applied `.a` everywhere
|
| 148 |
+
after erroring).
|
| 149 |
+
|
| 150 |
### The cascade is (layer, order)
|
| 151 |
|
| 152 |
No specificity, no `!important`. Later wins inside a layer. Layers sort
|
|
|
|
| 249 |
|
| 250 |
Three ways to get it:
|
| 251 |
|
| 252 |
+
1. Download `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files, then
|
| 253 |
Extensions panel, `...`, Install from VSIX. Works in VS Code and VSCodium.
|
| 254 |
2. Build it: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`.
|
| 255 |
3. Registry listing: staged for Open VSX (the registry VSCodium searches).
|
|
|
|
| 260 |
completions folded from your workspace token registry, hover docs, a TextMate
|
| 261 |
grammar for `.dssl`.
|
| 262 |
|
| 263 |
+
### The exit lane: dssl check + dssl build
|
| 264 |
+
|
| 265 |
+
v0.8.0 answers the honest limits below without giving up the language:
|
| 266 |
+
|
| 267 |
+
```sh
|
| 268 |
+
node cli/dssl.mjs check styles.dssl --strict # CI: real compiler, exit 1 on errors, --json
|
| 269 |
+
node cli/dssl.mjs build styles.dssl -o styles.css
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
`build` compiles the static subset to a real stylesheet: tokens become
|
| 273 |
+
custom properties, states become `:hover`, `@media`/`@container` pass
|
| 274 |
+
through, layers become `@layer`, motions become `@keyframes`, `pin` becomes
|
| 275 |
+
`!important`, `raw { }` passes through verbatim, and `seen` applies its
|
| 276 |
+
final state unconditionally (the graceful no-JS look). What needs the
|
| 277 |
+
runtime stays out and gets counted: `@signal` rules and `animate:`.
|
| 278 |
+
The same output is available in the browser as `DSSL.toCSS(source)`.
|
| 279 |
+
|
| 280 |
## 6. Tooling and tests
|
| 281 |
|
| 282 |
```sh
|
| 283 |
+
node cli/dssl.mjs check public/styles/*.dssl # compile everything, exit code for CI
|
| 284 |
+
node scripts/test-v073.mjs # 0.8.0 regression suite (65 assertions)
|
| 285 |
+
node scripts/test-guide-examples.mjs # every guide example compiles
|
| 286 |
+
node scripts/test-guide-browser.mjs # guide examples asserted in headless Chromium
|
| 287 |
+
node scripts/test-v072.mjs # 0.7.2 regression suite (40 assertions)
|
| 288 |
node scripts/test-v071.mjs # parser line-number suite
|
| 289 |
node scripts/test-v070.mjs # v0.7 feature suite (60)
|
| 290 |
node scripts/test-v050.mjs # v0.5 feature suite (61)
|
|
|
|
| 301 |
|
| 302 |
Read this before choosing DSSL for a real site.
|
| 303 |
|
| 304 |
+
- **JavaScript is required** for the live engine. No JS, no styles at runtime.
|
| 305 |
+
v0.8.0 adds the exit lane: `dssl build styles.dssl -o styles.css` compiles
|
| 306 |
+
the static subset to a real stylesheet you can `<link>` instead of the
|
| 307 |
+
engine β tokens as custom properties, states as `:hover`, layers as
|
| 308 |
+
`@layer`, motions as `@keyframes`, `raw { }` verbatim. What genuinely needs
|
| 309 |
+
JS (`@signal`, `animate:`, deform, overlap) stays out and is counted in the
|
| 310 |
+
report, so you always know what a no-JS page gives up. If a page must work
|
| 311 |
+
without JS AND uses the dynamic features, use CSS.
|
| 312 |
- **First paint.** Styles are applied by the engine after it loads, so large
|
| 313 |
+
pages can flash unstyled content (CLS). The build output fixes this for
|
| 314 |
+
static pages (real CSS paints before any JS). For live pages, the docs site
|
| 315 |
+
solves it by inlining its stylesheets into the SSR HTML. The planned
|
| 316 |
+
prerender mode (server renders the final inline styles) is the real fix
|
| 317 |
+
and is on the PARITY roadmap as the next big item.
|
| 318 |
- **CSP.** Pseudo-elements compile to one real stylesheet the engine injects;
|
| 319 |
strict `style-src 'self'` blocks inline style attributes and that sheet.
|
| 320 |
+
DSSL wants `unsafe-inline` for styles today β or use the `dssl build`
|
| 321 |
+
output, which is a normal external stylesheet and CSP-clean.
|
| 322 |
- **Interop is invasive.** DSSL normal declarations beat external CSS, and
|
| 323 |
`pin` beats external `!important`. Mixing with an existing CSS design
|
| 324 |
system works but the engine wins by design.
|
PARITY.md
CHANGED
|
@@ -28,9 +28,9 @@ The point of this table is to stop two bad arguments: "DSSL is missing everythin
|
|
| 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 |
|
| 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 |
|
| 34 |
|
| 35 |
## The exit story
|
| 36 |
|
|
@@ -39,6 +39,14 @@ Abandoning DSSL leaves you: a DOM of valid inline styles, one small `<style>` el
|
|
| 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
|
|
@@ -49,6 +57,36 @@ no other replacement styles with the platform's own primitives.
|
|
| 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
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
## v0.7.2 review-driven corrections
|
| 53 |
|
| 54 |
An external agent review reproduced a batch of real bugs; all of them are fixed
|
|
|
|
| 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 | `dssl build` emits real CSS (v0.8.0); prerender snapshot for live pages planned | partially shipped | 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 | `dssl build` emits the static subset as real CSS (v0.8.0); `@signal`/`animate:` stay runtime-only and counted | shipped for the static subset; prerender snapshot still planned for live pages | the honest trade has an exit now |
|
| 34 |
|
| 35 |
## The exit story
|
| 36 |
|
|
|
|
| 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 |
+
And since v0.8.0 there is a second exit: `dssl build styles.dssl -o styles.css`
|
| 43 |
+
compiles the static subset to a real stylesheet before you ever ship β no runtime,
|
| 44 |
+
no inline styles, CSP-clean, works without JavaScript. Tokens become custom properties,
|
| 45 |
+
states become pseudo-classes, layers become `@layer`, motions become `@keyframes`,
|
| 46 |
+
`pin` becomes `!important`, `raw { }` passes through verbatim, `seen` applies its final
|
| 47 |
+
state. `@signal` rules and `animate:` stay runtime-only and are counted in the report,
|
| 48 |
+
so the boundary is explicit, not folklore.
|
| 49 |
+
|
| 50 |
## What DSSL adds that CSS does not have
|
| 51 |
|
| 52 |
- live tokens and signals: change one value or fire one bridge call, the whole DOM re-resolves
|
|
|
|
| 57 |
- loud errors with did-you-mean, and a warnings channel for cascade-order footguns
|
| 58 |
- `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
|
| 59 |
|
| 60 |
+
## v0.8.0 review-driven corrections
|
| 61 |
+
|
| 62 |
+
The second external review (repro snippets, headless browser) found five bugs and
|
| 63 |
+
asked for the static build first. All fixed or shipped:
|
| 64 |
+
|
| 65 |
+
- `row: gap 8 between center` β the guide's own example β kept its `between`
|
| 66 |
+
(every place word used to write both axes; the later word overwrote
|
| 67 |
+
`justify-content` silently). Place words now own an axis: between/around/evenly
|
| 68 |
+
are main-axis, stretch/baseline cross-axis, center/start/end fill free axes;
|
| 69 |
+
conflicts and duplicates warn loudly.
|
| 70 |
+
- `@font-face { ... }` crashed the compiler with an uncaught throw. Unknown
|
| 71 |
+
at-rules now skip their entire balanced block with one clean error.
|
| 72 |
+
- `@supports (...) { .a { ... } }` used to error AND still apply `.a`
|
| 73 |
+
unconditionally. The whole block is skipped now; nothing leaks.
|
| 74 |
+
- Unquoted `url(a.png)` false-errored ("unknown value a.png"); `url()` is
|
| 75 |
+
atomic now, and the missing background words (`no-repeat`, `repeat-x/y`,
|
| 76 |
+
`space`, `round`, `scroll`, `local`) joined the vocabulary.
|
| 77 |
+
- `color: red !important` used to error AND emit `red !important` (which the
|
| 78 |
+
browser then silently dropped). `!important` is now one loud error, nothing
|
| 79 |
+
is emitted, and the hint names `pin { }` as the actual answer.
|
| 80 |
+
- `animation: spin ...` suggested "did you mean span"; it now passes motion
|
| 81 |
+
names through, warns when the motion is undefined, and bare durations are ms.
|
| 82 |
+
- `transition: color .2s ease` (CSS order) is an error whose hint shows the
|
| 83 |
+
exact DSSL conversion (`transition: 200 ease color`).
|
| 84 |
+
|
| 85 |
+
And the wishlist items that shipped: `raw { }` / `@css { }` verbatim passthrough,
|
| 86 |
+
`dssl build` + `dssl check` (the CLI, with `--json` and CI exit codes),
|
| 87 |
+
`DSSL.toCSS()`, headless-Chromium computed-style tests for every guide example
|
| 88 |
+
(`scripts/test-guide-browser.mjs`), and `font-size: fluid 16 24` clamp sugar.
|
| 89 |
+
|
| 90 |
## v0.7.2 review-driven corrections
|
| 91 |
|
| 92 |
An external agent review reproduced a batch of real bugs; all of them are fixed
|
README.md
CHANGED
|
@@ -6,7 +6,7 @@ license: mit
|
|
| 6 |
|
| 7 |
DSSL is a style language for the web. For the pages it fits, it replaces writing CSS entirely. It does not replace the CSS platform: the engine ends in inline styles that the browser parses, and it needs JavaScript. Where it wins is the authoring experience: tokens, words-as-layout, states and motion in one grammar, loud errors, zero build. PARITY.md keeps the honest feature ledger.
|
| 8 |
|
| 9 |
-
Not a preprocessor that spits out stylesheets. Not a utility class graveyard. You write `.dssl` files, the page loads a small engine, and the engine walks your DOM in real time and applies every rule it matches straight onto the elements it finds. Nothing
|
| 10 |
|
| 11 |
Elements that show up later get styled the moment they appear. Change a rule and the page updates on the next pass. The language is the runtime.
|
| 12 |
|
|
@@ -68,7 +68,7 @@ Both drop a `dssl-engine/` folder next to you: the engine, a starter token set,
|
|
| 68 |
|
| 69 |
Prefer to click: [dssl-engine.zip](https://dssl-sable.vercel.app/dssl-engine.zip) or [dssl-engine.tar.gz](https://dssl-sable.vercel.app/dssl-engine.tar.gz). Same package, straight from the browser.
|
| 70 |
|
| 71 |
-
Writing your own .dssl? The official editor extension (`ehs-studio.dssl-vscode`) puts the real compiler in your editor: the squiggles are the same errors the browser console prints, completions come from your own token registry, and hovers explain the semantic vocabulary. One package for VS Code and VSCodium (same extension API): download `dssl-vscode/dssl-vscode-0.
|
| 72 |
|
| 73 |
AI agents and machine reviewers: this repo ships `llms.txt` (dense, structured facts), `AGENTS.md` (how to work on the codebase) and `PARITY.md` (the honest feature matrix against CSS). The site serves the same `llms.txt` at https://dssl-sable.vercel.app/llms.txt
|
| 74 |
|
|
@@ -369,6 +369,28 @@ DSSL.version // '0.7.0'
|
|
| 369 |
|
| 370 |
Auto-boot picks up every `link[rel="stylesheet/dssl"]` and `style[type="text/dssl"]`, applies them, and fires `dssl-ready` on `window`. `<html data-dssl-reset>` enables the small built-in reset.
|
| 371 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
## Performance: freeze, explain, and the cost of live
|
| 373 |
|
| 374 |
DSSL is a runtime. It walks the DOM, matches rules, and writes inline styles. That buys live tokens, live signals and live layout, and it costs more than a static stylesheet by design. Three things tip the ledger back:
|
|
@@ -398,10 +420,10 @@ const rep = DSSL.explain(document.querySelector('.doc-card'))
|
|
| 398 |
|
| 399 |
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.
|
| 400 |
|
| 401 |
-
## What works today (v0.
|
| 402 |
|
| 403 |
- the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
|
| 404 |
-
- flexbox and grid with `row:` / `column:` / `grid:` / `stack:` / `center:` sugar
|
| 405 |
- behaviors and `use`
|
| 406 |
- design tokens, numeric keys included, scoped per file and per subtree
|
| 407 |
- states, engine-native `seen`, `stagger` choreography
|
|
@@ -410,25 +432,30 @@ The matcher also pre-filters every rule through a cheap class/tag/id gate extrac
|
|
| 410 |
- gradient sentences: `gradient`, `gradient-text`, `gradient-flow`
|
| 411 |
- the overlap policy and `pointer-events` pass-through for veils
|
| 412 |
- pseudo-elements in any spelling (shadow CSS), themed scrollbars included
|
| 413 |
-
-
|
| 414 |
-
- `
|
|
|
|
|
|
|
|
|
|
| 415 |
- bare font names, `hyphens: manual`, loud did-you-mean errors
|
| 416 |
- 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
|
| 417 |
- the editor extension bundles the real compiler, so editor squiggles and browser console agree
|
|
|
|
| 418 |
|
| 419 |
## Repo layout
|
| 420 |
|
| 421 |
-
- `public/dssl/` the engine: parser, resolver, engine, boot
|
| 422 |
- `public/styles/` the styles that drive the site
|
|
|
|
| 423 |
- `package/` what ships in the download: engine, starter files, usage guide, installers
|
| 424 |
- `src/` the Next.js site (docs, playground, changelog)
|
| 425 |
-
- `scripts/` smoke tests
|
| 426 |
- `dssl-vscode/` the editor extension (VS Code + VSCodium, bundles the real parser/resolver)
|
| 427 |
-
- `llms.txt`, `AGENTS.md`, `PARITY.md` for machine reviewers and contributors
|
| 428 |
|
| 429 |
## Status
|
| 430 |
|
| 431 |
-
v0.
|
| 432 |
|
| 433 |
## License
|
| 434 |
|
|
|
|
| 6 |
|
| 7 |
DSSL is a style language for the web. For the pages it fits, it replaces writing CSS entirely. It does not replace the CSS platform: the engine ends in inline styles that the browser parses, and it needs JavaScript. Where it wins is the authoring experience: tokens, words-as-layout, states and motion in one grammar, loud errors, zero build. PARITY.md keeps the honest feature ledger.
|
| 8 |
|
| 9 |
+
Not a preprocessor that spits out stylesheets. Not a utility class graveyard. You write `.dssl` files, the page loads a small engine, and the engine walks your DOM in real time and applies every rule it matches straight onto the elements it finds. Nothing compiles at runtime β and since v0.8.0 there is also an exit lane: `dssl build` compiles the static subset of the same source to a real `.css` file for pages that must work without JavaScript or sit behind a strict CSP. You choose per page: live engine, built CSS, or both.
|
| 10 |
|
| 11 |
Elements that show up later get styled the moment they appear. Change a rule and the page updates on the next pass. The language is the runtime.
|
| 12 |
|
|
|
|
| 68 |
|
| 69 |
Prefer to click: [dssl-engine.zip](https://dssl-sable.vercel.app/dssl-engine.zip) or [dssl-engine.tar.gz](https://dssl-sable.vercel.app/dssl-engine.tar.gz). Same package, straight from the browser.
|
| 70 |
|
| 71 |
+
Writing your own .dssl? The official editor extension (`ehs-studio.dssl-vscode`) puts the real compiler in your editor: the squiggles are the same errors the browser console prints, completions come from your own token registry, and hovers explain the semantic vocabulary. One package for VS Code and VSCodium (same extension API): download `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files, then Extensions panel -> ... -> Install from VSIX. An Open VSX listing (so it appears in VSCodium's Extensions tab, searchable) is staged and publishes with one Open VSX token; see `dssl-vscode/PUBLISHING.md`.
|
| 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 |
|
|
|
|
| 369 |
|
| 370 |
Auto-boot picks up every `link[rel="stylesheet/dssl"]` and `style[type="text/dssl"]`, applies them, and fires `dssl-ready` on `window`. `<html data-dssl-reset>` enables the small built-in reset.
|
| 371 |
|
| 372 |
+
## The exit lane: dssl check + dssl build (v0.8.0)
|
| 373 |
+
|
| 374 |
+
The same compiler that runs in your browser runs in your terminal:
|
| 375 |
+
|
| 376 |
+
```sh
|
| 377 |
+
node cli/dssl.mjs check styles.dssl --strict # CI: exit 1 on errors, --json for machines
|
| 378 |
+
node cli/dssl.mjs build styles.dssl -o styles.css
|
| 379 |
+
```
|
| 380 |
+
|
| 381 |
+
`build` emits real CSS from the static subset: tokens become custom properties,
|
| 382 |
+
states become `:hover`, `@media`/`@container` pass through, layers become
|
| 383 |
+
`@layer`, motions become `@keyframes`, `pin` becomes `!important`, `raw { }`
|
| 384 |
+
passes through verbatim, and `seen` applies its final state (the graceful
|
| 385 |
+
no-JS look). What genuinely needs the runtime (`@signal`, `animate:`, deform,
|
| 386 |
+
overlap) stays out and gets counted in the report, so the boundary is explicit
|
| 387 |
+
instead of folklore.
|
| 388 |
+
|
| 389 |
+
Two more tools from v0.8.0: `raw { }` (alias `@css { }`) drops verbatim CSS
|
| 390 |
+
like `@font-face` or `@keyframes` into the page stylesheet, and
|
| 391 |
+
`font-size: fluid 16 24` compiles to the linear `clamp()` between 360 and
|
| 392 |
+
1200px viewports.
|
| 393 |
+
|
| 394 |
## Performance: freeze, explain, and the cost of live
|
| 395 |
|
| 396 |
DSSL is a runtime. It walks the DOM, matches rules, and writes inline styles. That buys live tokens, live signals and live layout, and it costs more than a static stylesheet by design. Three things tip the ledger back:
|
|
|
|
| 420 |
|
| 421 |
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.
|
| 422 |
|
| 423 |
+
## What works today (v0.8.0)
|
| 424 |
|
| 425 |
- the full selector surface, including `:nth-child`, `:not`, `:is`, `:where`, `:has`
|
| 426 |
+
- flexbox and grid with `row:` / `column:` / `grid:` / `stack:` / `center:` sugar β place words now own an axis (`between` is main-axis, `stretch` is cross-axis) and conflicts warn instead of silently overwriting
|
| 427 |
- behaviors and `use`
|
| 428 |
- design tokens, numeric keys included, scoped per file and per subtree
|
| 429 |
- states, engine-native `seen`, `stagger` choreography
|
|
|
|
| 432 |
- gradient sentences: `gradient`, `gradient-text`, `gradient-flow`
|
| 433 |
- the overlap policy and `pointer-events` pass-through for veils
|
| 434 |
- pseudo-elements in any spelling (shadow CSS), themed scrollbars included
|
| 435 |
+
- `raw { }` / `@css { }` for verbatim CSS (`@font-face`, `@keyframes`, `@supports`); unknown at-rules skip their whole block with one clean error
|
| 436 |
+
- `fluid 16 24` sizing sugar on any length property
|
| 437 |
+
- the CLI: `dssl check` (CI, --json) and `dssl build` (real CSS from the static subset)
|
| 438 |
+
- compiler warnings for cascade-order footguns and duplicate axis words
|
| 439 |
+
- `DSSL.freeze()` / `DSSL.unfreeze()` / `DSSL.explain()` / `DSSL.toCSS()`
|
| 440 |
- bare font names, `hyphens: manual`, loud did-you-mean errors
|
| 441 |
- 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
|
| 442 |
- the editor extension bundles the real compiler, so editor squiggles and browser console agree
|
| 443 |
+
- every guide example is asserted in headless Chromium (computed styles, real mouse hover, real IntersectionObserver), so docs examples cannot drift from the engine again
|
| 444 |
|
| 445 |
## Repo layout
|
| 446 |
|
| 447 |
+
- `public/dssl/` the engine: parser, resolver, engine, boot, and `static.js` (the build target)
|
| 448 |
- `public/styles/` the styles that drive the site
|
| 449 |
+
- `cli/dssl.mjs` the CLI: `check` + `build`, no dependencies
|
| 450 |
- `package/` what ships in the download: engine, starter files, usage guide, installers
|
| 451 |
- `src/` the Next.js site (docs, playground, changelog)
|
| 452 |
+
- `scripts/` smoke tests, regression suites, and the headless-browser docs tests
|
| 453 |
- `dssl-vscode/` the editor extension (VS Code + VSCodium, bundles the real parser/resolver)
|
| 454 |
+
- `llms.txt`, `AGENTS.md`, `PARITY.md`, `GUIDE.md` for machine reviewers and contributors
|
| 455 |
|
| 456 |
## Status
|
| 457 |
|
| 458 |
+
v0.8.0, experimental, moving fast. The language grows whenever the site needs something it cannot express yet, so the site doubles as the test suite β and the docs examples are rendered and asserted in a real browser, so the guide cannot lie. Honest ledger: great for creative sites, prototypes, docs, playgrounds and internal tools; when a page must work without JavaScript, build the static subset with `dssl build` or pick another tool for that page. Freeze makes big static pages viable; live interaction is what the engine is for.
|
| 459 |
|
| 460 |
## License
|
| 461 |
|
cli/dssl.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/* =========================================================================
|
| 3 |
+
* dssl β the command line for the DSSL style language
|
| 4 |
+
*
|
| 5 |
+
* dssl check styles.dssl [more.dssl β¦] [--json] [--strict] [--quiet]
|
| 6 |
+
* Compile files with the REAL parser + resolver (the same engine the
|
| 7 |
+
* browser runs). Prints file:line diagnostics with did-you-mean hints.
|
| 8 |
+
* Exit 1 on errors, 2 on usage problems. --strict makes warnings fatal.
|
| 9 |
+
* --json emits machine-readable diagnostics for CI and editors.
|
| 10 |
+
*
|
| 11 |
+
* dssl build styles.dssl [-o out.css] [--json] [--no-banner]
|
| 12 |
+
* The exit lane: compile the static subset to a real .css file.
|
| 13 |
+
* Tokens become custom properties, states become :hover, layers become
|
| 14 |
+
* @layer, motions become @keyframes, raw { } passes through verbatim.
|
| 15 |
+
* @signal rules and animate:/engine-native properties stay in the
|
| 16 |
+
* runtime β the report says exactly what was left behind.
|
| 17 |
+
*
|
| 18 |
+
* Files are compiled in the order given; every bundle's tokens fold into the
|
| 19 |
+
* next (name a tokens.dssl first, exactly like <link> order in the browser).
|
| 20 |
+
* ========================================================================= */
|
| 21 |
+
import { readFileSync, writeFileSync, statSync } from 'node:fs';
|
| 22 |
+
import { dirname, resolve as pathResolve, basename, join } from 'node:path';
|
| 23 |
+
import { fileURLToPath } from 'node:url';
|
| 24 |
+
|
| 25 |
+
// the engine is plain ESM in public/dssl β import directly, no build step
|
| 26 |
+
const here = pathResolve(dirname(fileURLToPath(import.meta.url)), '..');
|
| 27 |
+
const { parse } = await import('file://' + join(here, 'public/dssl/parser.js'));
|
| 28 |
+
const { resolve } = await import('file://' + join(here, 'public/dssl/resolver.js'));
|
| 29 |
+
const { toCSS } = await import('file://' + join(here, 'public/dssl/static.js'));
|
| 30 |
+
const { VERSION } = await import('file://' + join(here, 'public/dssl/index.js'));
|
| 31 |
+
|
| 32 |
+
const USAGE = `dssl v${VERSION} β the DSSL style language
|
| 33 |
+
|
| 34 |
+
dssl check <filesβ¦> compile with the real engine, print diagnostics
|
| 35 |
+
--json machine-readable output
|
| 36 |
+
--strict warnings are errors too (exit 1)
|
| 37 |
+
--quiet only print diagnostics, no summary
|
| 38 |
+
dssl build <file> compile the static subset to a real .css file
|
| 39 |
+
-o <out.css> output path (default: <file>.css)
|
| 40 |
+
--json machine-readable report
|
| 41 |
+
--no-banner omit the generated-file header
|
| 42 |
+
dssl --version print the version`;
|
| 43 |
+
|
| 44 |
+
const readArg = (args, flag) => {
|
| 45 |
+
const i = args.indexOf(flag);
|
| 46 |
+
if (i === -1) return null;
|
| 47 |
+
args.splice(i, 1);
|
| 48 |
+
return args.splice(i, 1)[0] || null;
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const readSource = (file) => {
|
| 52 |
+
try {
|
| 53 |
+
statSync(file);
|
| 54 |
+
return readFileSync(file, 'utf8');
|
| 55 |
+
} catch {
|
| 56 |
+
console.error(`dssl: cannot read ${file}`);
|
| 57 |
+
process.exit(2);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
/** compile files in order, folding tokens forward like the browser does */
|
| 62 |
+
function compileAll(files) {
|
| 63 |
+
const out = [];
|
| 64 |
+
let tokenBase = null;
|
| 65 |
+
for (const file of files) {
|
| 66 |
+
const src = readSource(file);
|
| 67 |
+
const name = basename(file);
|
| 68 |
+
const { ast, errors: parseErrors } = parse(src, name);
|
| 69 |
+
let doc;
|
| 70 |
+
try {
|
| 71 |
+
doc = resolve(ast, name, tokenBase);
|
| 72 |
+
} catch (e) {
|
| 73 |
+
// the compiler must never throw β this is a bug report if it happens
|
| 74 |
+
doc = { rules: [], shadowRules: [], raws: [], errors: [{ file: name, line: 0, message: `internal compiler error: ${e.message}`, hint: 'please report this β a snippet that crashes the compiler is a bug in DSSL, not in your code' }], warnings: [], tokens: {} };
|
| 75 |
+
}
|
| 76 |
+
const all = [...parseErrors.map(e => ({ ...e, file: name })), ...doc.errors.map(e => ({ ...e, file: e.file || name }))];
|
| 77 |
+
out.push({ file, name, doc, errors: all, warnings: doc.warnings || [] });
|
| 78 |
+
tokenBase = { ...(tokenBase || {}), ...doc.tokens };
|
| 79 |
+
}
|
| 80 |
+
return out;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const printDiags = (results, quiet) => {
|
| 84 |
+
let errCount = 0, warnCount = 0;
|
| 85 |
+
for (const r of results) {
|
| 86 |
+
for (const e of r.errors) {
|
| 87 |
+
errCount++;
|
| 88 |
+
console.error(`${e.file || r.name}:${e.line ?? 0} β error β ${e.message}${e.hint ? `\n ${e.hint}` : ''}`);
|
| 89 |
+
}
|
| 90 |
+
for (const w of r.warnings) {
|
| 91 |
+
warnCount++;
|
| 92 |
+
console.error(`${w.file || r.name}:${w.line ?? 0} β warning β ${w.message}${w.hint ? `\n ${w.hint}` : ''}`);
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
if (!quiet && results.length > 1) {
|
| 96 |
+
console.error(`\n${results.length} file(s), ${errCount} error(s), ${warnCount} warning(s)`);
|
| 97 |
+
}
|
| 98 |
+
return { errCount, warnCount };
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const args = process.argv.slice(2);
|
| 102 |
+
const cmd = args.shift();
|
| 103 |
+
|
| 104 |
+
if (!cmd || cmd === '--help' || cmd === '-h') {
|
| 105 |
+
console.log(USAGE);
|
| 106 |
+
process.exit(cmd ? 0 : 2);
|
| 107 |
+
}
|
| 108 |
+
if (cmd === '--version' || cmd === '-v') {
|
| 109 |
+
console.log(VERSION);
|
| 110 |
+
process.exit(0);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if (cmd === 'check') {
|
| 114 |
+
const json = args.includes('--json');
|
| 115 |
+
const strict = args.includes('--strict');
|
| 116 |
+
const quiet = args.includes('--quiet');
|
| 117 |
+
for (const f of ['--json', '--strict', '--quiet']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
|
| 118 |
+
if (!args.length) { console.error('dssl check: give me at least one .dssl file'); process.exit(2); }
|
| 119 |
+
const results = compileAll(args);
|
| 120 |
+
if (json) {
|
| 121 |
+
console.log(JSON.stringify({
|
| 122 |
+
version: VERSION,
|
| 123 |
+
files: results.map(r => ({
|
| 124 |
+
file: r.file,
|
| 125 |
+
ok: r.errors.length === 0 && (!strict || r.warnings.length === 0),
|
| 126 |
+
errors: r.errors.map(e => ({ file: e.file || r.name, line: e.line ?? 0, message: e.message, hint: e.hint || null })),
|
| 127 |
+
warnings: r.warnings.map(w => ({ file: w.file || r.name, line: w.line ?? 0, message: w.message, hint: w.hint || null })),
|
| 128 |
+
})),
|
| 129 |
+
}, null, 2));
|
| 130 |
+
} else {
|
| 131 |
+
printDiags(results, quiet);
|
| 132 |
+
}
|
| 133 |
+
const bad = results.some(r => r.errors.length || (strict && r.warnings.length));
|
| 134 |
+
process.exit(bad ? 1 : 0);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (cmd === 'build') {
|
| 138 |
+
const json = args.includes('--json');
|
| 139 |
+
const banner = !args.includes('--no-banner');
|
| 140 |
+
for (const f of ['--json', '--no-banner']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
|
| 141 |
+
const outPath = readArg(args, '-o') || readArg(args, '--out');
|
| 142 |
+
if (!args.length) { console.error('dssl build: give me a .dssl file to build'); process.exit(2); }
|
| 143 |
+
const input = args[0];
|
| 144 |
+
const results = compileAll([input]);
|
| 145 |
+
const r = results[0];
|
| 146 |
+
const built = toCSS(r.doc, { file: r.name, banner });
|
| 147 |
+
const outputPath = outPath || (input.replace(/\.dssl$/i, '') + '.css');
|
| 148 |
+
if (json) {
|
| 149 |
+
console.log(JSON.stringify({
|
| 150 |
+
version: VERSION,
|
| 151 |
+
input: r.file,
|
| 152 |
+
output: outputPath,
|
| 153 |
+
ok: r.errors.length === 0,
|
| 154 |
+
errors: r.errors.map(e => ({ file: e.file || r.name, line: e.line ?? 0, message: e.message, hint: e.hint || null })),
|
| 155 |
+
warnings: r.warnings.map(w => ({ file: w.file || r.name, line: w.line ?? 0, message: w.message, hint: w.hint || null })),
|
| 156 |
+
report: built.report,
|
| 157 |
+
css: built.css,
|
| 158 |
+
}, null, 2));
|
| 159 |
+
} else {
|
| 160 |
+
writeFileSync(outputPath, built.css);
|
| 161 |
+
const rep = built.report;
|
| 162 |
+
console.log(`built ${outputPath} from ${r.file}`);
|
| 163 |
+
console.log(` ${rep.rules} rule(s), ${rep.shadowRules} pseudo-element rule(s), ${rep.keyframes} @keyframes, ${rep.raws} raw block(s)`);
|
| 164 |
+
if (rep.layers.length) console.log(` layers: ${rep.layers.join(' < ')}`);
|
| 165 |
+
if (rep.seenStripped) console.log(` ${rep.seenStripped} seen rule(s) applied unconditionally (the no-JS state)`);
|
| 166 |
+
if (rep.skippedSignals || rep.skippedEngine) {
|
| 167 |
+
console.log(` stays in the runtime: ${rep.skippedSignals} @signal rule(s), ${rep.skippedEngine} engine-native rule(s)`);
|
| 168 |
+
}
|
| 169 |
+
printDiags(results, true);
|
| 170 |
+
}
|
| 171 |
+
process.exit(r.errors.length ? 1 : 0);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
console.error(`dssl: unknown command "${cmd}"\n\n${USAGE}`);
|
| 175 |
+
process.exit(2);
|
dssl-vscode/.vscodeignore
CHANGED
|
File without changes
|
dssl-vscode/LICENSE
CHANGED
|
File without changes
|
dssl-vscode/PUBLISHING.md
CHANGED
|
File without changes
|
dssl-vscode/README.md
CHANGED
|
File without changes
|
dssl-vscode/{dssl-vscode-0.7.2.vsix β dssl-vscode-0.8.0.vsix}
RENAMED
|
Binary files a/dssl-vscode/dssl-vscode-0.7.2.vsix and b/dssl-vscode/dssl-vscode-0.8.0.vsix differ
|
|
|
dssl-vscode/extension.js
CHANGED
|
File without changes
|
dssl-vscode/icon.png
CHANGED
|
|
|
|
dssl-vscode/language-configuration.json
CHANGED
|
File without changes
|
dssl-vscode/lib/analysis.js
CHANGED
|
File without changes
|
dssl-vscode/lib/engine/parser.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 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',
|
|
@@ -28,6 +28,11 @@ const STATE_WORDS = new Set([
|
|
| 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
|
|
@@ -50,6 +55,20 @@ function scanFor(str, ch, from = 0) {
|
|
| 50 |
return -1;
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
/** split a string on a top-level separator */
|
| 54 |
function splitTop(str, sep) {
|
| 55 |
const out = [];
|
|
@@ -67,6 +86,20 @@ function splitTop(str, sep) {
|
|
| 67 |
return out;
|
| 68 |
}
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
/** strip comments (respecting strings), newlines preserved so lines survive */
|
| 71 |
function stripComments(src) {
|
| 72 |
let out = '', q = null;
|
|
@@ -115,6 +148,7 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 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() };
|
|
@@ -122,7 +156,13 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
return { kind: 'bogus' };
|
| 127 |
}
|
| 128 |
if (h.startsWith('::')) {
|
|
@@ -158,12 +198,34 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 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 |
-
|
| 166 |
-
rest = rest.slice(open + 1);
|
|
|
|
| 167 |
continue;
|
| 168 |
}
|
| 169 |
|
|
@@ -172,6 +234,63 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 172 |
}
|
| 173 |
};
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
const processContent = (content, line) => {
|
| 176 |
for (const piece of splitTop(content, ';')) {
|
| 177 |
const part = piece.trim();
|
|
@@ -245,7 +364,8 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 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; }
|
|
@@ -253,6 +373,9 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 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 "}"');
|
|
|
|
| 12 |
* resolution happens in resolver.js.
|
| 13 |
* ========================================================================= */
|
| 14 |
|
| 15 |
+
export 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',
|
|
|
|
| 28 |
export const PSEUDO_ELEMENTS = new Set([
|
| 29 |
'before', 'after', 'placeholder', 'selection', 'marker',
|
| 30 |
'first-line', 'first-letter', 'backdrop', 'file-selector-button',
|
| 31 |
+
// v0.7.2 β ::part() rides the shadow-CSS pipeline (a document stylesheet
|
| 32 |
+
// CAN style shadow parts of custom elements). ::slotted() is accepted by
|
| 33 |
+
// the parser but rejected by the resolver with a loud explanation: it only
|
| 34 |
+
// exists inside a shadow tree, which is not a place DSSL styles.
|
| 35 |
+
'part', 'slotted',
|
| 36 |
]);
|
| 37 |
|
| 38 |
// v0.4.2 β vendor pseudo-elements pass through verbatim (::-webkit-scrollbar
|
|
|
|
| 55 |
return -1;
|
| 56 |
}
|
| 57 |
|
| 58 |
+
/** find index of LAST top-level occurrence of `ch` (skips strings + parens) */
|
| 59 |
+
function scanForLast(str, ch) {
|
| 60 |
+
let depth = 0, q = null, found = -1;
|
| 61 |
+
for (let i = 0; i < str.length; i++) {
|
| 62 |
+
const c = str[i];
|
| 63 |
+
if (q) { if (c === q && str[i - 1] !== '\\') q = null; continue; }
|
| 64 |
+
if (c === '"' || c === "'") { q = c; continue; }
|
| 65 |
+
if (c === '(' || c === '[') { depth++; continue; }
|
| 66 |
+
if (c === ')' || c === ']') { depth = Math.max(0, depth - 1); continue; }
|
| 67 |
+
if (depth === 0 && c === ch) found = i;
|
| 68 |
+
}
|
| 69 |
+
return found;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
/** split a string on a top-level separator */
|
| 73 |
function splitTop(str, sep) {
|
| 74 |
const out = [];
|
|
|
|
| 86 |
return out;
|
| 87 |
}
|
| 88 |
|
| 89 |
+
/** find index of the matching close brace for the '{' just before `from`
|
| 90 |
+
* (string-aware). Returns -1 when the block is never closed. */
|
| 91 |
+
function matchBrace(str, from) {
|
| 92 |
+
let depth = 1, q = null;
|
| 93 |
+
for (let i = from; i < str.length; i++) {
|
| 94 |
+
const c = str[i];
|
| 95 |
+
if (q) { if (c === q && str[i - 1] !== '\\') q = null; continue; }
|
| 96 |
+
if (c === '"' || c === "'") { q = c; continue; }
|
| 97 |
+
if (c === '{') depth++;
|
| 98 |
+
else if (c === '}') { depth--; if (depth === 0) return i; }
|
| 99 |
+
}
|
| 100 |
+
return -1;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
/** strip comments (respecting strings), newlines preserved so lines survive */
|
| 104 |
function stripComments(src) {
|
| 105 |
let out = '', q = null;
|
|
|
|
| 148 |
const h = header.trim();
|
| 149 |
if (h === 'tokens') return { kind: 'tokens' };
|
| 150 |
if (h === 'pin') return { kind: 'pin' };
|
| 151 |
+
if (h === 'raw' || h === '@css') return { kind: 'raw' };
|
| 152 |
if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
|
| 153 |
if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
|
| 154 |
if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
|
|
|
|
| 156 |
if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
|
| 157 |
if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
|
| 158 |
if (h.startsWith('@')) {
|
| 159 |
+
// v0.8.0 β unknown at-rules are skipped WHOLE (balanced braces). They
|
| 160 |
+
// used to error and then keep parsing inside their block, so the inner
|
| 161 |
+
// rules leaked out unconditionally: `@supports (display:grid) { .a { β¦ } }
|
| 162 |
+
// applied .a everywhere. The block is now dropped in its entirety β
|
| 163 |
+
// and raw { } is the documented escape hatch for CSS DSSL has no sugar
|
| 164 |
+
// for yet (@font-face, @keyframes, @supports, @importβ¦).
|
| 165 |
+
err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'the whole block is skipped β for verbatim CSS like @font-face / @keyframes / @supports, wrap it in raw { }');
|
| 166 |
return { kind: 'bogus' };
|
| 167 |
}
|
| 168 |
if (h.startsWith('::')) {
|
|
|
|
| 198 |
|
| 199 |
if (open !== -1) {
|
| 200 |
const header = rest.slice(0, open).trim();
|
| 201 |
+
// v0.7.2 β declarations and a nested block sharing one line:
|
| 202 |
+
// `.card { background: red; hover { ... } }`. Everything before the
|
| 203 |
+
// header used to be swallowed whole into it, so `background: red`
|
| 204 |
+
// silently became part of a selector named "background: red; hover"
|
| 205 |
+
// and vanished. Declarations before the LAST top-level ';' are now
|
| 206 |
+
// processed as declarations; the header is what follows them.
|
| 207 |
+
if (header) {
|
| 208 |
+
const semi = scanForLast(header, ';');
|
| 209 |
+
if (semi !== -1) {
|
| 210 |
+
processContent(header.slice(0, semi), line);
|
| 211 |
+
const realHeader = header.slice(semi + 1).trim();
|
| 212 |
+
if (!realHeader) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
|
| 213 |
+
const meta = classify(realHeader, line);
|
| 214 |
+
const node = { ...meta, line, children: [] };
|
| 215 |
+
stack[stack.length - 1].children.push(node);
|
| 216 |
+
const after = openBlock(meta, node, rest, open, line);
|
| 217 |
+
if (after === null) { rest = rest.slice(open + 1); if (meta.kind !== 'bogus') stack.push(node); continue; }
|
| 218 |
+
rest = after;
|
| 219 |
+
continue;
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
if (!header) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
|
| 223 |
const meta = classify(header, line);
|
| 224 |
const node = { ...meta, line, children: [] };
|
| 225 |
stack[stack.length - 1].children.push(node);
|
| 226 |
+
const after = openBlock(meta, node, rest, open, line);
|
| 227 |
+
if (after === null) { rest = rest.slice(open + 1); if (meta.kind !== 'bogus') stack.push(node); continue; }
|
| 228 |
+
rest = after;
|
| 229 |
continue;
|
| 230 |
}
|
| 231 |
|
|
|
|
| 234 |
}
|
| 235 |
};
|
| 236 |
|
| 237 |
+
/** v0.8.0 β what happens right after a block header opens.
|
| 238 |
+
* - raw { } (top level): capture the block body VERBATIM as node.css and
|
| 239 |
+
* return the remainder of the chunk β or enter multi-line capture mode
|
| 240 |
+
* (returns '' with rawCapture armed).
|
| 241 |
+
* - bogus (unknown at-rule / pseudo-element): skip the whole balanced
|
| 242 |
+
* block so its content can never leak into the parent.
|
| 243 |
+
* - anything else: return null so the caller pushes the node and keeps
|
| 244 |
+
* parsing normally. */
|
| 245 |
+
let rawCapture = null; // { node, depth } while a multi-line raw { } is open
|
| 246 |
+
const openBlock = (meta, node, rest, open, line) => {
|
| 247 |
+
if (meta.kind === 'raw') {
|
| 248 |
+
if (stack.length > 1) {
|
| 249 |
+
err(line, 'raw { } must be top-level β it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
|
| 250 |
+
const close = matchBrace(rest, open + 1);
|
| 251 |
+
return close === -1 ? '' : rest.slice(close + 1);
|
| 252 |
+
}
|
| 253 |
+
const close = matchBrace(rest, open + 1);
|
| 254 |
+
if (close !== -1) {
|
| 255 |
+
node.css = rest.slice(open + 1, close);
|
| 256 |
+
return rest.slice(close + 1);
|
| 257 |
+
}
|
| 258 |
+
rawCapture = { node, depth: 1 };
|
| 259 |
+
return '';
|
| 260 |
+
}
|
| 261 |
+
if (meta.kind === 'bogus') {
|
| 262 |
+
const close = matchBrace(rest, open + 1);
|
| 263 |
+
if (close === -1) {
|
| 264 |
+
err(line, `the "${(meta.sel || '').slice(0, 30) || 'block'}" block was never closed with "}"`, 'every "{" needs a matching "}"');
|
| 265 |
+
return '';
|
| 266 |
+
}
|
| 267 |
+
return rest.slice(close + 1);
|
| 268 |
+
}
|
| 269 |
+
return null;
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
/** consume one source line while a multi-line raw { } is being captured */
|
| 273 |
+
const feedRawCapture = (raw, line) => {
|
| 274 |
+
let q = null;
|
| 275 |
+
for (let i = 0; i < raw.length; i++) {
|
| 276 |
+
const c = raw[i];
|
| 277 |
+
if (q) { if (c === q && raw[i - 1] !== '\\') q = null; continue; }
|
| 278 |
+
if (c === '"' || c === "'") { q = c; continue; }
|
| 279 |
+
if (c === '{') rawCapture.depth++;
|
| 280 |
+
else if (c === '}') {
|
| 281 |
+
rawCapture.depth--;
|
| 282 |
+
if (rawCapture.depth === 0) {
|
| 283 |
+
rawCapture.node.css += raw.slice(0, i);
|
| 284 |
+
const rest = raw.slice(i + 1);
|
| 285 |
+
rawCapture = null;
|
| 286 |
+
if (rest.trim()) processChunk(rest, line);
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
rawCapture.node.css += raw + '\n';
|
| 292 |
+
};
|
| 293 |
+
|
| 294 |
const processContent = (content, line) => {
|
| 295 |
for (const piece of splitTop(content, ';')) {
|
| 296 |
const part = piece.trim();
|
|
|
|
| 364 |
lines.forEach((raw, idx) => {
|
| 365 |
const line = idx + 1;
|
| 366 |
const trimmed = raw.trim();
|
| 367 |
+
if (!trimmed) { if (rawCapture) rawCapture.node.css += raw + '\n'; return; } // blank lines never split a pending chunk
|
| 368 |
+
if (rawCapture) { feedRawCapture(raw, line); return; }
|
| 369 |
const candidate = buf ? buf + ' ' + trimmed : raw;
|
| 370 |
if (buf && needsMore(candidate)) { buf = candidate; return; }
|
| 371 |
if (buf) { processChunk(candidate, bufLine || line); buf = ''; return; }
|
|
|
|
| 373 |
processChunk(candidate, line);
|
| 374 |
});
|
| 375 |
if (buf.trim()) processChunk(buf, bufLine);
|
| 376 |
+
if (rawCapture) {
|
| 377 |
+
err(lines.length, 'the raw { } block was never closed with "}"', 'every "{" needs a matching "}" β the CSS captured so far is discarded');
|
| 378 |
+
}
|
| 379 |
|
| 380 |
if (stack.length > 1) {
|
| 381 |
err(lines.length, `${stack.length - 1} block(s) were never closed with "}"`, 'every "{" needs a matching "}"');
|
dssl-vscode/lib/engine/resolver.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
| 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
|
|
@@ -45,6 +45,10 @@ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,
|
|
| 45 |
'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
|
| 46 |
'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
|
| 47 |
'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
'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,' +
|
|
@@ -68,11 +72,23 @@ 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',
|
| 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,' +
|
|
@@ -82,8 +98,8 @@ const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-it
|
|
| 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(','));
|
|
@@ -182,7 +198,7 @@ function resolveValue(raw, ctx) {
|
|
| 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 |
}
|
|
@@ -208,6 +224,15 @@ function resolveValue(raw, ctx) {
|
|
| 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.
|
|
@@ -267,8 +292,18 @@ function resolveValue(raw, ctx) {
|
|
| 267 |
if (c === '#') { // hex color
|
| 268 |
let j = i + 1;
|
| 269 |
while (j < n && /[0-9a-fA-F]/.test(raw[j])) j++;
|
| 270 |
-
|
|
|
|
| 271 |
i = j;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
continue;
|
| 273 |
}
|
| 274 |
out += c;
|
|
@@ -469,7 +504,21 @@ function buildGradient(v, ctx) {
|
|
| 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') {
|
|
@@ -482,9 +531,36 @@ function parseFlexWords(value, ctx, dir) {
|
|
| 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 (
|
| 486 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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']) });
|
|
@@ -510,15 +586,31 @@ const SEMANTIC = {
|
|
| 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 (
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
|
|
|
|
|
|
|
|
|
| 518 |
} else {
|
| 519 |
-
cols =
|
| 520 |
}
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
| 522 |
},
|
| 523 |
stack(v, ctx) {
|
| 524 |
const word = (v || 'center').trim();
|
|
@@ -579,17 +671,7 @@ const SEMANTIC = {
|
|
| 579 |
return out;
|
| 580 |
},
|
| 581 |
fluid(v, ctx) {
|
| 582 |
-
|
| 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();
|
|
@@ -663,6 +745,22 @@ const SEMANTIC = {
|
|
| 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 |
}
|
|
@@ -842,13 +940,51 @@ const SEMANTIC = {
|
|
| 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:
|
|
@@ -865,6 +1001,22 @@ function resolveDecl(prop, value, ctx) {
|
|
| 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,
|
|
@@ -876,6 +1028,19 @@ function resolveDecl(prop, value, ctx) {
|
|
| 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 |
* ------------------------------------------------------------------ */
|
|
@@ -926,6 +1091,7 @@ 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
|
|
@@ -965,7 +1131,10 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 965 |
} else if (node.kind === 'motion') {
|
| 966 |
motions[node.name] = node;
|
| 967 |
} else {
|
| 968 |
-
|
|
|
|
|
|
|
|
|
|
| 969 |
}
|
| 970 |
}
|
| 971 |
};
|
|
@@ -984,7 +1153,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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';
|
|
@@ -1041,7 +1210,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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, '-');
|
|
@@ -1064,6 +1233,22 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1064 |
/* ---- main recursive walk ---- */
|
| 1065 |
|
| 1066 |
const walk = (children, ctx, ctxRule) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
for (const node of children) {
|
| 1068 |
switch (node.kind) {
|
| 1069 |
case 'decl': {
|
|
@@ -1080,7 +1265,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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
|
|
@@ -1091,6 +1276,11 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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: [] };
|
|
@@ -1103,7 +1293,19 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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);
|
|
@@ -1111,31 +1313,39 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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,
|
| 1116 |
-
flush(sub,
|
| 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,
|
| 1123 |
-
flush(sub,
|
| 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,
|
| 1130 |
-
flush(sub,
|
| 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,
|
| 1138 |
-
flush(sub,
|
| 1139 |
break;
|
| 1140 |
}
|
| 1141 |
case 'pseudo': {
|
|
@@ -1148,7 +1358,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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
|
|
@@ -1174,6 +1384,14 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = 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` });
|
|
@@ -1214,7 +1432,15 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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 |
}
|
|
@@ -1283,7 +1509,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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);
|
|
@@ -1291,7 +1517,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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 {
|
|
@@ -1299,13 +1525,36 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 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 */
|
|
|
|
| 20 |
import { levenshtein, didYouMean, composeDeform } from './utils.js';
|
| 21 |
// v0.5.0 β the resolver validates the `::name` half of full selectors so a
|
| 22 |
// typo like .card::befor is a LOUD error, never a silently dead rule.
|
| 23 |
+
import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
|
| 24 |
|
| 25 |
/* ------------------------------------------------------------------ *
|
| 26 |
* Vocabulary
|
|
|
|
| 45 |
'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
|
| 46 |
'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
|
| 47 |
'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
|
| 48 |
+
// v0.8.0 β the background repeat/attachment words the url() fix exposed:
|
| 49 |
+
// `background: url(a.png) no-repeat` was a false error because "no-repeat"
|
| 50 |
+
// was missing while "repeat" existed
|
| 51 |
+
'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' +
|
| 52 |
'contain,cover,fill,fit-content,min-content,max-content,minmax,repeat,clamp,calc,var,url,env,attr,' +
|
| 53 |
'auto-fill,auto-fit,subgrid,dense,stretch,span,' +
|
| 54 |
'scaleX,scaleY,translateX,translateY,translate,rotate,skew,skewX,skewY,matrix,' +
|
|
|
|
| 72 |
'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
|
| 73 |
'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
|
| 74 |
'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
|
| 75 |
+
'shape-image-threshold',
|
| 76 |
// grid line placement takes integers, not lengths: grid-column: span 2
|
| 77 |
'grid-row', 'grid-column', 'grid-area',
|
| 78 |
]);
|
| 79 |
|
| 80 |
+
// v0.7.2 β time properties: a bare number is milliseconds, never px, and
|
| 81 |
+
// never unitless (both used to emit values the browser silently rejected).
|
| 82 |
+
// transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is
|
| 83 |
+
// why `transition-delay: 200` shipped as invalid unitless 200.
|
| 84 |
+
const TIME_PROPS = new Set([
|
| 85 |
+
'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
|
| 86 |
+
// v0.8.0 β the animation shorthand takes bare numbers as ms too:
|
| 87 |
+
// `animation: spin 1s infinite` is fine, but `animation: ghost 400` used
|
| 88 |
+
// to become "ghost 400px" (invalid). 400 β 400ms, matching animate:.
|
| 89 |
+
'animation',
|
| 90 |
+
]);
|
| 91 |
+
|
| 92 |
// A practical whitelist of standard CSS properties (loud errors beat silent ones)
|
| 93 |
const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
|
| 94 |
'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
|
|
|
|
| 98 |
'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,' +
|
| 99 |
'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
|
| 100 |
'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,' +
|
| 101 |
+
'bottom,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,content-visibility,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,fill,fill-rule,fill-opacity,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-opacity,stroke-width,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' +
|
| 102 |
+
'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,text-wrap,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' +
|
| 103 |
'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,' +
|
| 104 |
'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,' +
|
| 105 |
'-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(','));
|
|
|
|
| 198 |
unit = raw.slice(j, k).toLowerCase();
|
| 199 |
j = k;
|
| 200 |
}
|
| 201 |
+
out += num + (unit ? unit : (ctx.unitless ? '' : TIME_PROPS.has(ctx.prop) ? 'ms' : 'px'));
|
| 202 |
i = j;
|
| 203 |
continue;
|
| 204 |
}
|
|
|
|
| 224 |
}
|
| 225 |
const inner = raw.slice(i + 1, j - 1);
|
| 226 |
const fname = ident.toLowerCase();
|
| 227 |
+
// v0.8.0 β url() is atomic. The validator used to tokenize INSIDE it,
|
| 228 |
+
// so `url(a.png)` raised "unknown value a.png" (quoted urls worked
|
| 229 |
+
// only because strings were already consumed whole). CSS allows the
|
| 230 |
+
// unquoted form; DSSL passes it through untouched now.
|
| 231 |
+
if (fname === 'url') {
|
| 232 |
+
out += 'url(' + inner.trim() + ')';
|
| 233 |
+
i = j;
|
| 234 |
+
continue;
|
| 235 |
+
}
|
| 236 |
// v0.3.2 fix: repeat(COUNT, tracks) β the leading count is an integer,
|
| 237 |
// never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
|
| 238 |
// which the browser rejected and the whole grid silently collapsed.
|
|
|
|
| 292 |
if (c === '#') { // hex color
|
| 293 |
let j = i + 1;
|
| 294 |
while (j < n && /[0-9a-fA-F]/.test(raw[j])) j++;
|
| 295 |
+
const hex = raw.slice(i, j);
|
| 296 |
+
out += hex;
|
| 297 |
i = j;
|
| 298 |
+
// v0.7.2 β alpha on a raw hex color: #ff0000/50 used to become
|
| 299 |
+
// #ff0000/50px (the bare number got the generic px treatment) β invalid
|
| 300 |
+
// CSS, silently dropped by the browser. Tokens always had /NN via
|
| 301 |
+
// color-mix; raw hex now gets the same treatment.
|
| 302 |
+
const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
|
| 303 |
+
if (am) {
|
| 304 |
+
out = out.slice(0, out.length - hex.length) + `color-mix(in oklab, ${hex} ${am[1]}%, transparent)`;
|
| 305 |
+
i += am[0].length;
|
| 306 |
+
}
|
| 307 |
continue;
|
| 308 |
}
|
| 309 |
out += c;
|
|
|
|
| 504 |
|
| 505 |
function parseFlexWords(value, ctx, dir) {
|
| 506 |
const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
|
| 507 |
+
// v0.8.0 β axis roles. Every place word used to write BOTH axes, so
|
| 508 |
+
// `row: gap 8 between center` (the guide's own example) compiled to
|
| 509 |
+
// justify-content: space-between followed by justify-content: center β
|
| 510 |
+
// and center won, bunching everything in the middle. Words now declare
|
| 511 |
+
// the axis they are actually about: distribution words (between/around/
|
| 512 |
+
// evenly) own the main axis, stretch/baseline own the cross axis, and
|
| 513 |
+
// center/start/end fill whichever axes are still free. A word whose axes
|
| 514 |
+
// are all taken is a loud warning instead of a silent overwrite.
|
| 515 |
+
const FLEX_AXIS = {
|
| 516 |
+
between: 'main', around: 'main', evenly: 'main',
|
| 517 |
+
stretch: 'cross', baseline: 'cross',
|
| 518 |
+
center: 'both', start: 'both', end: 'both',
|
| 519 |
+
};
|
| 520 |
const atoms = value.split(/\s+/).filter(Boolean);
|
| 521 |
+
let mainSet = null, crossSet = null;
|
| 522 |
for (let k = 0; k < atoms.length; k++) {
|
| 523 |
const a = atoms[k];
|
| 524 |
if (a === 'gap') {
|
|
|
|
| 531 |
if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; }
|
| 532 |
if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; }
|
| 533 |
if (a === 'inline') { out[0].value = 'inline-flex'; continue; }
|
| 534 |
+
if (FLEX_AXIS[a]) {
|
| 535 |
+
const role = FLEX_AXIS[a];
|
| 536 |
+
const pc = PLACE_WORDS[a]; // [cross, main]
|
| 537 |
+
const axisWarn = (prop, prev) => {
|
| 538 |
+
ctx.warnings = ctx.warnings || [];
|
| 539 |
+
ctx.warnings.push(prev === a
|
| 540 |
+
? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir} β the second copy wins`, hint: `drop the duplicate; one distribution word + one alignment word is the whole grammar` }
|
| 541 |
+
: { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win β drop one, or reorder so the distribution word comes first` });
|
| 542 |
+
};
|
| 543 |
+
let wrote = false;
|
| 544 |
+
if (role === 'main' || (role === 'both' && !mainSet)) {
|
| 545 |
+
if (mainSet) axisWarn('justify-content', mainSet);
|
| 546 |
+
out.push({ prop: 'justify-content', value: pc[1] });
|
| 547 |
+
mainSet = a; wrote = true;
|
| 548 |
+
}
|
| 549 |
+
if (role === 'cross' || (role === 'both' && !crossSet)) {
|
| 550 |
+
if (crossSet) axisWarn('align-items', crossSet);
|
| 551 |
+
out.push({ prop: 'align-items', value: pc[0] });
|
| 552 |
+
crossSet = a; wrote = true;
|
| 553 |
+
}
|
| 554 |
+
if (!wrote) {
|
| 555 |
+
ctx.warnings = ctx.warnings || [];
|
| 556 |
+
ctx.warnings.push({
|
| 557 |
+
file: ctx.file, line: ctx.line,
|
| 558 |
+
message: mainSet === a || crossSet === a
|
| 559 |
+
? `"${a}" is written twice in ${dir} β both axes already came from it`
|
| 560 |
+
: `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`,
|
| 561 |
+
hint: 'one distribution word + one alignment word is the whole grammar β the extra word is dropped',
|
| 562 |
+
});
|
| 563 |
+
}
|
| 564 |
continue;
|
| 565 |
}
|
| 566 |
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']) });
|
|
|
|
| 586 |
column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
|
| 587 |
grid(v, ctx) {
|
| 588 |
const atoms = v.split(/\s+/).filter(Boolean);
|
| 589 |
+
// v0.7.2 β `grid: 3 gap 12` (the documented form) used to die: the gap
|
| 590 |
+
// atoms were fed into grid-template-columns β "3px gap 12px", invalid.
|
| 591 |
+
// gap and rows are now first-class words of the shorthand.
|
| 592 |
+
let gap = null, rows = null;
|
| 593 |
+
const rest = [];
|
| 594 |
+
for (let i = 0; i < atoms.length; i++) {
|
| 595 |
+
if (atoms[i] === 'gap' && atoms[i + 1]) { gap = atoms[++i]; continue; }
|
| 596 |
+
if (atoms[i] === 'rows' && atoms[i + 1]) { rows = atoms[++i]; continue; }
|
| 597 |
+
rest.push(atoms[i]);
|
| 598 |
+
}
|
| 599 |
let cols;
|
| 600 |
+
if (!rest.length) {
|
| 601 |
+
ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'grid needs columns', hint: 'grid: 3 Β· grid: auto-fill 200 Β· grid: 200px 1fr β optionally with gap N and rows N' });
|
| 602 |
+
cols = 'none';
|
| 603 |
+
} else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) {
|
| 604 |
+
cols = `repeat(${rest[0]}, 1fr)`;
|
| 605 |
+
} else if ((rest[0] === 'auto-fill' || rest[0] === 'auto-fit') && rest[1]) {
|
| 606 |
+
cols = `repeat(${rest[0]}, minmax(${len(rest[1], ctx)}, 1fr))`;
|
| 607 |
} else {
|
| 608 |
+
cols = rest.map(a => len(a, ctx)).join(' ');
|
| 609 |
}
|
| 610 |
+
const out = [{ prop: 'display', value: 'grid' }, { prop: 'grid-template-columns', value: cols }];
|
| 611 |
+
if (gap) out.push({ prop: 'gap', value: len(gap, ctx) });
|
| 612 |
+
if (rows) out.push({ prop: 'grid-template-rows', value: /^[0-9]+$/.test(rows) ? `repeat(${rows}, 1fr)` : len(rows, ctx) });
|
| 613 |
+
return out;
|
| 614 |
},
|
| 615 |
stack(v, ctx) {
|
| 616 |
const word = (v || 'center').trim();
|
|
|
|
| 671 |
return out;
|
| 672 |
},
|
| 673 |
fluid(v, ctx) {
|
| 674 |
+
return buildFluidParts(v.split(',').map(s => s.trim()).filter(Boolean), ctx);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
},
|
| 676 |
shadow(v, ctx) {
|
| 677 |
const t = v.trim();
|
|
|
|
| 745 |
const atoms = part.split(/\s+/);
|
| 746 |
const dur = atoms[0];
|
| 747 |
if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
|
| 748 |
+
// v0.8.0 β the CSS habit is property-first (`transition: color .2s
|
| 749 |
+
// ease`). That used to be a generic "not a time" error and the part
|
| 750 |
+
// silently dropped. The error now REWRITES the value into the DSSL
|
| 751 |
+
// order (time Β· easing Β· property) so the fix is a copy-paste.
|
| 752 |
+
const timeAtom = atoms.find(a => /^\d*\.?\d+(ms|s)?$/.test(a));
|
| 753 |
+
const easeAtom = atoms.find(a => EASINGS[a]);
|
| 754 |
+
const propAtom = atoms.find(a => a !== timeAtom && a !== easeAtom && (KNOWN_PROPS.has(a) || DEFORM_TO_TRANSFORM[a]));
|
| 755 |
+
if (timeAtom) {
|
| 756 |
+
const msOut = /^\d+$/.test(timeAtom) ? timeAtom : /ms$/.test(timeAtom) ? timeAtom.replace(/ms$/, '') : String(parseFloat(timeAtom) * 1000);
|
| 757 |
+
ctx.errors.push({
|
| 758 |
+
file: ctx.file, line: ctx.line,
|
| 759 |
+
message: `transition: the CSS form (property first) is not DSSL`,
|
| 760 |
+
hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''} β time first, easing, property last`,
|
| 761 |
+
});
|
| 762 |
+
continue;
|
| 763 |
+
}
|
| 764 |
ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration Β· easing Β· property β try 200 or 0.2s' });
|
| 765 |
continue;
|
| 766 |
}
|
|
|
|
| 940 |
},
|
| 941 |
};
|
| 942 |
|
| 943 |
+
/* v0.8.0 β fluid sizing sugar. `fluid: 16, 24` keeps working (font-size),
|
| 944 |
+
* and now any length-ish property takes it inline: `font-size: fluid 16 24`.
|
| 945 |
+
* Both compile to a clamp() that scales linearly between the viewport bounds
|
| 946 |
+
* (360px and 1200px by default) β the formula everyone writes by hand and
|
| 947 |
+
* gets wrong. */
|
| 948 |
+
const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' +
|
| 949 |
+
'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left').split(','));
|
| 950 |
+
|
| 951 |
+
function buildFluidParts(parts, ctx, prop = 'font-size') {
|
| 952 |
+
const px = (s) => {
|
| 953 |
+
const m = String(s).match(/^([0-9.]+)\s*(px|rem|em|%)?$/);
|
| 954 |
+
if (!m) {
|
| 955 |
+
ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" is not a size`, hint: 'fluid takes two sizes and optionally the viewport bounds β fluid 16 24, or fluid 16 24 360 1200' });
|
| 956 |
+
return '0px';
|
| 957 |
+
}
|
| 958 |
+
return m[2] ? `${m[1]}${m[2]}` : `${m[1]}px`;
|
| 959 |
+
};
|
| 960 |
+
const min = px(parts[0]); const max = px(parts[1]);
|
| 961 |
+
const vmin = px(parts[2] || '360'); const vmax = px(parts[3] || '1200');
|
| 962 |
+
const mv = parseFloat(min), xv = parseFloat(max);
|
| 963 |
+
const slope = ((xv - mv) / (parseFloat(vmax) - parseFloat(vmin))) * 100;
|
| 964 |
+
return [{ prop, value: `clamp(${min}, calc(${min} + ${slope.toFixed(3)}vw), ${max})` }];
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
/* ------------------------------------------------------------------ *
|
| 968 |
* resolveDecl β one declaration β zero or more engine decls
|
| 969 |
* ------------------------------------------------------------------ */
|
| 970 |
|
| 971 |
function resolveDecl(prop, value, ctx) {
|
| 972 |
const errors = ctx.errors;
|
| 973 |
+
const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings };
|
| 974 |
+
|
| 975 |
+
// v0.8.0 β !important does not exist in DSSL. It used to fall through as a
|
| 976 |
+
// value: the compiler said "unknown value important" AND still emitted
|
| 977 |
+
// `red !important`, which the browser then silently dropped as an invalid
|
| 978 |
+
// inline value. Now: one loud error, nothing emitted, and the pin { }
|
| 979 |
+
// answer to the actual question.
|
| 980 |
+
if (hasImportant(value)) {
|
| 981 |
+
errors.push({
|
| 982 |
+
file: ctx.file, line: ctx.line,
|
| 983 |
+
message: `!important does not exist in DSSL β "${prop}: ${String(value).replace(/\s*!\s*important\s*/i, '')}" was NOT applied`,
|
| 984 |
+
hint: 'the cascade is (layer, order) and later wins β put the rule in pin { } when it genuinely must win',
|
| 985 |
+
});
|
| 986 |
+
return [];
|
| 987 |
+
}
|
| 988 |
|
| 989 |
if (prop.startsWith('--')) {
|
| 990 |
// v0.4.0 β scoped tokens. Dotted names are token re-declarations:
|
|
|
|
| 1001 |
const sem = SEMANTIC[prop];
|
| 1002 |
if (sem) return sem(value, ctx2);
|
| 1003 |
|
| 1004 |
+
// v0.8.0 β the animation shorthand passes through (it is legal CSS), but
|
| 1005 |
+
// bare motion names must not pay the generic unknown-value tax: "spin"
|
| 1006 |
+
// used to error with the surreal suggestion "did you mean span?". Motion
|
| 1007 |
+
// names pass silently here and a dedicated warning in the final loop says
|
| 1008 |
+
// the DSSL way (motion + animate) when the name matches nothing.
|
| 1009 |
+
if (prop === 'animation' || prop === 'animation-name') {
|
| 1010 |
+
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }];
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
// v0.8.0 β inline fluid sugar on any length-ish property:
|
| 1014 |
+
// font-size: fluid 16 24 β clamp(16px, calc(16px + 0.952vw), 24px)
|
| 1015 |
+
const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim());
|
| 1016 |
+
if (fluidM && FLUID_PROPS.has(prop)) {
|
| 1017 |
+
return buildFluidParts(fluidM[1].split(/[\s,]+/).filter(Boolean), ctx2, prop);
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
if (!KNOWN_PROPS.has(prop)) {
|
| 1021 |
errors.push({
|
| 1022 |
file: ctx.file, line: ctx.line,
|
|
|
|
| 1028 |
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
|
| 1029 |
}
|
| 1030 |
|
| 1031 |
+
/** string-aware !important scan β quoted content: "!important" must not trip it */
|
| 1032 |
+
function hasImportant(v) {
|
| 1033 |
+
const s = String(v);
|
| 1034 |
+
let q = null;
|
| 1035 |
+
for (let i = 0; i < s.length; i++) {
|
| 1036 |
+
const c = s[i];
|
| 1037 |
+
if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
|
| 1038 |
+
if (c === '"' || c === "'") { q = c; continue; }
|
| 1039 |
+
if (c === '!' && /\s*important/i.test(s.slice(i + 1))) return true;
|
| 1040 |
+
}
|
| 1041 |
+
return false;
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
/* ------------------------------------------------------------------ *
|
| 1045 |
* Media / container / signal normalization
|
| 1046 |
* ------------------------------------------------------------------ */
|
|
|
|
| 1091 |
const errors = [];
|
| 1092 |
const rules = [];
|
| 1093 |
const shadowRules = [];
|
| 1094 |
+
const raws = []; // v0.8.0 β raw { } / @css { } verbatim passthrough blocks
|
| 1095 |
const motions = {};
|
| 1096 |
const behaviors = {};
|
| 1097 |
// v0.7.0 β compile-time warnings: things that compile and run but are
|
|
|
|
| 1131 |
} else if (node.kind === 'motion') {
|
| 1132 |
motions[node.name] = node;
|
| 1133 |
} else {
|
| 1134 |
+
// v0.8.0 β defensive `|| []`: a malformed tree can throw from here
|
| 1135 |
+
// and take the whole stylesheet down (the @font-face crash report).
|
| 1136 |
+
// The parser now skips unknown at-rules whole, but the guard stays.
|
| 1137 |
+
for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]);
|
| 1138 |
}
|
| 1139 |
}
|
| 1140 |
};
|
|
|
|
| 1153 |
const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
|
| 1154 |
return m ? parseFloat(m[1]) : null;
|
| 1155 |
};
|
| 1156 |
+
const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
|
| 1157 |
for (const child of node.children) {
|
| 1158 |
if (child.kind === 'decl' && child.prop === 'essential') {
|
| 1159 |
node._parsed.essential = child.value === '' || child.value === 'true';
|
|
|
|
| 1210 |
|
| 1211 |
const resolveTokenDecls = (keys, conds) => {
|
| 1212 |
const decls = [];
|
| 1213 |
+
const ctxBase = { file, line: 0, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false };
|
| 1214 |
for (const key of keys) {
|
| 1215 |
const raw = tokenMap.get(key);
|
| 1216 |
const prop = '--dssl-' + key.replace(/\./g, '-');
|
|
|
|
| 1233 |
/* ---- main recursive walk ---- */
|
| 1234 |
|
| 1235 |
const walk = (children, ctx, ctxRule) => {
|
| 1236 |
+
// v0.7.2 β parent-first flush. Two real bugs lived here. (1) A nested
|
| 1237 |
+
// block (hover { }, @media, a nested selector) was emitted BEFORE the
|
| 1238 |
+
// parent's own declarations got their cascade order, so `background: red`
|
| 1239 |
+
// on the parent always beat `hover { background: red }` nested inside it
|
| 1240 |
+
// β the exact pattern the docs taught, and the external review's headline
|
| 1241 |
+
// finding. (2) media/container/signal/layer blocks flushed with the
|
| 1242 |
+
// PARENT context, dropping the condition or layer entirely: a nested
|
| 1243 |
+
// `@media dark` was silently unconditional AND sorted before base.
|
| 1244 |
+
// Nested blocks now flush everything the parent has said so far first,
|
| 1245 |
+
// and every block flushes with the context it was actually walked with.
|
| 1246 |
+
const flushPending = (line) => {
|
| 1247 |
+
if (ctxRule && ctxRule.decls.length && ctx.sel) {
|
| 1248 |
+
flush(ctxRule, ctx, line);
|
| 1249 |
+
ctxRule.decls = [];
|
| 1250 |
+
}
|
| 1251 |
+
};
|
| 1252 |
for (const node of children) {
|
| 1253 |
switch (node.kind) {
|
| 1254 |
case 'decl': {
|
|
|
|
| 1265 |
bh._active = false;
|
| 1266 |
break;
|
| 1267 |
}
|
| 1268 |
+
const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
|
| 1269 |
for (const r of resolved) {
|
| 1270 |
// v0.7.0 β remember WHICH source property wrote a display value,
|
| 1271 |
// so the ordering linter can tell an explicit "display:" from a
|
|
|
|
| 1276 |
break;
|
| 1277 |
}
|
| 1278 |
case 'selector': {
|
| 1279 |
+
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) {
|
| 1280 |
+
const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]);
|
| 1281 |
+
if (near) warnings.push({ file, line: node.line, message: `"${node.sel}" compiled as a descendant selector ("${combine(ctx.sel, node.sel)}")`, hint: `if you meant the state, it is spelled "${near}" β this block currently styles descendants called <${node.sel}>` });
|
| 1282 |
+
}
|
| 1283 |
+
flushPending(node.line);
|
| 1284 |
const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
|
| 1285 |
const childCtx = { ...ctx, sel };
|
| 1286 |
const sub = { decls: [] };
|
|
|
|
| 1293 |
errors.push({ file, line: node.line, message: `"${node.name}" needs a selector around it` });
|
| 1294 |
break;
|
| 1295 |
}
|
| 1296 |
+
flushPending(node.line);
|
| 1297 |
const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
|
| 1298 |
+
// v0.7.2 β the arrow is motion/gradient-stop syntax; inside a seen
|
| 1299 |
+
// block it compiled into an inline style the browser rejects, so the
|
| 1300 |
+
// element just sat at its base style forever. Say so, loudly, and
|
| 1301 |
+
// point at the two real patterns (final value, or a motion).
|
| 1302 |
+
if (node.name === 'seen') {
|
| 1303 |
+
for (const d of node.children) {
|
| 1304 |
+
if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) {
|
| 1305 |
+
errors.push({ file, line: d.line, message: `"${d.prop}: ${d.value}" β the -> arrow is motion syntax and does nothing inside seen { }`, hint: 'write the final style (seen { opacity: 1 }) or attach a motion (seen { animate: rise-in 400 })' });
|
| 1306 |
+
}
|
| 1307 |
+
}
|
| 1308 |
+
}
|
| 1309 |
const sub = { decls: [] };
|
| 1310 |
walk(node.children, childCtx, sub);
|
| 1311 |
flush(sub, childCtx, node.line);
|
|
|
|
| 1313 |
}
|
| 1314 |
case 'media': {
|
| 1315 |
const q = normalizeMedia(node.query, { errors, file, line: node.line }, node.line);
|
| 1316 |
+
flushPending(node.line);
|
| 1317 |
+
const childCtx = { ...ctx, media: [...ctx.media, q] };
|
| 1318 |
const sub = { decls: [] };
|
| 1319 |
+
walk(node.children, childCtx, sub);
|
| 1320 |
+
flush(sub, childCtx, node.line);
|
| 1321 |
break;
|
| 1322 |
}
|
| 1323 |
case 'container': {
|
| 1324 |
const q = normalizeMedia(node.query || '(min-width: 0)', { errors, file, line: node.line }, node.line);
|
| 1325 |
+
flushPending(node.line);
|
| 1326 |
+
const childCtx = { ...ctx, containers: [...ctx.containers, q] };
|
| 1327 |
const sub = { decls: [] };
|
| 1328 |
+
walk(node.children, childCtx, sub);
|
| 1329 |
+
flush(sub, childCtx, node.line);
|
| 1330 |
break;
|
| 1331 |
}
|
| 1332 |
case 'signal': {
|
| 1333 |
const sig = parseSignal(node.expr, { errors, file, line: node.line }, node.line);
|
| 1334 |
+
flushPending(node.line);
|
| 1335 |
+
const childCtx = { ...ctx, signals: [...ctx.signals, sig] };
|
| 1336 |
const sub = { decls: [] };
|
| 1337 |
+
walk(node.children, childCtx, sub);
|
| 1338 |
+
flush(sub, childCtx, node.line);
|
| 1339 |
break;
|
| 1340 |
}
|
| 1341 |
case 'layer':
|
| 1342 |
case 'pin': {
|
| 1343 |
const layer = node.kind === 'pin' ? 'pin' : (['tokens', 'base', 'components', 'utilities', 'pin'].includes(node.name) ? node.name : 'components');
|
| 1344 |
+
flushPending(node.line);
|
| 1345 |
+
const childCtx = { ...ctx, layer };
|
| 1346 |
const sub = { decls: [] };
|
| 1347 |
+
walk(node.children, childCtx, sub);
|
| 1348 |
+
flush(sub, childCtx, node.line);
|
| 1349 |
break;
|
| 1350 |
}
|
| 1351 |
case 'pseudo': {
|
|
|
|
| 1358 |
for (const d of node.children) {
|
| 1359 |
if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
|
| 1360 |
if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
|
| 1361 |
+
const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
|
| 1362 |
// v0.5.0 β engine-native properties have no pseudo-element story:
|
| 1363 |
// there is no DOM handle to nudge, animate, stack or stagger. They
|
| 1364 |
// used to leak into the shadow sheet as junk CSS the browser
|
|
|
|
| 1384 |
if (tr) rules.push(tr);
|
| 1385 |
break;
|
| 1386 |
}
|
| 1387 |
+
case 'raw': {
|
| 1388 |
+
// v0.8.0 β verbatim CSS escape hatch (@font-face, @keyframes,
|
| 1389 |
+
// @supports, @import, @propertyβ¦). The parser captured the body
|
| 1390 |
+
// untouched; it rides the same document stylesheet as the
|
| 1391 |
+
// pseudo-element rules and survives `dssl build` verbatim.
|
| 1392 |
+
if ((node.css || '').trim()) raws.push({ css: node.css.trim(), file, line: node.line, order: order++ });
|
| 1393 |
+
break;
|
| 1394 |
+
}
|
| 1395 |
case 'behavior':
|
| 1396 |
case 'motion':
|
| 1397 |
if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` });
|
|
|
|
| 1432 |
errors.push({
|
| 1433 |
file, line,
|
| 1434 |
message: `unknown pseudo-element "::${pseudo}"`,
|
| 1435 |
+
hint: 'try ::before, ::after, ::selection, ::part(name), or a vendor one like ::-webkit-scrollbar-thumb',
|
| 1436 |
+
});
|
| 1437 |
+
continue;
|
| 1438 |
+
}
|
| 1439 |
+
if (name === 'slotted') {
|
| 1440 |
+
errors.push({
|
| 1441 |
+
file, line,
|
| 1442 |
+
message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM',
|
| 1443 |
+
hint: 'style the slotted element itself with a normal selector β it IS reachable from the page',
|
| 1444 |
});
|
| 1445 |
continue;
|
| 1446 |
}
|
|
|
|
| 1509 |
flush(rootRule, topCtx, 0);
|
| 1510 |
|
| 1511 |
// parse all motions that were referenced
|
| 1512 |
+
for (const r of [...rules, ...shadowRules]) {
|
| 1513 |
for (const d of r.decls) {
|
| 1514 |
if (d.prop === '@animate' && d.value && d.value.motion) {
|
| 1515 |
const m = parseMotion(d.value.motion);
|
|
|
|
| 1517 |
errors.push({
|
| 1518 |
file, line: d.line,
|
| 1519 |
message: `unknown motion "${d.value.motion}"`,
|
| 1520 |
+
hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } } β then "animate: ${d.value.motion} 400" runs it`,
|
| 1521 |
});
|
| 1522 |
d.value.broken = true;
|
| 1523 |
} else {
|
|
|
|
| 1525 |
d.value.essential = m.essential;
|
| 1526 |
}
|
| 1527 |
}
|
| 1528 |
+
// v0.8.0 β the animation shorthand is CSS passthrough, so a motion
|
| 1529 |
+
// name that matches nothing used to be a SILENT no-op (no @keyframes
|
| 1530 |
+
// can exist in DSSL). Warn with the whole story; if the keyframes come
|
| 1531 |
+
// from raw { } or another stylesheet, the warning says it can be ignored.
|
| 1532 |
+
if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') {
|
| 1533 |
+
const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w));
|
| 1534 |
+
const named = words.find(w => motions[w]);
|
| 1535 |
+
if (words.length && !named) {
|
| 1536 |
+
warnings.push({
|
| 1537 |
+
file, line: d.line,
|
| 1538 |
+
message: `animation "${d.value}" references no motion defined in this sheet β it will not run`,
|
| 1539 |
+
hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400 β @keyframes does not exist in DSSL; raw { } can carry verbatim keyframes`,
|
| 1540 |
+
});
|
| 1541 |
+
}
|
| 1542 |
+
}
|
| 1543 |
}
|
| 1544 |
}
|
| 1545 |
|
| 1546 |
+
// v0.8.0 β pre-parse EVERY motion (not just referenced ones) so the static
|
| 1547 |
+
// emitter can turn the whole motion vocabulary into real @keyframes.
|
| 1548 |
+
const motionFrames = {};
|
| 1549 |
+
for (const name of Object.keys(motions)) {
|
| 1550 |
+
const m = parseMotion(name);
|
| 1551 |
+
if (m && m.frames.length) motionFrames[name] = m;
|
| 1552 |
+
}
|
| 1553 |
+
|
| 1554 |
const mediaQueries = new Set();
|
| 1555 |
for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
|
| 1556 |
|
| 1557 |
+
return { rules, shadowRules, raws, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
|
| 1558 |
}
|
| 1559 |
|
| 1560 |
/** naive `&` substitution + descendant join */
|
dssl-vscode/lib/props-docs.js
CHANGED
|
File without changes
|
dssl-vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
"name": "dssl-vscode",
|
| 3 |
"displayName": "DSSL Language Support",
|
| 4 |
"description": "The DSSL style language in your editor: syntax highlighting, real-compiler diagnostics (the same errors the browser console shows), completions from the live token registry, hover docs. Works in VS Code and VSCodium.",
|
| 5 |
-
"version": "0.
|
| 6 |
"publisher": "ehs-studio",
|
| 7 |
"icon": "icon.png",
|
| 8 |
"license": "MIT",
|
|
|
|
| 2 |
"name": "dssl-vscode",
|
| 3 |
"displayName": "DSSL Language Support",
|
| 4 |
"description": "The DSSL style language in your editor: syntax highlighting, real-compiler diagnostics (the same errors the browser console shows), completions from the live token registry, hover docs. Works in VS Code and VSCodium.",
|
| 5 |
+
"version": "0.8.0",
|
| 6 |
"publisher": "ehs-studio",
|
| 7 |
"icon": "icon.png",
|
| 8 |
"license": "MIT",
|
dssl-vscode/syntaxes/dssl.tmLanguage.json
CHANGED
|
File without changes
|
engine-package/USAGE.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DSSL engine β quickstart
|
| 2 |
+
|
| 3 |
+
DSSL is a style language that runs in the browser. You write `.dssl` files, add two tags to your page, and the engine does the rest. No compiler, no build step, no CSS output. The engine walks your DOM live and writes styles straight onto the elements it matches, including elements that show up later.
|
| 4 |
+
|
| 5 |
+
## 1. Get the engine
|
| 6 |
+
|
| 7 |
+
Copy the `dssl/` folder from this package into your project, or grab it fresh from a terminal:
|
| 8 |
+
|
| 9 |
+
```sh
|
| 10 |
+
curl -fsSL https://dssl-sable.vercel.app/install.sh | sh
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
That drops a `dssl-engine/` folder next to you with everything you need.
|
| 14 |
+
|
| 15 |
+
## 2. Wire it up
|
| 16 |
+
|
| 17 |
+
```html
|
| 18 |
+
<!doctype html>
|
| 19 |
+
<html data-dssl-reset>
|
| 20 |
+
<head>
|
| 21 |
+
<script type="module" src="/dssl/boot.js"></script>
|
| 22 |
+
<link rel="stylesheet/dssl" href="/styles/tokens.dssl">
|
| 23 |
+
<link rel="stylesheet/dssl" href="/examples/hello.dssl">
|
| 24 |
+
</head>
|
| 25 |
+
<body>
|
| 26 |
+
<h1>Styled by DSSL</h1>
|
| 27 |
+
</body>
|
| 28 |
+
</html>
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
What those lines do:
|
| 32 |
+
|
| 33 |
+
- `data-dssl-reset` (optional) turns on the small built-in reset.
|
| 34 |
+
- `boot.js` is the whole install. It boots the engine on page load.
|
| 35 |
+
- Every `<link rel="stylesheet/dssl">` is fetched and applied automatically, in the order you list them. Tokens first, then your styles.
|
| 36 |
+
- Inline styles work too: `<style type="text/dssl"> ... </style>`.
|
| 37 |
+
- When booting is done the page fires a `dssl-ready` event.
|
| 38 |
+
|
| 39 |
+
## 3. Write some DSSL
|
| 40 |
+
|
| 41 |
+
`examples/hello.dssl` in this package:
|
| 42 |
+
|
| 43 |
+
```dssl
|
| 44 |
+
body {
|
| 45 |
+
background: #f5f0e4
|
| 46 |
+
color: #26201a
|
| 47 |
+
padding: 40
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.card {
|
| 51 |
+
background: #fdfaf2
|
| 52 |
+
border: 1 solid #ddd2bc
|
| 53 |
+
radius: 6
|
| 54 |
+
padding: 24
|
| 55 |
+
|
| 56 |
+
hover {
|
| 57 |
+
box-shadow: 0 6 18 #26201a/16
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Bare numbers are pixels. States like `hover` are nested blocks. The engine keeps them working forever, even for elements added to the page later.
|
| 63 |
+
|
| 64 |
+
## The JavaScript API
|
| 65 |
+
|
| 66 |
+
`window.DSSL` is available after boot:
|
| 67 |
+
|
| 68 |
+
- `DSSL.load(urls)` fetch and apply one or more `.dssl` files (fetched in parallel, applied in order)
|
| 69 |
+
- `DSSL.apply(source, { name, scope })` apply a source string
|
| 70 |
+
- `DSSL.compile(source)` parse and resolve without touching the DOM, returns `ok`, `errors` and stats
|
| 71 |
+
- `DSSL.signal(name, value)` drive styles from JS with `@signal` conditions in your `.dssl`
|
| 72 |
+
- `DSSL.refresh()` force a full recompute
|
| 73 |
+
- `DSSL.replay(selector)` replay entrance motions on matching elements
|
| 74 |
+
- `DSSL.errors` the last reported errors (also grouped in the console, with did-you-mean hints)
|
| 75 |
+
- `DSSL.version` engine version
|
| 76 |
+
|
| 77 |
+
## Scoped engines
|
| 78 |
+
|
| 79 |
+
```js
|
| 80 |
+
DSSL.apply(source, { scope: '#widget' })
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
gives one subtree its own engine, its own tokens and its own cascade. The playground on the live site is built exactly this way.
|
| 84 |
+
|
| 85 |
+
## The exit lane (v0.8.0)
|
| 86 |
+
|
| 87 |
+
The engine is a JS runtime. When a page needs to work without JavaScript, sit
|
| 88 |
+
behind a strict CSP, or paint before any script, build the static subset to a
|
| 89 |
+
real stylesheet:
|
| 90 |
+
|
| 91 |
+
```sh
|
| 92 |
+
node cli/dssl.mjs check styles.dssl --strict # CI: the real compiler, exit 1 on errors, --json for machines
|
| 93 |
+
node cli/dssl.mjs build styles.dssl -o styles.css
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
`build` emits real CSS: tokens become custom properties, states become
|
| 97 |
+
`:hover`, layers become `@layer`, motions become `@keyframes`, `pin` becomes
|
| 98 |
+
`!important`, `raw { }` passes through verbatim, and `seen` applies its final
|
| 99 |
+
state (the graceful no-JS look). The report counts what stays runtime-only
|
| 100 |
+
(`@signal` rules, `animate:`), so the boundary is explicit.
|
| 101 |
+
|
| 102 |
+
In the browser the same output is `DSSL.toCSS(source)` β `{ css, report }`.
|
| 103 |
+
|
| 104 |
+
Two more v0.8.0 tools worth knowing: `raw { }` (alias `@css { }`) drops
|
| 105 |
+
verbatim CSS like `@font-face` into the page stylesheet, and
|
| 106 |
+
`font-size: fluid 16 24` compiles to the linear `clamp()` between 360 and
|
| 107 |
+
1200px viewports.
|
| 108 |
+
|
| 109 |
+
## Files in this package
|
| 110 |
+
|
| 111 |
+
- `dssl/` the engine. `boot.js` is the entry, `index.js` the public API, `static.js` the build-target compiler.
|
| 112 |
+
- `cli/dssl.mjs` the CLI (`check` + `build`), no dependencies, Node 18+
|
| 113 |
+
- `styles/tokens.dssl` the starter token set (colors, fonts, radii, shadows)
|
| 114 |
+
- `examples/hello.dssl` a tiny starter stylesheet
|
| 115 |
+
- `USAGE.md` this file
|
| 116 |
+
- `LICENSE` MIT
|
| 117 |
+
|
| 118 |
+
Live site: https://dssl-sable.vercel.app
|
| 119 |
+
Source and full language docs: https://huggingface.co/EHS-Studio/dssl
|
engine-package/hello.dssl
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// hello.dssl β a tiny starter stylesheet
|
| 2 |
+
// bare numbers are px, hover is a nested state, /14 at the end of a color is alpha
|
| 3 |
+
|
| 4 |
+
body {
|
| 5 |
+
background: #f5f0e4
|
| 6 |
+
color: #26201a
|
| 7 |
+
padding: 40
|
| 8 |
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
h1 {
|
| 12 |
+
color: #7c2d2d
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.card {
|
| 16 |
+
background: #fdfaf2
|
| 17 |
+
border: 1 solid #ddd2bc
|
| 18 |
+
radius: 6
|
| 19 |
+
padding: 24
|
| 20 |
+
box-shadow: 0 2 6 #26201a/14
|
| 21 |
+
transition: 200
|
| 22 |
+
|
| 23 |
+
hover {
|
| 24 |
+
box-shadow: 0 6 18 #26201a/16
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
button {
|
| 29 |
+
background: #7c2d2d
|
| 30 |
+
color: #f9efdd
|
| 31 |
+
padding: 10 18
|
| 32 |
+
radius: 6
|
| 33 |
+
border: 0
|
| 34 |
+
transition: 200
|
| 35 |
+
|
| 36 |
+
hover {
|
| 37 |
+
background: #5e1f1f
|
| 38 |
+
}
|
| 39 |
+
}
|
engine-package/install.sh
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
# DSSL engine installer
|
| 3 |
+
# curl -fsSL https://dssl-sable.vercel.app/install.sh | sh
|
| 4 |
+
set -e
|
| 5 |
+
BASE="https://dssl-sable.vercel.app"
|
| 6 |
+
ZIP="/tmp/dssl-engine-$$.zip"
|
| 7 |
+
|
| 8 |
+
command -v curl >/dev/null 2>&1 || { echo "DSSL installer: 'curl' is required."; exit 1; }
|
| 9 |
+
command -v unzip >/dev/null 2>&1 || { echo "DSSL installer: 'unzip' is required."; exit 1; }
|
| 10 |
+
|
| 11 |
+
echo "fetching the DSSL engine..."
|
| 12 |
+
curl -fsSL "$BASE/dssl-engine.zip" -o "$ZIP"
|
| 13 |
+
rm -rf dssl-engine
|
| 14 |
+
unzip -q "$ZIP" -d .
|
| 15 |
+
rm -f "$ZIP"
|
| 16 |
+
|
| 17 |
+
echo ""
|
| 18 |
+
echo "DSSL engine installed to ./dssl-engine"
|
| 19 |
+
echo "read dssl-engine/USAGE.md to wire it into a page."
|
examples/websocket/frontend.tsx
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import { io } from 'socket.io-client';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Input } from '@/components/ui/input';
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 8 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 9 |
+
|
| 10 |
+
type User = {
|
| 11 |
+
id: string;
|
| 12 |
+
username: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
type Message = {
|
| 16 |
+
id: string;
|
| 17 |
+
username: string;
|
| 18 |
+
content: string;
|
| 19 |
+
timestamp: Date | string;
|
| 20 |
+
type: 'user' | 'system';
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export default function SocketDemo() {
|
| 24 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 25 |
+
const [inputMessage, setInputMessage] = useState('');
|
| 26 |
+
const [username, setUsername] = useState('');
|
| 27 |
+
const [isUsernameSet, setIsUsernameSet] = useState(false);
|
| 28 |
+
const [socket, setSocket] = useState<any>(null);
|
| 29 |
+
const [isConnected, setIsConnected] = useState(false);
|
| 30 |
+
const [users, setUsers] = useState<User[]>([]);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
// Connect to websocket server
|
| 34 |
+
// Never use PORT in the URL, alyways use XTransformPort
|
| 35 |
+
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
|
| 36 |
+
const socketInstance = io('/?XTransformPort=3003', {
|
| 37 |
+
transports: ['websocket', 'polling'],
|
| 38 |
+
forceNew: true,
|
| 39 |
+
reconnection: true,
|
| 40 |
+
reconnectionAttempts: 5,
|
| 41 |
+
reconnectionDelay: 1000,
|
| 42 |
+
timeout: 10000
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
setSocket(socketInstance);
|
| 46 |
+
|
| 47 |
+
socketInstance.on('connect', () => {
|
| 48 |
+
setIsConnected(true);
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
socketInstance.on('disconnect', () => {
|
| 52 |
+
setIsConnected(false);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
socketInstance.on('message', (msg: Message) => {
|
| 56 |
+
setMessages(prev => [...prev, msg]);
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
socketInstance.on('user-joined', (data: { user: User; message: Message }) => {
|
| 60 |
+
setMessages(prev => [...prev, data.message]);
|
| 61 |
+
setUsers(prev => {
|
| 62 |
+
if (!prev.find(u => u.id === data.user.id)) {
|
| 63 |
+
return [...prev, data.user];
|
| 64 |
+
}
|
| 65 |
+
return prev;
|
| 66 |
+
});
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
socketInstance.on('user-left', (data: { user: User; message: Message }) => {
|
| 70 |
+
setMessages(prev => [...prev, data.message]);
|
| 71 |
+
setUsers(prev => prev.filter(u => u.id !== data.user.id));
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
socketInstance.on('users-list', (data: { users: User[] }) => {
|
| 75 |
+
setUsers(data.users);
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
return () => {
|
| 79 |
+
socketInstance.disconnect();
|
| 80 |
+
};
|
| 81 |
+
}, []);
|
| 82 |
+
|
| 83 |
+
const handleJoin = () => {
|
| 84 |
+
if (socket && username.trim() && isConnected) {
|
| 85 |
+
socket.emit('join', { username: username.trim() });
|
| 86 |
+
setIsUsernameSet(true);
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const sendMessage = () => {
|
| 91 |
+
if (socket && inputMessage.trim() && username.trim()) {
|
| 92 |
+
socket.emit('message', {
|
| 93 |
+
content: inputMessage.trim(),
|
| 94 |
+
username: username.trim()
|
| 95 |
+
});
|
| 96 |
+
setInputMessage('');
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const handleKeyPress = (e: React.KeyboardEvent) => {
|
| 101 |
+
if (e.key === 'Enter') {
|
| 102 |
+
sendMessage();
|
| 103 |
+
}
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
return (
|
| 107 |
+
<div className="container mx-auto p-4 max-w-2xl">
|
| 108 |
+
<Card>
|
| 109 |
+
<CardHeader>
|
| 110 |
+
<CardTitle className="flex items-center justify-between">
|
| 111 |
+
WebSocket Demo
|
| 112 |
+
<span className={`text-sm px-2 py-1 rounded ${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
| 113 |
+
{isConnected ? 'Connected' : 'Disconnected'}
|
| 114 |
+
</span>
|
| 115 |
+
</CardTitle>
|
| 116 |
+
</CardHeader>
|
| 117 |
+
<CardContent className="space-y-4">
|
| 118 |
+
{!isUsernameSet ? (
|
| 119 |
+
<div className="space-y-2">
|
| 120 |
+
<Input
|
| 121 |
+
value={username}
|
| 122 |
+
onChange={(e) => setUsername(e.target.value)}
|
| 123 |
+
onKeyPress={(e) => {
|
| 124 |
+
if (e.key === 'Enter') {
|
| 125 |
+
handleJoin();
|
| 126 |
+
}
|
| 127 |
+
}}
|
| 128 |
+
placeholder="Enter your username..."
|
| 129 |
+
disabled={!isConnected}
|
| 130 |
+
className="flex-1"
|
| 131 |
+
/>
|
| 132 |
+
<Button
|
| 133 |
+
onClick={handleJoin}
|
| 134 |
+
disabled={!isConnected || !username.trim()}
|
| 135 |
+
className="w-full"
|
| 136 |
+
>
|
| 137 |
+
Join Chat
|
| 138 |
+
</Button>
|
| 139 |
+
</div>
|
| 140 |
+
) : (
|
| 141 |
+
<>
|
| 142 |
+
<ScrollArea className="h-80 w-full border rounded-md p-4">
|
| 143 |
+
<div className="space-y-2">
|
| 144 |
+
{messages.length === 0 ? (
|
| 145 |
+
<p className="text-gray-500 text-center">No messages yet</p>
|
| 146 |
+
) : (
|
| 147 |
+
messages.map((msg) => (
|
| 148 |
+
<div key={msg.id} className="border-b pb-2 last:border-b-0">
|
| 149 |
+
<div className="flex justify-between items-start">
|
| 150 |
+
<div className="flex-1">
|
| 151 |
+
<p className={`text-sm font-medium ${msg.type === 'system'
|
| 152 |
+
? 'text-blue-600 italic'
|
| 153 |
+
: 'text-gray-700'
|
| 154 |
+
}`}>
|
| 155 |
+
{msg.username}
|
| 156 |
+
</p>
|
| 157 |
+
<p className={`${msg.type === 'system'
|
| 158 |
+
? 'text-blue-500 italic'
|
| 159 |
+
: 'text-gray-900'
|
| 160 |
+
}`}>
|
| 161 |
+
{msg.content}
|
| 162 |
+
</p>
|
| 163 |
+
</div>
|
| 164 |
+
<span className="text-xs text-gray-500">
|
| 165 |
+
{new Date(msg.timestamp).toLocaleTimeString()}
|
| 166 |
+
</span>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
))
|
| 170 |
+
)}
|
| 171 |
+
</div>
|
| 172 |
+
</ScrollArea>
|
| 173 |
+
|
| 174 |
+
<div className="flex space-x-2">
|
| 175 |
+
<Input
|
| 176 |
+
value={inputMessage}
|
| 177 |
+
onChange={(e) => setInputMessage(e.target.value)}
|
| 178 |
+
onKeyPress={handleKeyPress}
|
| 179 |
+
placeholder="Type a message..."
|
| 180 |
+
disabled={!isConnected}
|
| 181 |
+
className="flex-1"
|
| 182 |
+
/>
|
| 183 |
+
<Button
|
| 184 |
+
onClick={sendMessage}
|
| 185 |
+
disabled={!isConnected || !inputMessage.trim()}
|
| 186 |
+
>
|
| 187 |
+
Send
|
| 188 |
+
</Button>
|
| 189 |
+
</div>
|
| 190 |
+
</>
|
| 191 |
+
)}
|
| 192 |
+
</CardContent>
|
| 193 |
+
</Card>
|
| 194 |
+
</div>
|
| 195 |
+
);
|
| 196 |
+
}
|
examples/websocket/server.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServer } from 'http'
|
| 2 |
+
import { Server } from 'socket.io'
|
| 3 |
+
|
| 4 |
+
const httpServer = createServer()
|
| 5 |
+
const io = new Server(httpServer, {
|
| 6 |
+
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
|
| 7 |
+
path: '/',
|
| 8 |
+
cors: {
|
| 9 |
+
origin: "*",
|
| 10 |
+
methods: ["GET", "POST"]
|
| 11 |
+
},
|
| 12 |
+
pingTimeout: 60000,
|
| 13 |
+
pingInterval: 25000,
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
+
interface User {
|
| 17 |
+
id: string
|
| 18 |
+
username: string
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface Message {
|
| 22 |
+
id: string
|
| 23 |
+
username: string
|
| 24 |
+
content: string
|
| 25 |
+
timestamp: Date
|
| 26 |
+
type: 'user' | 'system'
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const users = new Map<string, User>()
|
| 30 |
+
|
| 31 |
+
const generateMessageId = () => Math.random().toString(36).substr(2, 9)
|
| 32 |
+
|
| 33 |
+
const createSystemMessage = (content: string): Message => ({
|
| 34 |
+
id: generateMessageId(),
|
| 35 |
+
username: 'System',
|
| 36 |
+
content,
|
| 37 |
+
timestamp: new Date(),
|
| 38 |
+
type: 'system'
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
const createUserMessage = (username: string, content: string): Message => ({
|
| 42 |
+
id: generateMessageId(),
|
| 43 |
+
username,
|
| 44 |
+
content,
|
| 45 |
+
timestamp: new Date(),
|
| 46 |
+
type: 'user'
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
io.on('connection', (socket) => {
|
| 50 |
+
console.log(`User connected: ${socket.id}`)
|
| 51 |
+
|
| 52 |
+
// Add test event handler
|
| 53 |
+
socket.on('test', (data) => {
|
| 54 |
+
console.log('Received test message:', data)
|
| 55 |
+
socket.emit('test-response', {
|
| 56 |
+
message: 'Server received test message',
|
| 57 |
+
data: data,
|
| 58 |
+
timestamp: new Date().toISOString()
|
| 59 |
+
})
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
socket.on('join', (data: { username: string }) => {
|
| 63 |
+
const { username } = data
|
| 64 |
+
|
| 65 |
+
// Create user object
|
| 66 |
+
const user: User = {
|
| 67 |
+
id: socket.id,
|
| 68 |
+
username
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Add to user list
|
| 72 |
+
users.set(socket.id, user)
|
| 73 |
+
|
| 74 |
+
// Send join message to all users
|
| 75 |
+
const joinMessage = createSystemMessage(`${username} joined the chat room`)
|
| 76 |
+
io.emit('user-joined', { user, message: joinMessage })
|
| 77 |
+
|
| 78 |
+
// Send current user list to new user
|
| 79 |
+
const usersList = Array.from(users.values())
|
| 80 |
+
socket.emit('users-list', { users: usersList })
|
| 81 |
+
|
| 82 |
+
console.log(`${username} joined the chat room, current online users: ${users.size}`)
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
socket.on('message', (data: { content: string; username: string }) => {
|
| 86 |
+
const { content, username } = data
|
| 87 |
+
const user = users.get(socket.id)
|
| 88 |
+
|
| 89 |
+
if (user && user.username === username) {
|
| 90 |
+
const message = createUserMessage(username, content)
|
| 91 |
+
io.emit('message', message)
|
| 92 |
+
console.log(`${username}: ${content}`)
|
| 93 |
+
}
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
socket.on('disconnect', () => {
|
| 97 |
+
const user = users.get(socket.id)
|
| 98 |
+
|
| 99 |
+
if (user) {
|
| 100 |
+
// Remove from user list
|
| 101 |
+
users.delete(socket.id)
|
| 102 |
+
|
| 103 |
+
// Send leave message to all users
|
| 104 |
+
const leaveMessage = createSystemMessage(`${user.username} left the chat room`)
|
| 105 |
+
io.emit('user-left', { user: { id: socket.id, username: user.username }, message: leaveMessage })
|
| 106 |
+
|
| 107 |
+
console.log(`${user.username} left the chat room, current online users: ${users.size}`)
|
| 108 |
+
} else {
|
| 109 |
+
console.log(`User disconnected: ${socket.id}`)
|
| 110 |
+
}
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
socket.on('error', (error) => {
|
| 114 |
+
console.error(`Socket error (${socket.id}):`, error)
|
| 115 |
+
})
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
const PORT = 3003
|
| 119 |
+
httpServer.listen(PORT, () => {
|
| 120 |
+
console.log(`WebSocket server running on port ${PORT}`)
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
// Graceful shutdown
|
| 124 |
+
process.on('SIGTERM', () => {
|
| 125 |
+
console.log('Received SIGTERM signal, shutting down server...')
|
| 126 |
+
httpServer.close(() => {
|
| 127 |
+
console.log('WebSocket server closed')
|
| 128 |
+
process.exit(0)
|
| 129 |
+
})
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
process.on('SIGINT', () => {
|
| 133 |
+
console.log('Received SIGINT signal, shutting down server...')
|
| 134 |
+
httpServer.close(() => {
|
| 135 |
+
console.log('WebSocket server closed')
|
| 136 |
+
process.exit(0)
|
| 137 |
+
})
|
| 138 |
+
})
|
llms.txt
CHANGED
|
@@ -1,28 +1,28 @@
|
|
| 1 |
# DSSL
|
| 2 |
|
| 3 |
-
> DSSL is a style language for the web. For JS-guaranteed pages it replaces writing CSS; it does not replace the CSS platform
|
| 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.
|
| 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">` or embedded in the page as `<style type="text/dssl">` / `<script type="text/dssl">` blocks (auto-booted in document order). The engine 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.
|
| 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. Nested state blocks sort after the parent rule's own declarations (v0.7.2 fix: they used to lose), and nested `@media`/`@signal`/`@layer` keep their condition. `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.
|
| 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`.
|
|
@@ -30,6 +30,9 @@
|
|
| 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 |
- v0.7.2 review-driven fixes, all regression-tested: gate prefilter runs before native matches(); hover state events scope candidate recomputation to the touched subtree plus ancestors (was page-wide); checkbox/details states listen to `change`/`toggle`; raw hex alpha (`#ff0000/50` -> color-mix); bare numbers on time properties are ms; `::part()` supported, `::slotted()` rejected with an explanation; SVG properties (`fill`, `stroke*`) and `text-wrap`/`content-visibility` in the vocabulary.
|
| 34 |
- 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.
|
| 35 |
|
|
@@ -39,14 +42,24 @@
|
|
| 39 |
DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
|
| 40 |
DSSL.apply(src, { scope, name }) // scoped engines for widgets
|
| 41 |
DSSL.compile(src) // parse + resolve, no DOM (powers editors)
|
|
|
|
| 42 |
DSSL.signal('menuOpen', true) // the JS -> styles bridge
|
| 43 |
DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
|
| 44 |
DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
|
| 45 |
DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
|
| 46 |
```
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
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.
|
| 49 |
|
| 50 |
## Editor support
|
| 51 |
|
| 52 |
-
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. Install `dssl-vscode/dssl-vscode-0.
|
|
|
|
| 1 |
# DSSL
|
| 2 |
|
| 3 |
+
> DSSL is a style language for the web. For JS-guaranteed pages it replaces writing CSS; it does not replace the CSS platform. 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 at runtime β but v0.8.0 adds the exit lane: `dssl build` compiles the static subset to a real .css file for no-JS, strict-CSP or size-critical pages. 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.8.0 (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">` or embedded in the page as `<style type="text/dssl">` / `<script type="text/dssl">` blocks (auto-booted in document order). The engine 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 β unless you build the static subset: `dssl build styles.dssl -o styles.css` emits real CSS (tokens β custom properties, states β :hover, layers β @layer, motions β @keyframes, `raw { }` verbatim, `seen` applied as its no-JS final state) and the report counts what stays runtime-only (`@signal` rules, `animate:`). For content pages that must not flash, use the build output or choose another tool.
|
| 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. Nested state blocks sort after the parent rule's own declarations (v0.7.2 fix: they used to lose), and nested `@media`/`@signal`/`@layer` keep their condition. `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.8.0)
|
| 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`. v0.8.0 axis roles: distribution words (`between`/`around`/`evenly`) own the main axis, `stretch`/`baseline` own the cross axis, `center`/`start`/`end` fill free axes, and a word with nowhere to go warns instead of overwriting silently.
|
| 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`.
|
|
|
|
| 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 |
+
- Verbatim escape hatch (v0.8.0): `raw { }` / `@css { }` drops real CSS (@font-face, @keyframes, @supports, @import, @property) into the page stylesheet untouched; unknown at-rules skip their whole block with one clean error.
|
| 34 |
+
- Fluid sugar (v0.8.0): `font-size: fluid 16 24` (and any length-ish property) compiles to the linear clamp() between 360px and 1200px viewports; `fluid: 16, 24` still works.
|
| 35 |
+
- v0.8.0 review-driven fixes, all regression-tested incl. headless-Chromium computed-style tests of the docs: row/column axis roles (the documented `between center` example laid out wrong before); @font-face no longer crashes the compiler (unknown at-rules skip balanced); @supports inner rules no longer leak unconditionally; `url(...)` is atomic (unquoted form no longer false-errors); `!important` is a loud error that emits nothing; the animation shorthand warns when its motion is undefined instead of suggesting "span"; CSS-order transition values get the exact DSSL conversion in the error hint.
|
| 36 |
- v0.7.2 review-driven fixes, all regression-tested: gate prefilter runs before native matches(); hover state events scope candidate recomputation to the touched subtree plus ancestors (was page-wide); checkbox/details states listen to `change`/`toggle`; raw hex alpha (`#ff0000/50` -> color-mix); bare numbers on time properties are ms; `::part()` supported, `::slotted()` rejected with an explanation; SVG properties (`fill`, `stroke*`) and `text-wrap`/`content-visibility` in the vocabulary.
|
| 37 |
- 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.
|
| 38 |
|
|
|
|
| 42 |
DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
|
| 43 |
DSSL.apply(src, { scope, name }) // scoped engines for widgets
|
| 44 |
DSSL.compile(src) // parse + resolve, no DOM (powers editors)
|
| 45 |
+
DSSL.toCSS(src) // v0.8.0: the exit lane -> { css, report }
|
| 46 |
DSSL.signal('menuOpen', true) // the JS -> styles bridge
|
| 47 |
DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
|
| 48 |
DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
|
| 49 |
DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
|
| 50 |
```
|
| 51 |
|
| 52 |
+
## CLI (v0.8.0)
|
| 53 |
+
|
| 54 |
+
```sh
|
| 55 |
+
node cli/dssl.mjs check styles.dssl --strict # CI: exit 1 on errors, --json for machines
|
| 56 |
+
node cli/dssl.mjs build styles.dssl -o styles.css # the static subset as real CSS
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
`check` runs the real compiler with file:line diagnostics and did-you-mean hints. `build` compiles the static subset (tokens β custom properties, states β :hover, layers β @layer, motions β @keyframes, raw { } verbatim, pin β !important, seen β its no-JS final state) and prints a report of what was emitted and what stays runtime-only.
|
| 60 |
+
|
| 61 |
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.
|
| 62 |
|
| 63 |
## Editor support
|
| 64 |
|
| 65 |
+
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. Install `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files. Source lives in `dssl-vscode/` in the repo.
|
public/dssl-engine.tar.gz
CHANGED
|
Binary files a/public/dssl-engine.tar.gz and b/public/dssl-engine.tar.gz differ
|
|
|
public/dssl-engine.zip
CHANGED
|
Binary files a/public/dssl-engine.zip and b/public/dssl-engine.zip differ
|
|
|
public/dssl/engine.js
CHANGED
|
@@ -173,15 +173,19 @@ export class DsslEngine {
|
|
| 173 |
rebuild() {
|
| 174 |
const rules = [];
|
| 175 |
const shadowRules = [];
|
|
|
|
| 176 |
const bundles = [...this.bundles.values()].sort((a, b) => a.seq - b.seq);
|
| 177 |
for (const b of bundles) {
|
| 178 |
for (const r of b.rules) {
|
| 179 |
rules.push(r); // scoped engines map :root rules onto the scope element
|
| 180 |
}
|
| 181 |
shadowRules.push(...b.shadowRules);
|
|
|
|
|
|
|
| 182 |
}
|
| 183 |
this.rules = rules;
|
| 184 |
this.shadowRules = shadowRules;
|
|
|
|
| 185 |
|
| 186 |
// unique media queries β MediaQueryList cache
|
| 187 |
for (const r of rules) for (const q of r.media) this.mql(q);
|
|
@@ -1311,6 +1315,12 @@ export class DsslEngine {
|
|
| 1311 |
|
| 1312 |
let css = this.shadowRules.map(cssFor).join('\n\n');
|
| 1313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1314 |
if (this.reset) {
|
| 1315 |
css += `
|
| 1316 |
|
|
|
|
| 173 |
rebuild() {
|
| 174 |
const rules = [];
|
| 175 |
const shadowRules = [];
|
| 176 |
+
const raws = [];
|
| 177 |
const bundles = [...this.bundles.values()].sort((a, b) => a.seq - b.seq);
|
| 178 |
for (const b of bundles) {
|
| 179 |
for (const r of b.rules) {
|
| 180 |
rules.push(r); // scoped engines map :root rules onto the scope element
|
| 181 |
}
|
| 182 |
shadowRules.push(...b.shadowRules);
|
| 183 |
+
// v0.8.0 β raw { } verbatim CSS rides the same document stylesheet
|
| 184 |
+
if (b.raws) raws.push(...b.raws);
|
| 185 |
}
|
| 186 |
this.rules = rules;
|
| 187 |
this.shadowRules = shadowRules;
|
| 188 |
+
this.raws = raws;
|
| 189 |
|
| 190 |
// unique media queries β MediaQueryList cache
|
| 191 |
for (const r of rules) for (const q of r.media) this.mql(q);
|
|
|
|
| 1315 |
|
| 1316 |
let css = this.shadowRules.map(cssFor).join('\n\n');
|
| 1317 |
|
| 1318 |
+
// v0.8.0 β raw { } blocks: verbatim CSS (@font-face, @keyframes,
|
| 1319 |
+
// @supports, @importβ¦), appended after the pseudo-element rules
|
| 1320 |
+
if (this.raws && this.raws.length) {
|
| 1321 |
+
css += '\n\n' + this.raws.map(r => r.css).join('\n\n');
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
if (this.reset) {
|
| 1325 |
css += `
|
| 1326 |
|
public/dssl/index.js
CHANGED
|
@@ -16,9 +16,10 @@
|
|
| 16 |
|
| 17 |
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.
|
| 22 |
|
| 23 |
let counter = 0;
|
| 24 |
const engines = new Map();
|
|
@@ -143,6 +144,18 @@ export async function load(urls, opts = {}) {
|
|
| 143 |
return results;
|
| 144 |
}
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
export function signal(name, value) { for (const e of engines.values()) e.signal(name, value); }
|
| 147 |
export function getSignal(name) { return engines.get('global')?.getSignal(name); }
|
| 148 |
export function refresh() { for (const e of engines.values()) e.computeAll(); }
|
|
@@ -193,7 +206,7 @@ export function explain(el, opts = {}) {
|
|
| 193 |
}
|
| 194 |
|
| 195 |
export const DSSL = {
|
| 196 |
-
version: VERSION, compile, apply, load, signal, getSignal, refresh, replay, errors,
|
| 197 |
freeze, unfreeze, explain,
|
| 198 |
__debug: { engines },
|
| 199 |
};
|
|
|
|
| 16 |
|
| 17 |
import { parse } from './parser.js';
|
| 18 |
import { resolve, DEFAULT_TOKENS } from './resolver.js';
|
| 19 |
+
import { toCSS as docToCSS } from './static.js';
|
| 20 |
import { DsslEngine } from './engine.js';
|
| 21 |
|
| 22 |
+
export const VERSION = '0.8.0';
|
| 23 |
|
| 24 |
let counter = 0;
|
| 25 |
const engines = new Map();
|
|
|
|
| 144 |
return results;
|
| 145 |
}
|
| 146 |
|
| 147 |
+
/** v0.8.0 β the exit lane: compile a DSSL source string to a real CSS
|
| 148 |
+
* stylesheet (tokens β custom properties, states β :hover, @media as-is,
|
| 149 |
+
* layers β @layer, motions β @keyframes, raw { } verbatim). Returns
|
| 150 |
+
* { css, report } β report counts what was emitted and what stays
|
| 151 |
+
* engine-only (@signal rules, animate: and friends). */
|
| 152 |
+
export function toCSS(source, opts = {}) {
|
| 153 |
+
const file = opts.file || 'inline.dssl';
|
| 154 |
+
const result = compile(source, file);
|
| 155 |
+
const built = docToCSS(result.doc, { file, banner: opts.banner });
|
| 156 |
+
return { ...built, ok: result.ok, errors: result.errors, warnings: result.warnings };
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
export function signal(name, value) { for (const e of engines.values()) e.signal(name, value); }
|
| 160 |
export function getSignal(name) { return engines.get('global')?.getSignal(name); }
|
| 161 |
export function refresh() { for (const e of engines.values()) e.computeAll(); }
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
export const DSSL = {
|
| 209 |
+
version: VERSION, compile, apply, load, toCSS, signal, getSignal, refresh, replay, errors,
|
| 210 |
freeze, unfreeze, explain,
|
| 211 |
__debug: { engines },
|
| 212 |
};
|
public/dssl/parser.js
CHANGED
|
@@ -86,6 +86,20 @@ function splitTop(str, sep) {
|
|
| 86 |
return out;
|
| 87 |
}
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
/** strip comments (respecting strings), newlines preserved so lines survive */
|
| 90 |
function stripComments(src) {
|
| 91 |
let out = '', q = null;
|
|
@@ -134,6 +148,7 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 134 |
const h = header.trim();
|
| 135 |
if (h === 'tokens') return { kind: 'tokens' };
|
| 136 |
if (h === 'pin') return { kind: 'pin' };
|
|
|
|
| 137 |
if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
|
| 138 |
if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
|
| 139 |
if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
|
|
@@ -141,7 +156,13 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 141 |
if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
|
| 142 |
if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
|
| 143 |
if (h.startsWith('@')) {
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
return { kind: 'bogus' };
|
| 146 |
}
|
| 147 |
if (h.startsWith('::')) {
|
|
@@ -192,8 +213,9 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 192 |
const meta = classify(realHeader, line);
|
| 193 |
const node = { ...meta, line, children: [] };
|
| 194 |
stack[stack.length - 1].children.push(node);
|
| 195 |
-
|
| 196 |
-
rest = rest.slice(open + 1);
|
|
|
|
| 197 |
continue;
|
| 198 |
}
|
| 199 |
}
|
|
@@ -201,8 +223,9 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 201 |
const meta = classify(header, line);
|
| 202 |
const node = { ...meta, line, children: [] };
|
| 203 |
stack[stack.length - 1].children.push(node);
|
| 204 |
-
|
| 205 |
-
rest = rest.slice(open + 1);
|
|
|
|
| 206 |
continue;
|
| 207 |
}
|
| 208 |
|
|
@@ -211,6 +234,63 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 211 |
}
|
| 212 |
};
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
const processContent = (content, line) => {
|
| 215 |
for (const piece of splitTop(content, ';')) {
|
| 216 |
const part = piece.trim();
|
|
@@ -284,7 +364,8 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 284 |
lines.forEach((raw, idx) => {
|
| 285 |
const line = idx + 1;
|
| 286 |
const trimmed = raw.trim();
|
| 287 |
-
if (!trimmed) return; // blank lines never split a pending chunk
|
|
|
|
| 288 |
const candidate = buf ? buf + ' ' + trimmed : raw;
|
| 289 |
if (buf && needsMore(candidate)) { buf = candidate; return; }
|
| 290 |
if (buf) { processChunk(candidate, bufLine || line); buf = ''; return; }
|
|
@@ -292,6 +373,9 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 292 |
processChunk(candidate, line);
|
| 293 |
});
|
| 294 |
if (buf.trim()) processChunk(buf, bufLine);
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
if (stack.length > 1) {
|
| 297 |
err(lines.length, `${stack.length - 1} block(s) were never closed with "}"`, 'every "{" needs a matching "}"');
|
|
|
|
| 86 |
return out;
|
| 87 |
}
|
| 88 |
|
| 89 |
+
/** find index of the matching close brace for the '{' just before `from`
|
| 90 |
+
* (string-aware). Returns -1 when the block is never closed. */
|
| 91 |
+
function matchBrace(str, from) {
|
| 92 |
+
let depth = 1, q = null;
|
| 93 |
+
for (let i = from; i < str.length; i++) {
|
| 94 |
+
const c = str[i];
|
| 95 |
+
if (q) { if (c === q && str[i - 1] !== '\\') q = null; continue; }
|
| 96 |
+
if (c === '"' || c === "'") { q = c; continue; }
|
| 97 |
+
if (c === '{') depth++;
|
| 98 |
+
else if (c === '}') { depth--; if (depth === 0) return i; }
|
| 99 |
+
}
|
| 100 |
+
return -1;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
/** strip comments (respecting strings), newlines preserved so lines survive */
|
| 104 |
function stripComments(src) {
|
| 105 |
let out = '', q = null;
|
|
|
|
| 148 |
const h = header.trim();
|
| 149 |
if (h === 'tokens') return { kind: 'tokens' };
|
| 150 |
if (h === 'pin') return { kind: 'pin' };
|
| 151 |
+
if (h === 'raw' || h === '@css') return { kind: 'raw' };
|
| 152 |
if (h.startsWith('behavior ')) return { kind: 'behavior', name: h.slice(9).trim() };
|
| 153 |
if (h.startsWith('motion ')) return { kind: 'motion', name: h.slice(7).trim() };
|
| 154 |
if (h.startsWith('@layer ')) return { kind: 'layer', name: h.slice(7).trim() };
|
|
|
|
| 156 |
if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
|
| 157 |
if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
|
| 158 |
if (h.startsWith('@')) {
|
| 159 |
+
// v0.8.0 β unknown at-rules are skipped WHOLE (balanced braces). They
|
| 160 |
+
// used to error and then keep parsing inside their block, so the inner
|
| 161 |
+
// rules leaked out unconditionally: `@supports (display:grid) { .a { β¦ } }
|
| 162 |
+
// applied .a everywhere. The block is now dropped in its entirety β
|
| 163 |
+
// and raw { } is the documented escape hatch for CSS DSSL has no sugar
|
| 164 |
+
// for yet (@font-face, @keyframes, @supports, @importβ¦).
|
| 165 |
+
err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'the whole block is skipped β for verbatim CSS like @font-face / @keyframes / @supports, wrap it in raw { }');
|
| 166 |
return { kind: 'bogus' };
|
| 167 |
}
|
| 168 |
if (h.startsWith('::')) {
|
|
|
|
| 213 |
const meta = classify(realHeader, line);
|
| 214 |
const node = { ...meta, line, children: [] };
|
| 215 |
stack[stack.length - 1].children.push(node);
|
| 216 |
+
const after = openBlock(meta, node, rest, open, line);
|
| 217 |
+
if (after === null) { rest = rest.slice(open + 1); if (meta.kind !== 'bogus') stack.push(node); continue; }
|
| 218 |
+
rest = after;
|
| 219 |
continue;
|
| 220 |
}
|
| 221 |
}
|
|
|
|
| 223 |
const meta = classify(header, line);
|
| 224 |
const node = { ...meta, line, children: [] };
|
| 225 |
stack[stack.length - 1].children.push(node);
|
| 226 |
+
const after = openBlock(meta, node, rest, open, line);
|
| 227 |
+
if (after === null) { rest = rest.slice(open + 1); if (meta.kind !== 'bogus') stack.push(node); continue; }
|
| 228 |
+
rest = after;
|
| 229 |
continue;
|
| 230 |
}
|
| 231 |
|
|
|
|
| 234 |
}
|
| 235 |
};
|
| 236 |
|
| 237 |
+
/** v0.8.0 β what happens right after a block header opens.
|
| 238 |
+
* - raw { } (top level): capture the block body VERBATIM as node.css and
|
| 239 |
+
* return the remainder of the chunk β or enter multi-line capture mode
|
| 240 |
+
* (returns '' with rawCapture armed).
|
| 241 |
+
* - bogus (unknown at-rule / pseudo-element): skip the whole balanced
|
| 242 |
+
* block so its content can never leak into the parent.
|
| 243 |
+
* - anything else: return null so the caller pushes the node and keeps
|
| 244 |
+
* parsing normally. */
|
| 245 |
+
let rawCapture = null; // { node, depth } while a multi-line raw { } is open
|
| 246 |
+
const openBlock = (meta, node, rest, open, line) => {
|
| 247 |
+
if (meta.kind === 'raw') {
|
| 248 |
+
if (stack.length > 1) {
|
| 249 |
+
err(line, 'raw { } must be top-level β it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
|
| 250 |
+
const close = matchBrace(rest, open + 1);
|
| 251 |
+
return close === -1 ? '' : rest.slice(close + 1);
|
| 252 |
+
}
|
| 253 |
+
const close = matchBrace(rest, open + 1);
|
| 254 |
+
if (close !== -1) {
|
| 255 |
+
node.css = rest.slice(open + 1, close);
|
| 256 |
+
return rest.slice(close + 1);
|
| 257 |
+
}
|
| 258 |
+
rawCapture = { node, depth: 1 };
|
| 259 |
+
return '';
|
| 260 |
+
}
|
| 261 |
+
if (meta.kind === 'bogus') {
|
| 262 |
+
const close = matchBrace(rest, open + 1);
|
| 263 |
+
if (close === -1) {
|
| 264 |
+
err(line, `the "${(meta.sel || '').slice(0, 30) || 'block'}" block was never closed with "}"`, 'every "{" needs a matching "}"');
|
| 265 |
+
return '';
|
| 266 |
+
}
|
| 267 |
+
return rest.slice(close + 1);
|
| 268 |
+
}
|
| 269 |
+
return null;
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
/** consume one source line while a multi-line raw { } is being captured */
|
| 273 |
+
const feedRawCapture = (raw, line) => {
|
| 274 |
+
let q = null;
|
| 275 |
+
for (let i = 0; i < raw.length; i++) {
|
| 276 |
+
const c = raw[i];
|
| 277 |
+
if (q) { if (c === q && raw[i - 1] !== '\\') q = null; continue; }
|
| 278 |
+
if (c === '"' || c === "'") { q = c; continue; }
|
| 279 |
+
if (c === '{') rawCapture.depth++;
|
| 280 |
+
else if (c === '}') {
|
| 281 |
+
rawCapture.depth--;
|
| 282 |
+
if (rawCapture.depth === 0) {
|
| 283 |
+
rawCapture.node.css += raw.slice(0, i);
|
| 284 |
+
const rest = raw.slice(i + 1);
|
| 285 |
+
rawCapture = null;
|
| 286 |
+
if (rest.trim()) processChunk(rest, line);
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
rawCapture.node.css += raw + '\n';
|
| 292 |
+
};
|
| 293 |
+
|
| 294 |
const processContent = (content, line) => {
|
| 295 |
for (const piece of splitTop(content, ';')) {
|
| 296 |
const part = piece.trim();
|
|
|
|
| 364 |
lines.forEach((raw, idx) => {
|
| 365 |
const line = idx + 1;
|
| 366 |
const trimmed = raw.trim();
|
| 367 |
+
if (!trimmed) { if (rawCapture) rawCapture.node.css += raw + '\n'; return; } // blank lines never split a pending chunk
|
| 368 |
+
if (rawCapture) { feedRawCapture(raw, line); return; }
|
| 369 |
const candidate = buf ? buf + ' ' + trimmed : raw;
|
| 370 |
if (buf && needsMore(candidate)) { buf = candidate; return; }
|
| 371 |
if (buf) { processChunk(candidate, bufLine || line); buf = ''; return; }
|
|
|
|
| 373 |
processChunk(candidate, line);
|
| 374 |
});
|
| 375 |
if (buf.trim()) processChunk(buf, bufLine);
|
| 376 |
+
if (rawCapture) {
|
| 377 |
+
err(lines.length, 'the raw { } block was never closed with "}"', 'every "{" needs a matching "}" β the CSS captured so far is discarded');
|
| 378 |
+
}
|
| 379 |
|
| 380 |
if (stack.length > 1) {
|
| 381 |
err(lines.length, `${stack.length - 1} block(s) were never closed with "}"`, 'every "{" needs a matching "}"');
|
public/dssl/resolver.js
CHANGED
|
@@ -45,6 +45,10 @@ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,
|
|
| 45 |
'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
|
| 46 |
'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
|
| 47 |
'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
'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,' +
|
|
@@ -79,6 +83,10 @@ const UNITLESS_PROPS = new Set([
|
|
| 79 |
// why `transition-delay: 200` shipped as invalid unitless 200.
|
| 80 |
const TIME_PROPS = new Set([
|
| 81 |
'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
]);
|
| 83 |
|
| 84 |
// A practical whitelist of standard CSS properties (loud errors beat silent ones)
|
|
@@ -216,6 +224,15 @@ function resolveValue(raw, ctx) {
|
|
| 216 |
}
|
| 217 |
const inner = raw.slice(i + 1, j - 1);
|
| 218 |
const fname = ident.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
// v0.3.2 fix: repeat(COUNT, tracks) β the leading count is an integer,
|
| 220 |
// never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
|
| 221 |
// which the browser rejected and the whole grid silently collapsed.
|
|
@@ -487,7 +504,21 @@ function buildGradient(v, ctx) {
|
|
| 487 |
|
| 488 |
function parseFlexWords(value, ctx, dir) {
|
| 489 |
const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
const atoms = value.split(/\s+/).filter(Boolean);
|
|
|
|
| 491 |
for (let k = 0; k < atoms.length; k++) {
|
| 492 |
const a = atoms[k];
|
| 493 |
if (a === 'gap') {
|
|
@@ -500,9 +531,36 @@ function parseFlexWords(value, ctx, dir) {
|
|
| 500 |
if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; }
|
| 501 |
if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; }
|
| 502 |
if (a === 'inline') { out[0].value = 'inline-flex'; continue; }
|
| 503 |
-
if (
|
| 504 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
continue;
|
| 507 |
}
|
| 508 |
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']) });
|
|
@@ -613,17 +671,7 @@ const SEMANTIC = {
|
|
| 613 |
return out;
|
| 614 |
},
|
| 615 |
fluid(v, ctx) {
|
| 616 |
-
|
| 617 |
-
const px = (s) => {
|
| 618 |
-
const m = String(s).match(/^([0-9.]+)\s*(px|rem|em|%)?$/);
|
| 619 |
-
if (!m) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" is not a size` }); return '0px'; }
|
| 620 |
-
return m[2] ? `${m[1]}${m[2]}` : `${m[1]}px`;
|
| 621 |
-
};
|
| 622 |
-
const min = px(parts[0]); const max = px(parts[1]);
|
| 623 |
-
const vmin = px(parts[2] || '360'); const vmax = px(parts[3] || '1200');
|
| 624 |
-
const mv = parseFloat(min), xv = parseFloat(max);
|
| 625 |
-
const slope = ((xv - mv) / (parseFloat(vmax) - parseFloat(vmin))) * 100;
|
| 626 |
-
return [{ prop: 'font-size', value: `clamp(${min}, calc(${min} + ${slope.toFixed(3)}vw), ${max})` }];
|
| 627 |
},
|
| 628 |
shadow(v, ctx) {
|
| 629 |
const t = v.trim();
|
|
@@ -697,6 +745,22 @@ const SEMANTIC = {
|
|
| 697 |
const atoms = part.split(/\s+/);
|
| 698 |
const dur = atoms[0];
|
| 699 |
if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
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' });
|
| 701 |
continue;
|
| 702 |
}
|
|
@@ -876,13 +940,51 @@ const SEMANTIC = {
|
|
| 876 |
},
|
| 877 |
};
|
| 878 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 879 |
/* ------------------------------------------------------------------ *
|
| 880 |
* resolveDecl β one declaration β zero or more engine decls
|
| 881 |
* ------------------------------------------------------------------ */
|
| 882 |
|
| 883 |
function resolveDecl(prop, value, ctx) {
|
| 884 |
const errors = ctx.errors;
|
| 885 |
-
const ctx2 = { ...ctx, prop, errors };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 886 |
|
| 887 |
if (prop.startsWith('--')) {
|
| 888 |
// v0.4.0 β scoped tokens. Dotted names are token re-declarations:
|
|
@@ -899,6 +1001,22 @@ function resolveDecl(prop, value, ctx) {
|
|
| 899 |
const sem = SEMANTIC[prop];
|
| 900 |
if (sem) return sem(value, ctx2);
|
| 901 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 902 |
if (!KNOWN_PROPS.has(prop)) {
|
| 903 |
errors.push({
|
| 904 |
file: ctx.file, line: ctx.line,
|
|
@@ -910,6 +1028,19 @@ function resolveDecl(prop, value, ctx) {
|
|
| 910 |
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
|
| 911 |
}
|
| 912 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 913 |
/* ------------------------------------------------------------------ *
|
| 914 |
* Media / container / signal normalization
|
| 915 |
* ------------------------------------------------------------------ */
|
|
@@ -960,6 +1091,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 960 |
const errors = [];
|
| 961 |
const rules = [];
|
| 962 |
const shadowRules = [];
|
|
|
|
| 963 |
const motions = {};
|
| 964 |
const behaviors = {};
|
| 965 |
// v0.7.0 β compile-time warnings: things that compile and run but are
|
|
@@ -999,7 +1131,10 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 999 |
} else if (node.kind === 'motion') {
|
| 1000 |
motions[node.name] = node;
|
| 1001 |
} else {
|
| 1002 |
-
|
|
|
|
|
|
|
|
|
|
| 1003 |
}
|
| 1004 |
}
|
| 1005 |
};
|
|
@@ -1018,7 +1153,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1018 |
const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
|
| 1019 |
return m ? parseFloat(m[1]) : null;
|
| 1020 |
};
|
| 1021 |
-
const ctxBase = { file, line: node.line, errors, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
|
| 1022 |
for (const child of node.children) {
|
| 1023 |
if (child.kind === 'decl' && child.prop === 'essential') {
|
| 1024 |
node._parsed.essential = child.value === '' || child.value === 'true';
|
|
@@ -1075,7 +1210,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1075 |
|
| 1076 |
const resolveTokenDecls = (keys, conds) => {
|
| 1077 |
const decls = [];
|
| 1078 |
-
const ctxBase = { file, line: 0, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false };
|
| 1079 |
for (const key of keys) {
|
| 1080 |
const raw = tokenMap.get(key);
|
| 1081 |
const prop = '--dssl-' + key.replace(/\./g, '-');
|
|
@@ -1130,7 +1265,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1130 |
bh._active = false;
|
| 1131 |
break;
|
| 1132 |
}
|
| 1133 |
-
const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
|
| 1134 |
for (const r of resolved) {
|
| 1135 |
// v0.7.0 β remember WHICH source property wrote a display value,
|
| 1136 |
// so the ordering linter can tell an explicit "display:" from a
|
|
@@ -1223,7 +1358,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1223 |
for (const d of node.children) {
|
| 1224 |
if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
|
| 1225 |
if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
|
| 1226 |
-
const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
|
| 1227 |
// v0.5.0 β engine-native properties have no pseudo-element story:
|
| 1228 |
// there is no DOM handle to nudge, animate, stack or stagger. They
|
| 1229 |
// used to leak into the shadow sheet as junk CSS the browser
|
|
@@ -1249,6 +1384,14 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1249 |
if (tr) rules.push(tr);
|
| 1250 |
break;
|
| 1251 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1252 |
case 'behavior':
|
| 1253 |
case 'motion':
|
| 1254 |
if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` });
|
|
@@ -1366,7 +1509,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1366 |
flush(rootRule, topCtx, 0);
|
| 1367 |
|
| 1368 |
// parse all motions that were referenced
|
| 1369 |
-
for (const r of rules) {
|
| 1370 |
for (const d of r.decls) {
|
| 1371 |
if (d.prop === '@animate' && d.value && d.value.motion) {
|
| 1372 |
const m = parseMotion(d.value.motion);
|
|
@@ -1374,7 +1517,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1374 |
errors.push({
|
| 1375 |
file, line: d.line,
|
| 1376 |
message: `unknown motion "${d.value.motion}"`,
|
| 1377 |
-
hint: didYouMean(d.value.motion, Object.keys(motions)),
|
| 1378 |
});
|
| 1379 |
d.value.broken = true;
|
| 1380 |
} else {
|
|
@@ -1382,13 +1525,36 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1382 |
d.value.essential = m.essential;
|
| 1383 |
}
|
| 1384 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1385 |
}
|
| 1386 |
}
|
| 1387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1388 |
const mediaQueries = new Set();
|
| 1389 |
for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
|
| 1390 |
|
| 1391 |
-
return { rules, shadowRules, motions, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
|
| 1392 |
}
|
| 1393 |
|
| 1394 |
/** naive `&` substitution + descendant join */
|
|
|
|
| 45 |
'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
|
| 46 |
'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
|
| 47 |
'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
|
| 48 |
+
// v0.8.0 β the background repeat/attachment words the url() fix exposed:
|
| 49 |
+
// `background: url(a.png) no-repeat` was a false error because "no-repeat"
|
| 50 |
+
// was missing while "repeat" existed
|
| 51 |
+
'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' +
|
| 52 |
'contain,cover,fill,fit-content,min-content,max-content,minmax,repeat,clamp,calc,var,url,env,attr,' +
|
| 53 |
'auto-fill,auto-fit,subgrid,dense,stretch,span,' +
|
| 54 |
'scaleX,scaleY,translateX,translateY,translate,rotate,skew,skewX,skewY,matrix,' +
|
|
|
|
| 83 |
// why `transition-delay: 200` shipped as invalid unitless 200.
|
| 84 |
const TIME_PROPS = new Set([
|
| 85 |
'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
|
| 86 |
+
// v0.8.0 β the animation shorthand takes bare numbers as ms too:
|
| 87 |
+
// `animation: spin 1s infinite` is fine, but `animation: ghost 400` used
|
| 88 |
+
// to become "ghost 400px" (invalid). 400 β 400ms, matching animate:.
|
| 89 |
+
'animation',
|
| 90 |
]);
|
| 91 |
|
| 92 |
// A practical whitelist of standard CSS properties (loud errors beat silent ones)
|
|
|
|
| 224 |
}
|
| 225 |
const inner = raw.slice(i + 1, j - 1);
|
| 226 |
const fname = ident.toLowerCase();
|
| 227 |
+
// v0.8.0 β url() is atomic. The validator used to tokenize INSIDE it,
|
| 228 |
+
// so `url(a.png)` raised "unknown value a.png" (quoted urls worked
|
| 229 |
+
// only because strings were already consumed whole). CSS allows the
|
| 230 |
+
// unquoted form; DSSL passes it through untouched now.
|
| 231 |
+
if (fname === 'url') {
|
| 232 |
+
out += 'url(' + inner.trim() + ')';
|
| 233 |
+
i = j;
|
| 234 |
+
continue;
|
| 235 |
+
}
|
| 236 |
// v0.3.2 fix: repeat(COUNT, tracks) β the leading count is an integer,
|
| 237 |
// never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
|
| 238 |
// which the browser rejected and the whole grid silently collapsed.
|
|
|
|
| 504 |
|
| 505 |
function parseFlexWords(value, ctx, dir) {
|
| 506 |
const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
|
| 507 |
+
// v0.8.0 β axis roles. Every place word used to write BOTH axes, so
|
| 508 |
+
// `row: gap 8 between center` (the guide's own example) compiled to
|
| 509 |
+
// justify-content: space-between followed by justify-content: center β
|
| 510 |
+
// and center won, bunching everything in the middle. Words now declare
|
| 511 |
+
// the axis they are actually about: distribution words (between/around/
|
| 512 |
+
// evenly) own the main axis, stretch/baseline own the cross axis, and
|
| 513 |
+
// center/start/end fill whichever axes are still free. A word whose axes
|
| 514 |
+
// are all taken is a loud warning instead of a silent overwrite.
|
| 515 |
+
const FLEX_AXIS = {
|
| 516 |
+
between: 'main', around: 'main', evenly: 'main',
|
| 517 |
+
stretch: 'cross', baseline: 'cross',
|
| 518 |
+
center: 'both', start: 'both', end: 'both',
|
| 519 |
+
};
|
| 520 |
const atoms = value.split(/\s+/).filter(Boolean);
|
| 521 |
+
let mainSet = null, crossSet = null;
|
| 522 |
for (let k = 0; k < atoms.length; k++) {
|
| 523 |
const a = atoms[k];
|
| 524 |
if (a === 'gap') {
|
|
|
|
| 531 |
if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; }
|
| 532 |
if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; }
|
| 533 |
if (a === 'inline') { out[0].value = 'inline-flex'; continue; }
|
| 534 |
+
if (FLEX_AXIS[a]) {
|
| 535 |
+
const role = FLEX_AXIS[a];
|
| 536 |
+
const pc = PLACE_WORDS[a]; // [cross, main]
|
| 537 |
+
const axisWarn = (prop, prev) => {
|
| 538 |
+
ctx.warnings = ctx.warnings || [];
|
| 539 |
+
ctx.warnings.push(prev === a
|
| 540 |
+
? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir} β the second copy wins`, hint: `drop the duplicate; one distribution word + one alignment word is the whole grammar` }
|
| 541 |
+
: { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win β drop one, or reorder so the distribution word comes first` });
|
| 542 |
+
};
|
| 543 |
+
let wrote = false;
|
| 544 |
+
if (role === 'main' || (role === 'both' && !mainSet)) {
|
| 545 |
+
if (mainSet) axisWarn('justify-content', mainSet);
|
| 546 |
+
out.push({ prop: 'justify-content', value: pc[1] });
|
| 547 |
+
mainSet = a; wrote = true;
|
| 548 |
+
}
|
| 549 |
+
if (role === 'cross' || (role === 'both' && !crossSet)) {
|
| 550 |
+
if (crossSet) axisWarn('align-items', crossSet);
|
| 551 |
+
out.push({ prop: 'align-items', value: pc[0] });
|
| 552 |
+
crossSet = a; wrote = true;
|
| 553 |
+
}
|
| 554 |
+
if (!wrote) {
|
| 555 |
+
ctx.warnings = ctx.warnings || [];
|
| 556 |
+
ctx.warnings.push({
|
| 557 |
+
file: ctx.file, line: ctx.line,
|
| 558 |
+
message: mainSet === a || crossSet === a
|
| 559 |
+
? `"${a}" is written twice in ${dir} β both axes already came from it`
|
| 560 |
+
: `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`,
|
| 561 |
+
hint: 'one distribution word + one alignment word is the whole grammar β the extra word is dropped',
|
| 562 |
+
});
|
| 563 |
+
}
|
| 564 |
continue;
|
| 565 |
}
|
| 566 |
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']) });
|
|
|
|
| 671 |
return out;
|
| 672 |
},
|
| 673 |
fluid(v, ctx) {
|
| 674 |
+
return buildFluidParts(v.split(',').map(s => s.trim()).filter(Boolean), ctx);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
},
|
| 676 |
shadow(v, ctx) {
|
| 677 |
const t = v.trim();
|
|
|
|
| 745 |
const atoms = part.split(/\s+/);
|
| 746 |
const dur = atoms[0];
|
| 747 |
if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
|
| 748 |
+
// v0.8.0 β the CSS habit is property-first (`transition: color .2s
|
| 749 |
+
// ease`). That used to be a generic "not a time" error and the part
|
| 750 |
+
// silently dropped. The error now REWRITES the value into the DSSL
|
| 751 |
+
// order (time Β· easing Β· property) so the fix is a copy-paste.
|
| 752 |
+
const timeAtom = atoms.find(a => /^\d*\.?\d+(ms|s)?$/.test(a));
|
| 753 |
+
const easeAtom = atoms.find(a => EASINGS[a]);
|
| 754 |
+
const propAtom = atoms.find(a => a !== timeAtom && a !== easeAtom && (KNOWN_PROPS.has(a) || DEFORM_TO_TRANSFORM[a]));
|
| 755 |
+
if (timeAtom) {
|
| 756 |
+
const msOut = /^\d+$/.test(timeAtom) ? timeAtom : /ms$/.test(timeAtom) ? timeAtom.replace(/ms$/, '') : String(parseFloat(timeAtom) * 1000);
|
| 757 |
+
ctx.errors.push({
|
| 758 |
+
file: ctx.file, line: ctx.line,
|
| 759 |
+
message: `transition: the CSS form (property first) is not DSSL`,
|
| 760 |
+
hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''} β time first, easing, property last`,
|
| 761 |
+
});
|
| 762 |
+
continue;
|
| 763 |
+
}
|
| 764 |
ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration Β· easing Β· property β try 200 or 0.2s' });
|
| 765 |
continue;
|
| 766 |
}
|
|
|
|
| 940 |
},
|
| 941 |
};
|
| 942 |
|
| 943 |
+
/* v0.8.0 β fluid sizing sugar. `fluid: 16, 24` keeps working (font-size),
|
| 944 |
+
* and now any length-ish property takes it inline: `font-size: fluid 16 24`.
|
| 945 |
+
* Both compile to a clamp() that scales linearly between the viewport bounds
|
| 946 |
+
* (360px and 1200px by default) β the formula everyone writes by hand and
|
| 947 |
+
* gets wrong. */
|
| 948 |
+
const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' +
|
| 949 |
+
'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left').split(','));
|
| 950 |
+
|
| 951 |
+
function buildFluidParts(parts, ctx, prop = 'font-size') {
|
| 952 |
+
const px = (s) => {
|
| 953 |
+
const m = String(s).match(/^([0-9.]+)\s*(px|rem|em|%)?$/);
|
| 954 |
+
if (!m) {
|
| 955 |
+
ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" is not a size`, hint: 'fluid takes two sizes and optionally the viewport bounds β fluid 16 24, or fluid 16 24 360 1200' });
|
| 956 |
+
return '0px';
|
| 957 |
+
}
|
| 958 |
+
return m[2] ? `${m[1]}${m[2]}` : `${m[1]}px`;
|
| 959 |
+
};
|
| 960 |
+
const min = px(parts[0]); const max = px(parts[1]);
|
| 961 |
+
const vmin = px(parts[2] || '360'); const vmax = px(parts[3] || '1200');
|
| 962 |
+
const mv = parseFloat(min), xv = parseFloat(max);
|
| 963 |
+
const slope = ((xv - mv) / (parseFloat(vmax) - parseFloat(vmin))) * 100;
|
| 964 |
+
return [{ prop, value: `clamp(${min}, calc(${min} + ${slope.toFixed(3)}vw), ${max})` }];
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
/* ------------------------------------------------------------------ *
|
| 968 |
* resolveDecl β one declaration β zero or more engine decls
|
| 969 |
* ------------------------------------------------------------------ */
|
| 970 |
|
| 971 |
function resolveDecl(prop, value, ctx) {
|
| 972 |
const errors = ctx.errors;
|
| 973 |
+
const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings };
|
| 974 |
+
|
| 975 |
+
// v0.8.0 β !important does not exist in DSSL. It used to fall through as a
|
| 976 |
+
// value: the compiler said "unknown value important" AND still emitted
|
| 977 |
+
// `red !important`, which the browser then silently dropped as an invalid
|
| 978 |
+
// inline value. Now: one loud error, nothing emitted, and the pin { }
|
| 979 |
+
// answer to the actual question.
|
| 980 |
+
if (hasImportant(value)) {
|
| 981 |
+
errors.push({
|
| 982 |
+
file: ctx.file, line: ctx.line,
|
| 983 |
+
message: `!important does not exist in DSSL β "${prop}: ${String(value).replace(/\s*!\s*important\s*/i, '')}" was NOT applied`,
|
| 984 |
+
hint: 'the cascade is (layer, order) and later wins β put the rule in pin { } when it genuinely must win',
|
| 985 |
+
});
|
| 986 |
+
return [];
|
| 987 |
+
}
|
| 988 |
|
| 989 |
if (prop.startsWith('--')) {
|
| 990 |
// v0.4.0 β scoped tokens. Dotted names are token re-declarations:
|
|
|
|
| 1001 |
const sem = SEMANTIC[prop];
|
| 1002 |
if (sem) return sem(value, ctx2);
|
| 1003 |
|
| 1004 |
+
// v0.8.0 β the animation shorthand passes through (it is legal CSS), but
|
| 1005 |
+
// bare motion names must not pay the generic unknown-value tax: "spin"
|
| 1006 |
+
// used to error with the surreal suggestion "did you mean span?". Motion
|
| 1007 |
+
// names pass silently here and a dedicated warning in the final loop says
|
| 1008 |
+
// the DSSL way (motion + animate) when the name matches nothing.
|
| 1009 |
+
if (prop === 'animation' || prop === 'animation-name') {
|
| 1010 |
+
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }];
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
// v0.8.0 β inline fluid sugar on any length-ish property:
|
| 1014 |
+
// font-size: fluid 16 24 β clamp(16px, calc(16px + 0.952vw), 24px)
|
| 1015 |
+
const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim());
|
| 1016 |
+
if (fluidM && FLUID_PROPS.has(prop)) {
|
| 1017 |
+
return buildFluidParts(fluidM[1].split(/[\s,]+/).filter(Boolean), ctx2, prop);
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
if (!KNOWN_PROPS.has(prop)) {
|
| 1021 |
errors.push({
|
| 1022 |
file: ctx.file, line: ctx.line,
|
|
|
|
| 1028 |
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
|
| 1029 |
}
|
| 1030 |
|
| 1031 |
+
/** string-aware !important scan β quoted content: "!important" must not trip it */
|
| 1032 |
+
function hasImportant(v) {
|
| 1033 |
+
const s = String(v);
|
| 1034 |
+
let q = null;
|
| 1035 |
+
for (let i = 0; i < s.length; i++) {
|
| 1036 |
+
const c = s[i];
|
| 1037 |
+
if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
|
| 1038 |
+
if (c === '"' || c === "'") { q = c; continue; }
|
| 1039 |
+
if (c === '!' && /\s*important/i.test(s.slice(i + 1))) return true;
|
| 1040 |
+
}
|
| 1041 |
+
return false;
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
/* ------------------------------------------------------------------ *
|
| 1045 |
* Media / container / signal normalization
|
| 1046 |
* ------------------------------------------------------------------ */
|
|
|
|
| 1091 |
const errors = [];
|
| 1092 |
const rules = [];
|
| 1093 |
const shadowRules = [];
|
| 1094 |
+
const raws = []; // v0.8.0 β raw { } / @css { } verbatim passthrough blocks
|
| 1095 |
const motions = {};
|
| 1096 |
const behaviors = {};
|
| 1097 |
// v0.7.0 β compile-time warnings: things that compile and run but are
|
|
|
|
| 1131 |
} else if (node.kind === 'motion') {
|
| 1132 |
motions[node.name] = node;
|
| 1133 |
} else {
|
| 1134 |
+
// v0.8.0 β defensive `|| []`: a malformed tree can throw from here
|
| 1135 |
+
// and take the whole stylesheet down (the @font-face crash report).
|
| 1136 |
+
// The parser now skips unknown at-rules whole, but the guard stays.
|
| 1137 |
+
for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]);
|
| 1138 |
}
|
| 1139 |
}
|
| 1140 |
};
|
|
|
|
| 1153 |
const m = label.match(/^at\s+([0-9.]+)\s*%?$/);
|
| 1154 |
return m ? parseFloat(m[1]) : null;
|
| 1155 |
};
|
| 1156 |
+
const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false };
|
| 1157 |
for (const child of node.children) {
|
| 1158 |
if (child.kind === 'decl' && child.prop === 'essential') {
|
| 1159 |
node._parsed.essential = child.value === '' || child.value === 'true';
|
|
|
|
| 1210 |
|
| 1211 |
const resolveTokenDecls = (keys, conds) => {
|
| 1212 |
const decls = [];
|
| 1213 |
+
const ctxBase = { file, line: 0, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false };
|
| 1214 |
for (const key of keys) {
|
| 1215 |
const raw = tokenMap.get(key);
|
| 1216 |
const prop = '--dssl-' + key.replace(/\./g, '-');
|
|
|
|
| 1265 |
bh._active = false;
|
| 1266 |
break;
|
| 1267 |
}
|
| 1268 |
+
const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
|
| 1269 |
for (const r of resolved) {
|
| 1270 |
// v0.7.0 β remember WHICH source property wrote a display value,
|
| 1271 |
// so the ordering linter can tell an explicit "display:" from a
|
|
|
|
| 1358 |
for (const d of node.children) {
|
| 1359 |
if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
|
| 1360 |
if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
|
| 1361 |
+
const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
|
| 1362 |
// v0.5.0 β engine-native properties have no pseudo-element story:
|
| 1363 |
// there is no DOM handle to nudge, animate, stack or stagger. They
|
| 1364 |
// used to leak into the shadow sheet as junk CSS the browser
|
|
|
|
| 1384 |
if (tr) rules.push(tr);
|
| 1385 |
break;
|
| 1386 |
}
|
| 1387 |
+
case 'raw': {
|
| 1388 |
+
// v0.8.0 β verbatim CSS escape hatch (@font-face, @keyframes,
|
| 1389 |
+
// @supports, @import, @propertyβ¦). The parser captured the body
|
| 1390 |
+
// untouched; it rides the same document stylesheet as the
|
| 1391 |
+
// pseudo-element rules and survives `dssl build` verbatim.
|
| 1392 |
+
if ((node.css || '').trim()) raws.push({ css: node.css.trim(), file, line: node.line, order: order++ });
|
| 1393 |
+
break;
|
| 1394 |
+
}
|
| 1395 |
case 'behavior':
|
| 1396 |
case 'motion':
|
| 1397 |
if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` });
|
|
|
|
| 1509 |
flush(rootRule, topCtx, 0);
|
| 1510 |
|
| 1511 |
// parse all motions that were referenced
|
| 1512 |
+
for (const r of [...rules, ...shadowRules]) {
|
| 1513 |
for (const d of r.decls) {
|
| 1514 |
if (d.prop === '@animate' && d.value && d.value.motion) {
|
| 1515 |
const m = parseMotion(d.value.motion);
|
|
|
|
| 1517 |
errors.push({
|
| 1518 |
file, line: d.line,
|
| 1519 |
message: `unknown motion "${d.value.motion}"`,
|
| 1520 |
+
hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } } β then "animate: ${d.value.motion} 400" runs it`,
|
| 1521 |
});
|
| 1522 |
d.value.broken = true;
|
| 1523 |
} else {
|
|
|
|
| 1525 |
d.value.essential = m.essential;
|
| 1526 |
}
|
| 1527 |
}
|
| 1528 |
+
// v0.8.0 β the animation shorthand is CSS passthrough, so a motion
|
| 1529 |
+
// name that matches nothing used to be a SILENT no-op (no @keyframes
|
| 1530 |
+
// can exist in DSSL). Warn with the whole story; if the keyframes come
|
| 1531 |
+
// from raw { } or another stylesheet, the warning says it can be ignored.
|
| 1532 |
+
if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') {
|
| 1533 |
+
const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w));
|
| 1534 |
+
const named = words.find(w => motions[w]);
|
| 1535 |
+
if (words.length && !named) {
|
| 1536 |
+
warnings.push({
|
| 1537 |
+
file, line: d.line,
|
| 1538 |
+
message: `animation "${d.value}" references no motion defined in this sheet β it will not run`,
|
| 1539 |
+
hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400 β @keyframes does not exist in DSSL; raw { } can carry verbatim keyframes`,
|
| 1540 |
+
});
|
| 1541 |
+
}
|
| 1542 |
+
}
|
| 1543 |
}
|
| 1544 |
}
|
| 1545 |
|
| 1546 |
+
// v0.8.0 β pre-parse EVERY motion (not just referenced ones) so the static
|
| 1547 |
+
// emitter can turn the whole motion vocabulary into real @keyframes.
|
| 1548 |
+
const motionFrames = {};
|
| 1549 |
+
for (const name of Object.keys(motions)) {
|
| 1550 |
+
const m = parseMotion(name);
|
| 1551 |
+
if (m && m.frames.length) motionFrames[name] = m;
|
| 1552 |
+
}
|
| 1553 |
+
|
| 1554 |
const mediaQueries = new Set();
|
| 1555 |
for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
|
| 1556 |
|
| 1557 |
+
return { rules, shadowRules, raws, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule };
|
| 1558 |
}
|
| 1559 |
|
| 1560 |
/** naive `&` substitution + descendant join */
|
public/dssl/static.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL β static.js β the exit lane
|
| 3 |
+
*
|
| 4 |
+
* toCSS(doc) compiles a resolved document into a real CSS stylesheet:
|
| 5 |
+
* tokens β :root custom properties (inside @layer tokens)
|
| 6 |
+
* states β native pseudo-classes (:hover, :focus-visible, β¦)
|
| 7 |
+
* @media β as-is Β· @container β as-is
|
| 8 |
+
* layers β native @layer (tokens < base < components < utilities < pin)
|
| 9 |
+
* pin { } β !important (the one must-win block, same as the engine)
|
| 10 |
+
* motions β @keyframes (so `animation: name` shorthand works too)
|
| 11 |
+
* raw { } β verbatim
|
| 12 |
+
* ::before β¦ β shadow-rule output, as-is
|
| 13 |
+
* seen β applied unconditionally (graceful no-JS state)
|
| 14 |
+
*
|
| 15 |
+
* What stays engine-only (skipped, and counted in the report):
|
| 16 |
+
* @signal rules (the JS bridge), `animate:`/engine-native properties
|
| 17 |
+
* (WAAPI lives in the runtime).
|
| 18 |
+
*
|
| 19 |
+
* The point: the SAME source can ship as a .css file when a page needs
|
| 20 |
+
* no-JS, a strict CSP, first-paint-before-JS, or 4.5x smaller HTML.
|
| 21 |
+
* ========================================================================= */
|
| 22 |
+
|
| 23 |
+
const LAYER_RANK = { tokens: 0, base: 1, components: 2, utilities: 3, pin: 4 };
|
| 24 |
+
const LAYER_NAMES = ['tokens', 'base', 'components', 'utilities', 'pin'];
|
| 25 |
+
|
| 26 |
+
const kebab = (p) => p.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
| 27 |
+
|
| 28 |
+
const declLine = (d, pin) => {
|
| 29 |
+
const value = pin && !/^--/.test(d.prop) ? d.value + ' !important' : d.value;
|
| 30 |
+
return ` ${d.prop}: ${value};`;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const isEngineProp = (p) => p && p.startsWith('@');
|
| 34 |
+
|
| 35 |
+
function emitRule(r, { pin = false, pseudo = null } = {}) {
|
| 36 |
+
let sel = r.sel;
|
| 37 |
+
if (pseudo) sel = sel + '::' + pseudo;
|
| 38 |
+
let out = `${sel} {\n${r.decls.map(d => declLine(d, pin)).join('\n')}\n}`;
|
| 39 |
+
for (const q of [...(r.containers || [])].reverse()) out = `@container ${q} {\n${out}\n}`;
|
| 40 |
+
for (const q of [...(r.media || [])].reverse()) out = `@media ${q} {\n${out}\n}`;
|
| 41 |
+
return out;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/** split one rule into { static, engineProps } β static decls survive */
|
| 45 |
+
function splitDecls(decls) {
|
| 46 |
+
const keep = [];
|
| 47 |
+
let engineProps = 0;
|
| 48 |
+
for (const d of decls) {
|
| 49 |
+
if (isEngineProp(d.prop)) { engineProps++; continue; }
|
| 50 |
+
keep.push(d);
|
| 51 |
+
}
|
| 52 |
+
return { keep, engineProps };
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function toCSS(doc, opts = {}) {
|
| 56 |
+
const file = opts.file || 'styles.dssl';
|
| 57 |
+
const report = {
|
| 58 |
+
file,
|
| 59 |
+
rules: 0, shadowRules: 0, keyframes: 0, raws: 0,
|
| 60 |
+
skippedSignals: 0, skippedEngine: 0, seenStripped: 0,
|
| 61 |
+
layers: [],
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const chunks = [];
|
| 65 |
+
const header = opts.banner !== false
|
| 66 |
+
? `/* generated by dssl build from ${file} β the static subset of DSSL v0.8.0.\n @signal rules and animate:/engine-native properties stay in the runtime. */\n`
|
| 67 |
+
: '';
|
| 68 |
+
let out = header;
|
| 69 |
+
|
| 70 |
+
const allRules = [...(doc.registryRule ? [doc.registryRule] : []), ...(doc.rules || [])];
|
| 71 |
+
|
| 72 |
+
// ---- keyframes from motions (name kept, so `animation: name` works) ----
|
| 73 |
+
const motionFrames = doc.motionFrames || {};
|
| 74 |
+
for (const [name, m] of Object.entries(motionFrames)) {
|
| 75 |
+
const frames = m.frames.map(f => {
|
| 76 |
+
const off = f.offset === 0 ? '0%' : f.offset === 100 ? '100%' : `${f.offset}%`;
|
| 77 |
+
const body = Object.entries(f.props).map(([p, v]) => ` ${kebab(p)}: ${v};`).join('\n');
|
| 78 |
+
return `${off} {\n${body}\n}`;
|
| 79 |
+
});
|
| 80 |
+
if (frames.length) {
|
| 81 |
+
chunks.push({ layer: null, css: `@keyframes ${name} {\n${frames.join('\n')}\n}` });
|
| 82 |
+
report.keyframes++;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// ---- regular rules ----
|
| 87 |
+
for (const r of allRules) {
|
| 88 |
+
if (r.signals && r.signals.length) { report.skippedSignals++; continue; }
|
| 89 |
+
const { keep, engineProps } = splitDecls(r.decls || []);
|
| 90 |
+
if (!keep.length) { if (engineProps) report.skippedEngine++; continue; }
|
| 91 |
+
const rule = { ...r, decls: keep };
|
| 92 |
+
// seen β the no-JS graceful state: apply the final style unconditionally
|
| 93 |
+
if (/:seen\b/.test(rule.sel)) {
|
| 94 |
+
rule.sel = rule.sel.replace(/:seen\b/g, '').replace(/\s{2,}/g, ' ').trim();
|
| 95 |
+
report.seenStripped++;
|
| 96 |
+
if (!rule.sel) rule.sel = '*';
|
| 97 |
+
}
|
| 98 |
+
const pin = rule.layer === 'pin';
|
| 99 |
+
chunks.push({ layer: rule.layer, css: emitRule(rule, { pin }), order: rule.order });
|
| 100 |
+
report.rules++;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// ---- pseudo-element rules (the shadow-CSS lane, native in a build) ----
|
| 104 |
+
for (const r of doc.shadowRules || []) {
|
| 105 |
+
if (r.signals && r.signals.length) { report.skippedSignals++; continue; }
|
| 106 |
+
const { keep, engineProps } = splitDecls(r.decls || []);
|
| 107 |
+
if (!keep.length) { if (engineProps) report.skippedEngine++; continue; }
|
| 108 |
+
chunks.push({ layer: r.layer, css: emitRule({ ...r, decls: keep }, { pin: r.layer === 'pin', pseudo: r.pseudo }), order: r.order });
|
| 109 |
+
report.shadowRules++;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// ---- layers: emit the order statement once, then group ----
|
| 113 |
+
const usedLayers = [...new Set(chunks.map(c => c.layer).filter(Boolean))]
|
| 114 |
+
.sort((a, b) => (LAYER_RANK[a] ?? 2) - (LAYER_RANK[b] ?? 2));
|
| 115 |
+
if (usedLayers.length) {
|
| 116 |
+
report.layers = usedLayers;
|
| 117 |
+
out += `@layer ${LAYER_NAMES.join(', ')};\n\n`;
|
| 118 |
+
for (const layer of usedLayers) {
|
| 119 |
+
const inLayer = chunks.filter(c => c.layer === layer).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
| 120 |
+
out += `@layer ${layer} {\n${inLayer.map(c => c.css).join('\n\n')}\n}\n\n`;
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
for (const c of chunks.filter(x => x.layer === null)) out += c.css + '\n\n';
|
| 124 |
+
|
| 125 |
+
// ---- raw { } verbatim ----
|
| 126 |
+
for (const r of doc.raws || []) {
|
| 127 |
+
if (!r.css) continue;
|
| 128 |
+
out += `/* raw @ ${r.file || file}:${r.line} */\n` + r.css + '\n\n';
|
| 129 |
+
report.raws++;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return { css: out.trimEnd() + '\n', report };
|
| 133 |
+
}
|
public/llms.txt
CHANGED
|
@@ -1,28 +1,28 @@
|
|
| 1 |
# DSSL
|
| 2 |
|
| 3 |
-
> DSSL is a style language for the web
|
| 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.
|
| 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">`
|
| 15 |
-
2. Honest trade: it is a JS runtime. If JavaScript is unavailable the page renders unstyled.
|
| 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.
|
| 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`.
|
|
@@ -30,6 +30,10 @@
|
|
| 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
|
|
@@ -38,14 +42,24 @@
|
|
| 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.
|
|
|
|
| 1 |
# DSSL
|
| 2 |
|
| 3 |
+
> DSSL is a style language for the web. For JS-guaranteed pages it replaces writing CSS; it does not replace the CSS platform. 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 at runtime β but v0.8.0 adds the exit lane: `dssl build` compiles the static subset to a real .css file for no-JS, strict-CSP or size-critical pages. 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.8.0 (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">` or embedded in the page as `<style type="text/dssl">` / `<script type="text/dssl">` blocks (auto-booted in document order). The engine 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 β unless you build the static subset: `dssl build styles.dssl -o styles.css` emits real CSS (tokens β custom properties, states β :hover, layers β @layer, motions β @keyframes, `raw { }` verbatim, `seen` applied as its no-JS final state) and the report counts what stays runtime-only (`@signal` rules, `animate:`). For content pages that must not flash, use the build output or choose another tool.
|
| 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. Nested state blocks sort after the parent rule's own declarations (v0.7.2 fix: they used to lose), and nested `@media`/`@signal`/`@layer` keep their condition. `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.8.0)
|
| 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`. v0.8.0 axis roles: distribution words (`between`/`around`/`evenly`) own the main axis, `stretch`/`baseline` own the cross axis, `center`/`start`/`end` fill free axes, and a word with nowhere to go warns instead of overwriting silently.
|
| 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`.
|
|
|
|
| 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 |
+
- Verbatim escape hatch (v0.8.0): `raw { }` / `@css { }` drops real CSS (@font-face, @keyframes, @supports, @import, @property) into the page stylesheet untouched; unknown at-rules skip their whole block with one clean error.
|
| 34 |
+
- Fluid sugar (v0.8.0): `font-size: fluid 16 24` (and any length-ish property) compiles to the linear clamp() between 360px and 1200px viewports; `fluid: 16, 24` still works.
|
| 35 |
+
- v0.8.0 review-driven fixes, all regression-tested incl. headless-Chromium computed-style tests of the docs: row/column axis roles (the documented `between center` example laid out wrong before); @font-face no longer crashes the compiler (unknown at-rules skip balanced); @supports inner rules no longer leak unconditionally; `url(...)` is atomic (unquoted form no longer false-errors); `!important` is a loud error that emits nothing; the animation shorthand warns when its motion is undefined instead of suggesting "span"; CSS-order transition values get the exact DSSL conversion in the error hint.
|
| 36 |
+
- v0.7.2 review-driven fixes, all regression-tested: gate prefilter runs before native matches(); hover state events scope candidate recomputation to the touched subtree plus ancestors (was page-wide); checkbox/details states listen to `change`/`toggle`; raw hex alpha (`#ff0000/50` -> color-mix); bare numbers on time properties are ms; `::part()` supported, `::slotted()` rejected with an explanation; SVG properties (`fill`, `stroke*`) and `text-wrap`/`content-visibility` in the vocabulary.
|
| 37 |
- 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.
|
| 38 |
|
| 39 |
## JS API
|
|
|
|
| 42 |
DSSL.load(['/styles/site.dssl']) // fetch + apply, in order
|
| 43 |
DSSL.apply(src, { scope, name }) // scoped engines for widgets
|
| 44 |
DSSL.compile(src) // parse + resolve, no DOM (powers editors)
|
| 45 |
+
DSSL.toCSS(src) // v0.8.0: the exit lane -> { css, report }
|
| 46 |
DSSL.signal('menuOpen', true) // the JS -> styles bridge
|
| 47 |
DSSL.freeze() / DSSL.unfreeze() // the speed handbrake
|
| 48 |
DSSL.explain(el, { quiet: true }) // matched rules, file/line, winning decls per property
|
| 49 |
DSSL.replay(sel) / DSSL.refresh() / DSSL.errors() / DSSL.version
|
| 50 |
```
|
| 51 |
|
| 52 |
+
## CLI (v0.8.0)
|
| 53 |
+
|
| 54 |
+
```sh
|
| 55 |
+
node cli/dssl.mjs check styles.dssl --strict # CI: exit 1 on errors, --json for machines
|
| 56 |
+
node cli/dssl.mjs build styles.dssl -o styles.css # the static subset as real CSS
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
`check` runs the real compiler with file:line diagnostics and did-you-mean hints. `build` compiles the static subset (tokens β custom properties, states β :hover, layers β @layer, motions β @keyframes, raw { } verbatim, pin β !important, seen β its no-JS final state) and prints a report of what was emitted and what stays runtime-only.
|
| 60 |
+
|
| 61 |
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.
|
| 62 |
|
| 63 |
## Editor support
|
| 64 |
|
| 65 |
+
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. Install `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files. Source lives in `dssl-vscode/` in the repo.
|
public/styles/bench.dssl
CHANGED
|
File without changes
|
public/styles/site.dssl
CHANGED
|
@@ -118,7 +118,8 @@ select { use focus-ring }
|
|
| 118 |
[data-view="cheat"] .nl-cheat,
|
| 119 |
[data-view="spec"] .nl-spec,
|
| 120 |
[data-view="playground"] .nl-playground,
|
| 121 |
-
[data-view="changelog"] .nl-changelog
|
|
|
|
| 122 |
color: color.accent
|
| 123 |
border-bottom-color: color.accent
|
| 124 |
}
|
|
|
|
| 118 |
[data-view="cheat"] .nl-cheat,
|
| 119 |
[data-view="spec"] .nl-spec,
|
| 120 |
[data-view="playground"] .nl-playground,
|
| 121 |
+
[data-view="changelog"] .nl-changelog,
|
| 122 |
+
[data-view="bench"] .nl-bench {
|
| 123 |
color: color.accent
|
| 124 |
border-bottom-color: color.accent
|
| 125 |
}
|
scripts/build-engine-package.sh
CHANGED
|
@@ -6,10 +6,11 @@ ROOT=/home/z/my-project
|
|
| 6 |
STAGE=$(mktemp -d)
|
| 7 |
trap 'rm -rf "$STAGE"' EXIT
|
| 8 |
|
| 9 |
-
mkdir -p "$STAGE/dssl-engine/dssl" "$STAGE/dssl-engine/styles" "$STAGE/dssl-engine/examples"
|
| 10 |
cp "$ROOT"/public/dssl/boot.js "$ROOT"/public/dssl/index.js "$ROOT"/public/dssl/engine.js \
|
| 11 |
-
"$ROOT"/public/dssl/parser.js "$ROOT"/public/dssl/resolver.js "$ROOT"/public/dssl/utils.js \
|
| 12 |
"$STAGE/dssl-engine/dssl/"
|
|
|
|
| 13 |
cp "$ROOT/public/styles/tokens.dssl" "$STAGE/dssl-engine/styles/"
|
| 14 |
cp "$ROOT/engine-package/USAGE.md" "$ROOT/engine-package/install.sh" "$STAGE/dssl-engine/"
|
| 15 |
cp "$ROOT/engine-package/hello.dssl" "$STAGE/dssl-engine/examples/hello.dssl"
|
|
|
|
| 6 |
STAGE=$(mktemp -d)
|
| 7 |
trap 'rm -rf "$STAGE"' EXIT
|
| 8 |
|
| 9 |
+
mkdir -p "$STAGE/dssl-engine/dssl" "$STAGE/dssl-engine/styles" "$STAGE/dssl-engine/examples" "$STAGE/dssl-engine/cli"
|
| 10 |
cp "$ROOT"/public/dssl/boot.js "$ROOT"/public/dssl/index.js "$ROOT"/public/dssl/engine.js \
|
| 11 |
+
"$ROOT"/public/dssl/parser.js "$ROOT"/public/dssl/resolver.js "$ROOT"/public/dssl/static.js "$ROOT"/public/dssl/utils.js \
|
| 12 |
"$STAGE/dssl-engine/dssl/"
|
| 13 |
+
cp "$ROOT/cli/dssl.mjs" "$STAGE/dssl-engine/cli/"
|
| 14 |
cp "$ROOT/public/styles/tokens.dssl" "$STAGE/dssl-engine/styles/"
|
| 15 |
cp "$ROOT/engine-package/USAGE.md" "$ROOT/engine-package/install.sh" "$STAGE/dssl-engine/"
|
| 16 |
cp "$ROOT/engine-package/hello.dssl" "$STAGE/dssl-engine/examples/hello.dssl"
|
scripts/repro-v072.mjs
CHANGED
|
File without changes
|
scripts/repro-v073.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* repro-v073.mjs β reproduce every claim from the 2026-10-23 Claude review
|
| 2 |
+
* of v0.7.2 before touching any code. Green = the bug exists as reported. */
|
| 3 |
+
import { parse, STATE_WORDS } from '../public/dssl/parser.js';
|
| 4 |
+
import { resolve } from '../public/dssl/resolver.js';
|
| 5 |
+
|
| 6 |
+
const compileDoc = (src, file = 'repro.dssl') => {
|
| 7 |
+
const { ast, errors } = parse(src, file);
|
| 8 |
+
const doc = resolve(ast, file);
|
| 9 |
+
return { parseErrors: errors, doc };
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
let found = 0;
|
| 13 |
+
const repro = (name, cond) => {
|
| 14 |
+
if (cond) { found++; console.log(`REPRODUCED ${name}`); }
|
| 15 |
+
else console.log(`not repro'd ${name}`);
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
// --- bug 1: row: gap 8 between center loses "between" --------------------
|
| 19 |
+
{
|
| 20 |
+
const { doc } = compileDoc('toolbar { row: gap 8 between center }');
|
| 21 |
+
const decls = doc.rules[0]?.decls || [];
|
| 22 |
+
const jc = decls.filter(d => d.prop === 'justify-content');
|
| 23 |
+
const ai = decls.filter(d => d.prop === 'align-items');
|
| 24 |
+
console.log(' decls:', decls.map(d => `${d.prop}:${d.value}`).join(' | '));
|
| 25 |
+
repro('bug1: justify-content written twice (last wins = center)', jc.length === 2 && jc[1].value === 'center');
|
| 26 |
+
repro('bug1: bare center also writes align-items (both axes)', ai.length === 1);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// --- bug 2: @font-face crashes the compiler -------------------------------
|
| 30 |
+
{
|
| 31 |
+
let threw = null, out = '';
|
| 32 |
+
try {
|
| 33 |
+
const r = compileDoc('@font-face { font-family: X; src: url(x.woff2) }');
|
| 34 |
+
out = JSON.stringify(r.doc.rules || []);
|
| 35 |
+
} catch (e) { threw = e; }
|
| 36 |
+
repro('bug2: @font-face THROWS instead of compile error', !!threw);
|
| 37 |
+
if (threw) console.log(' throw:', threw.message);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// --- bug 3: @supports leaks inner rule unconditionally --------------------
|
| 41 |
+
{
|
| 42 |
+
const { parseErrors, doc } = compileDoc('@supports (display:grid) { .a { display: grid } }');
|
| 43 |
+
const leaked = (doc.rules || []).some(r => (r.sel || '').includes('a'));
|
| 44 |
+
const atErr = parseErrors.some(e => /@supports/.test(e.message));
|
| 45 |
+
console.log(' parse errors:', parseErrors.map(e => e.message).join(' ; ').slice(0, 120));
|
| 46 |
+
repro('bug3: unknown at-rule errors reported', atErr);
|
| 47 |
+
repro('bug3: inner rule STILL APPLIES unconditionally', leaked);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// --- bug 4: unquoted url() gives false errors ------------------------------
|
| 51 |
+
{
|
| 52 |
+
const { parseErrors, doc } = compileDoc('.a { background: url(a.png) no-repeat }');
|
| 53 |
+
const msgs = parseErrors.map(e => e.message).join(' ; ');
|
| 54 |
+
const badValue = /unknown value "(a\.png|no-repeat)"/.test(msgs);
|
| 55 |
+
const emitted = (doc.rules[0]?.decls || []).some(d => /url\(a\.png\)/.test(d.value));
|
| 56 |
+
console.log(' errors:', msgs.slice(0, 140) || '(none)');
|
| 57 |
+
repro('bug4: false unknown-value errors for unquoted url parts', badValue);
|
| 58 |
+
repro('bug4: declaration still emitted (error + emit confusion)', emitted);
|
| 59 |
+
// control: quoted form is fine
|
| 60 |
+
const q = compileDoc('.a { background: url("a.png") no-repeat }');
|
| 61 |
+
console.log(' quoted errors:', q.parseErrors.map(e => e.message).join(';').slice(0, 100) || '(none)');
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// --- bug 5a: animation: spin 1s infinite -> did you mean "span" -----------
|
| 65 |
+
{
|
| 66 |
+
const { parseErrors } = compileDoc('.a { animation: spin 1s infinite }');
|
| 67 |
+
const silly = parseErrors.some(e => /span/.test(e.message));
|
| 68 |
+
console.log(' errors:', parseErrors.map(e => `${e.message}${e.hint ? ' [' + e.hint + ']' : ''}`).join(' ; ').slice(0, 160));
|
| 69 |
+
repro('bug5a: silly "span" suggestion instead of motion guidance', silly);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// --- bug 5b: !important errors AND still emits ----------------------------
|
| 73 |
+
{
|
| 74 |
+
const { parseErrors, doc } = compileDoc('.a { color: red !important }');
|
| 75 |
+
const wrongErr = parseErrors.some(e => /unknown value "important"/.test(e.message));
|
| 76 |
+
const emitted = (doc.rules[0]?.decls || []).some(d => /important/.test(d.value));
|
| 77 |
+
console.log(' errors:', parseErrors.map(e => e.message).join(';').slice(0, 120));
|
| 78 |
+
console.log(' emitted value:', doc.rules[0]?.decls.map(d => d.value).join('|'));
|
| 79 |
+
repro('bug5b: !important reported as unknown value "important"', wrongErr);
|
| 80 |
+
repro('bug5b: !important still emitted in the value', emitted);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// --- bug 5c: transition CSS form gives no conversion hint ------------------
|
| 84 |
+
{
|
| 85 |
+
const { parseErrors } = compileDoc('.a { transition: color .2s ease }');
|
| 86 |
+
const hint = parseErrors.some(e => /200/.test(e.hint || '') && /transition/.test(e.hint || ''));
|
| 87 |
+
console.log(' errors:', parseErrors.map(e => `${e.message}${e.hint ? ' [' + e.hint + ']' : ''}`).join(' ; ').slice(0, 200) || '(none)');
|
| 88 |
+
repro('bug5c: CSS transition form rejected with no DSSL-form hint', parseErrors.length > 0 && !hint);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
console.log(`\n${found} claim(s) reproduced`);
|
scripts/test-extension-lib.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* test-extension-lib.mjs β functional smoke for the editor extension lib.
|
| 2 |
+
* Confirms the bundled engine copy (lib/engine) + analysis.js still agree:
|
| 3 |
+
* diagnostics, did-you-mean, completions, and the v0.7.2 cascade fix all
|
| 4 |
+
* behave through the exact code the .vsix ships. Run before packaging. */
|
| 5 |
+
import { resolve } from '../dssl-vscode/lib/engine/resolver.js';
|
| 6 |
+
import { parse, STATE_WORDS } from '../dssl-vscode/lib/engine/parser.js';
|
| 7 |
+
import { analyzeWorkspace, diagnosticsFor } from '../dssl-vscode/lib/analysis.js';
|
| 8 |
+
|
| 9 |
+
let pass = 0, fail = 0;
|
| 10 |
+
const ok = (name, cond) => { if (cond) { pass++; console.log(` ok ${name}`); } else { fail++; console.log(`FAIL ${name}`); } };
|
| 11 |
+
const compileDoc = (src, file) => resolve(parse(src, file).ast, file);
|
| 12 |
+
|
| 13 |
+
// 1. the fixed quickstart pattern: nested hover must override base bg
|
| 14 |
+
const quickstart = 'card {\n background: #fff\n hover { background: #f5f0e8 }\n}\n';
|
| 15 |
+
const doc = compileDoc(quickstart, 'test.dssl');
|
| 16 |
+
const base = doc.rules.find(r => r.sel === 'card');
|
| 17 |
+
const hov = doc.rules.find(r => r.sel === 'card:hover');
|
| 18 |
+
ok('nested hover rule exists', !!hov);
|
| 19 |
+
ok('hover overrides base bg', hov && hov.decls.some(d => d.prop === 'background' && d.value === '#f5f0e8'));
|
| 20 |
+
ok('hover sorts AFTER base (same layer)', base && hov && base.layer === hov.layer && hov.order > base.order);
|
| 21 |
+
ok('no invalid hex-alpha in output', JSON.stringify(doc).includes('#f5f0e8') && !JSON.stringify(doc).includes('/14px'));
|
| 22 |
+
|
| 23 |
+
// 2. diagnostics: typo caught with did-you-mean, no false positive on valid code
|
| 24 |
+
const ws = analyzeWorkspace(new Map([['test.dssl', 'card {\n bakground: red\n}\n']]));
|
| 25 |
+
const typoDiags = diagnosticsFor(ws, 'test.dssl');
|
| 26 |
+
ok('typo surfaces a diagnostic', typoDiags.some(d => /bakground|unknown property/i.test(d.message)));
|
| 27 |
+
ok('did-you-mean suggests background', typoDiags.some(d => /background/i.test(d.message)));
|
| 28 |
+
const cleanWs = analyzeWorkspace(new Map([['test.dssl', quickstart]]));
|
| 29 |
+
const cleanDiags = diagnosticsFor(cleanWs, 'test.dssl');
|
| 30 |
+
ok('valid quickstart produces zero errors', !cleanDiags.some(d => d.severity === 'error'));
|
| 31 |
+
|
| 32 |
+
// 3. raw hex alpha now legal (was the /50px bug)
|
| 33 |
+
const hexDoc = compileDoc('card { color: #ff0000/50 }', 'a.dssl');
|
| 34 |
+
ok('hex /50 compiles to color-mix, not /50px', hexDoc.rules[0].decls[0].value.includes('color-mix'));
|
| 35 |
+
|
| 36 |
+
// 4. STATE_WORDS export exists (used for the did-you-mean warning)
|
| 37 |
+
ok('STATE_WORDS exported by bundled parser', STATE_WORDS instanceof Set && STATE_WORDS.has('hover'));
|
| 38 |
+
|
| 39 |
+
// 5. TIME_PROPS fix reachable through the bundle: bare delay becomes ms
|
| 40 |
+
const tDoc = compileDoc('card { transition-delay: 200 }', 't.dssl');
|
| 41 |
+
ok('bare transition-delay becomes 200ms', /200\s*ms|200ms/.test(JSON.stringify(tDoc.rules[0].decls)));
|
| 42 |
+
|
| 43 |
+
console.log(`\n${pass} passed, ${fail} failed`);
|
| 44 |
+
process.exit(fail ? 1 : 0);
|
scripts/test-guide-browser.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/* test-guide-browser.mjs β DOM-level tests for the docs (review item 3).
|
| 3 |
+
*
|
| 4 |
+
* The between-center bug compiled clean and laid out wrong, which is exactly
|
| 5 |
+
* the gap "it compiles" cannot cover. This harness serves the repo over a
|
| 6 |
+
* local port, boots the REAL engine in headless Chromium, applies every
|
| 7 |
+
* GUIDE.md example via the documented <script type="text/dssl"> boot path,
|
| 8 |
+
* and asserts the computed styles the guide promises. Run before any release. */
|
| 9 |
+
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
| 10 |
+
import { createServer } from 'node:http';
|
| 11 |
+
import { extname, join } from 'node:path';
|
| 12 |
+
import { chromium } from 'playwright';
|
| 13 |
+
|
| 14 |
+
const ROOT = '/home/z/my-project';
|
| 15 |
+
const PORT = 8931 + Math.floor(Math.random() * 200);
|
| 16 |
+
|
| 17 |
+
const MIME = { '.js': 'text/javascript', '.html': 'text/html', '.css': 'text/css', '.json': 'application/json' };
|
| 18 |
+
const server = createServer((req, res) => {
|
| 19 |
+
const p = req.url.split('?')[0];
|
| 20 |
+
if (p === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
| 21 |
+
// the site serves /dssl/* out of public/ β mirror that mapping
|
| 22 |
+
const file = p.startsWith('/dssl/')
|
| 23 |
+
? join(ROOT, 'public', p.slice(1))
|
| 24 |
+
: join(ROOT, p === '/' ? 'index.html' : p.slice(1));
|
| 25 |
+
try {
|
| 26 |
+
const body = readFileSync(file);
|
| 27 |
+
res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream', 'access-control-allow-origin': '*' });
|
| 28 |
+
res.end(body);
|
| 29 |
+
} catch {
|
| 30 |
+
res.writeHead(404); res.end('nope');
|
| 31 |
+
}
|
| 32 |
+
});
|
| 33 |
+
await new Promise(r => server.listen(PORT, '127.0.0.1', r));
|
| 34 |
+
|
| 35 |
+
// the five GUIDE.md blocks, verbatim, applied through the auto-boot path
|
| 36 |
+
const guide = readFileSync(join(ROOT, 'GUIDE.md'), 'utf8');
|
| 37 |
+
const blocks = [...guide.matchAll(/```dssl\n([\s\S]*?)```/g)].map(m => m[1]);
|
| 38 |
+
if (blocks.length < 5) { console.error('expected 5 guide blocks, found ' + blocks.length); process.exit(1); }
|
| 39 |
+
|
| 40 |
+
const html = `<!doctype html><html><head><meta charset="utf8">
|
| 41 |
+
<style>body { margin: 0 } .board { width: 600px; container-type: inline-size } .gallery { width: 400px }</style>
|
| 42 |
+
</head><body>
|
| 43 |
+
<div class="card">card</div>
|
| 44 |
+
<div class="toolbar"><span>a</span><span>b</span></div>
|
| 45 |
+
<div class="sidebar"><span>a</span><span>b</span></div>
|
| 46 |
+
<div class="board"><span>1</span><span>2</span><span>3</span><span>4</span></div>
|
| 47 |
+
<div class="gallery"><span>1</span><span>2</span><span>3</span></div>
|
| 48 |
+
<div class="modal"><span>mid</span></div>
|
| 49 |
+
<div class="hero"><span>hero</span></div>
|
| 50 |
+
${blocks.map(b => `<script type="text/dssl">\n${b}\n</script>`).join('\n')}
|
| 51 |
+
<script type="module">
|
| 52 |
+
import '/dssl/index.js';
|
| 53 |
+
</script>
|
| 54 |
+
</body></html>`;
|
| 55 |
+
|
| 56 |
+
let pass = 0, fail = 0;
|
| 57 |
+
const ok = (name, cond, extra) => {
|
| 58 |
+
if (cond) { pass++; console.log(` ok ${name}`); }
|
| 59 |
+
else { fail++; console.log(`FAIL ${name}${extra ? ' β ' + extra : ''}`); }
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
// served over the local server so module imports are same-origin
|
| 63 |
+
const harnessPath = join(ROOT, '.guide-harness.html');
|
| 64 |
+
writeFileSync(harnessPath, html);
|
| 65 |
+
const browser = await chromium.launch();
|
| 66 |
+
const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
|
| 67 |
+
const consoleErrors = [];
|
| 68 |
+
page.on('console', m => { if (m.type() === 'error' || m.type() === 'warning') consoleErrors.push(m.type() + ': ' + m.text()); });
|
| 69 |
+
page.on('pageerror', e => consoleErrors.push('pageerror: ' + e.message));
|
| 70 |
+
|
| 71 |
+
await page.goto(`http://127.0.0.1:${PORT}/.guide-harness.html`, { waitUntil: 'domcontentloaded' });
|
| 72 |
+
await page.waitForFunction(() => window.DSSL && window.__dsslBooted, { timeout: 8000 }).catch(() => {});
|
| 73 |
+
await page.waitForTimeout(600); // IntersectionObserver + animations settle
|
| 74 |
+
|
| 75 |
+
const cs = (sel, prop, pseudo) => page.evaluate(([sel, prop, pseudo]) => {
|
| 76 |
+
const el = document.querySelector(sel);
|
| 77 |
+
if (!el) return null;
|
| 78 |
+
return getComputedStyle(el, pseudo || null).getPropertyValue(prop).trim();
|
| 79 |
+
}, [sel, prop, pseudo]);
|
| 80 |
+
|
| 81 |
+
/* block 1 β tokens + card base. Note: the guide composes β block 4 adds
|
| 82 |
+
* @media 768 { padding: 24 }, which legitimately wins at 1200px wide. */
|
| 83 |
+
ok('B1 card background is the token ink value', (await cs('.card', 'background-color')) === 'rgb(22, 22, 29)', await cs('.card', 'background-color'));
|
| 84 |
+
ok('B1 card padding at 1200px is block 4\'s @media 768 value (24px)', (await cs('.card', 'padding')) === '24px', await cs('.card', 'padding'));
|
| 85 |
+
ok('B1 card radius is radius.md (14px)', (await cs('.card', 'border-radius')) === '14px', await cs('.card', 'border-radius'));
|
| 86 |
+
ok('B1 card shadow resolves the md token (0 2 4 + alpha)', (await cs('.card', 'box-shadow')).includes('0px 2px 4px'), await cs('.card', 'box-shadow'));
|
| 87 |
+
|
| 88 |
+
/* block 2 β the layout words (the between-center bug lives here) */
|
| 89 |
+
ok('B2 toolbar justify-content space-between (the review bug)', (await cs('.toolbar', 'justify-content')) === 'space-between', await cs('.toolbar', 'justify-content'));
|
| 90 |
+
ok('B2 toolbar align-items center survives the same line', (await cs('.toolbar', 'align-items')) === 'center', await cs('.toolbar', 'align-items'));
|
| 91 |
+
ok('B2 toolbar is flex row with 8px gap', (await cs('.toolbar', 'display')) === 'flex' && (await cs('.toolbar', 'gap')) === '8px');
|
| 92 |
+
ok('B2 sidebar is a column with 16px gap', (await cs('.sidebar', 'flex-direction')) === 'column' && (await cs('.sidebar', 'gap')) === '16px');
|
| 93 |
+
const boardCols = await cs('.board', 'grid-template-columns');
|
| 94 |
+
ok('B2 board is a grid (auto-fill 220 form wins as the later rule)', (await cs('.board', 'display')) === 'grid' && boardCols.split(' ').length >= 2, boardCols);
|
| 95 |
+
ok('B2 gallery grid: 2 cols, rows 3, gap 8', (await cs('.gallery', 'grid-template-columns')).split(' ').length === 2 && (await cs('.gallery', 'gap')) === '8px', `${await cs('.gallery', 'grid-template-columns')} / ${await cs('.gallery', 'gap')}`);
|
| 96 |
+
const modalAI = await cs('.modal', 'align-items'), modalJI = await cs('.modal', 'justify-items');
|
| 97 |
+
ok('B2 modal stack centers both axes', modalAI === 'center' && modalJI === 'center', `${modalAI} / ${modalJI}`);
|
| 98 |
+
const heroPlace = await cs('.hero', 'place-items');
|
| 99 |
+
ok('B2 hero center: both centers everything', (heroPlace === 'center center') || (await cs('.hero', 'display')) === 'grid', heroPlace);
|
| 100 |
+
|
| 101 |
+
/* block 3 β hover beats base, by order (the 0.7.2 headline fix) */
|
| 102 |
+
const before = await cs('.card', 'background-color');
|
| 103 |
+
await page.hover('.card');
|
| 104 |
+
await page.waitForTimeout(120);
|
| 105 |
+
const after = await cs('.card', 'background-color');
|
| 106 |
+
ok('B3 hover changes the card background (real mouse)', before !== after && after === 'rgb(58, 47, 38)', `${before} -> ${after}`);
|
| 107 |
+
await page.mouse.move(0, 0);
|
| 108 |
+
await page.waitForTimeout(120);
|
| 109 |
+
ok('B3 un-hoving restores the base', (await cs('.card', 'background-color')) === before);
|
| 110 |
+
|
| 111 |
+
/* block 4 β media conditions */
|
| 112 |
+
await page.emulateMedia({ colorScheme: 'dark' });
|
| 113 |
+
await page.waitForTimeout(150);
|
| 114 |
+
ok('B4 dark mode applies (#181310)', (await cs('.card', 'background-color')) === 'rgb(24, 19, 16)', await cs('.card', 'background-color'));
|
| 115 |
+
await page.setViewportSize({ width: 700, height: 900 });
|
| 116 |
+
await page.waitForTimeout(150);
|
| 117 |
+
ok('B4 below 768 the wide padding is gone', (await cs('.card', 'padding')) === '16px', await cs('.card', 'padding'));
|
| 118 |
+
await page.setViewportSize({ width: 900, height: 900 });
|
| 119 |
+
await page.waitForTimeout(150);
|
| 120 |
+
ok('B4 at 900px padding: 24 applies', (await cs('.card', 'padding')) === '24px', await cs('.card', 'padding'));
|
| 121 |
+
await page.emulateMedia({ colorScheme: 'light' });
|
| 122 |
+
await page.setViewportSize({ width: 1200, height: 900 });
|
| 123 |
+
await page.waitForTimeout(150);
|
| 124 |
+
|
| 125 |
+
/* block 5 β seen + hover animations (WAAPI) */
|
| 126 |
+
const animCount = await page.evaluate(() => {
|
| 127 |
+
const el = document.querySelector('.card');
|
| 128 |
+
el.scrollIntoView();
|
| 129 |
+
return new Promise(res => setTimeout(() => res(el.getAnimations().length), 400));
|
| 130 |
+
});
|
| 131 |
+
ok('B5 seen fires a real animation on the card', animCount > 0, 'animations: ' + animCount);
|
| 132 |
+
await page.hover('.card');
|
| 133 |
+
await page.waitForTimeout(120);
|
| 134 |
+
const liftAnims = await page.evaluate(() => document.querySelector('.card').getAnimations().length);
|
| 135 |
+
ok('B5 hover plays the lift motion', liftAnims > 0, 'animations on hover: ' + liftAnims);
|
| 136 |
+
|
| 137 |
+
ok('no console errors or warnings during the whole run', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | '));
|
| 138 |
+
|
| 139 |
+
await browser.close();
|
| 140 |
+
server.close();
|
| 141 |
+
console.log(`\n${pass} passed, ${fail} failed`);
|
| 142 |
+
process.exit(fail ? 1 : 0);
|
scripts/test-guide-examples.mjs
CHANGED
|
File without changes
|
scripts/test-v072.mjs
CHANGED
|
@@ -131,7 +131,10 @@ const declsOf = (doc, sel) => doc.rules.filter(r => r.sel === sel).flatMap(r =>
|
|
| 131 |
}
|
| 132 |
/* 12 β version */
|
| 133 |
{
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
ok(STATE_WORDS.has('hover') && STATE_WORDS.has('seen'), 'STATE_WORDS exported');
|
| 136 |
}
|
| 137 |
|
|
|
|
| 131 |
}
|
| 132 |
/* 12 β version */
|
| 133 |
{
|
| 134 |
+
// v0.8.0 β pin loosened the same way v070's was: the suite must survive
|
| 135 |
+
// later releases, but catch an accidental DOWNGRADE below 0.7.2.
|
| 136 |
+
const [maj, min, pat] = String(VERSION).split('.').map(Number);
|
| 137 |
+
ok(maj === 0 && (min > 7 || (min === 7 && pat >= 2)), 'engine VERSION is >= 0.7.2 (got ' + VERSION + ')');
|
| 138 |
ok(STATE_WORDS.has('hover') && STATE_WORDS.has('seen'), 'STATE_WORDS exported');
|
| 139 |
}
|
| 140 |
|
scripts/test-v073.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* test-v073.mjs β one assertion per fix in the 0.8.0 "the exit drop".
|
| 2 |
+
* Source: the 2026-10-23 Claude review (5 bugs) + wishlist items 1/3/4/6/8.
|
| 3 |
+
* Every claim was reproduced first (scripts/repro-v073.mjs), these assert the
|
| 4 |
+
* fixes and the new features against the real compiler. */
|
| 5 |
+
import { parse } from '../public/dssl/parser.js';
|
| 6 |
+
import { resolve } from '../public/dssl/resolver.js';
|
| 7 |
+
import { toCSS } from '../public/dssl/static.js';
|
| 8 |
+
import { execFileSync } from 'node:child_process';
|
| 9 |
+
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
| 10 |
+
import { tmpdir } from 'node:os';
|
| 11 |
+
import { join } from 'node:path';
|
| 12 |
+
|
| 13 |
+
let pass = 0, fail = 0;
|
| 14 |
+
const ok = (name, cond, extra) => {
|
| 15 |
+
if (cond) { pass++; console.log(` ok ${name}`); }
|
| 16 |
+
else { fail++; console.log(`FAIL ${name}${extra ? ' β ' + extra : ''}`); }
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const compileDoc = (src, file = 't.dssl') => {
|
| 20 |
+
const { ast, errors } = parse(src, file);
|
| 21 |
+
const doc = resolve(ast, file);
|
| 22 |
+
return { doc, errors: [...errors, ...doc.errors] };
|
| 23 |
+
};
|
| 24 |
+
const declsOf = (doc, i = 0) => doc.rules[i]?.decls || [];
|
| 25 |
+
const count = (decls, prop) => decls.filter(d => d.prop === prop).length;
|
| 26 |
+
const val = (decls, prop) => decls.find(d => d.prop === prop)?.value;
|
| 27 |
+
const lastVal = (decls, prop) => decls.filter(d => d.prop === prop).pop()?.value;
|
| 28 |
+
|
| 29 |
+
/* ---- bug 1: row/column axis roles ---- */
|
| 30 |
+
{
|
| 31 |
+
const { doc, errors } = compileDoc('toolbar { row: gap 8 between center }');
|
| 32 |
+
const d = declsOf(doc);
|
| 33 |
+
ok('1a. between center: justify-content is space-between, written once', count(d, 'justify-content') === 1 && val(d, 'justify-content') === 'space-between');
|
| 34 |
+
ok('1b. between center: align-items is center', val(d, 'align-items') === 'center');
|
| 35 |
+
ok('1c. between center: zero errors', errors.length === 0);
|
| 36 |
+
const w = compileDoc('toolbar { row: gap 8 center between }');
|
| 37 |
+
ok('1d. center between: warns about the justify-content override', w.doc.warnings.some(x => /overrides "center"/.test(x.message)));
|
| 38 |
+
ok('1e. center between: space-between still wins (later word, cascade takes last)', lastVal(declsOf(w.doc), 'justify-content') === 'space-between');
|
| 39 |
+
const dupe = compileDoc('toolbar { row: center center }');
|
| 40 |
+
ok('1f. center center: duplicate-word warning', dupe.doc.warnings.some(x => /written twice/.test(x.message)));
|
| 41 |
+
const nowhere = compileDoc('toolbar { row: start end }');
|
| 42 |
+
ok('1g. start end: "end" has nowhere to go, loudly', nowhere.doc.warnings.some(x => /nowhere to go/.test(x.message)));
|
| 43 |
+
const stretch = compileDoc('toolbar { row: gap 8 stretch }');
|
| 44 |
+
ok('1h. row: stretch sets align-items only (no justify-content filler)', count(declsOf(stretch.doc), 'align-items') === 1 && count(declsOf(stretch.doc), 'justify-content') === 0);
|
| 45 |
+
const stack = compileDoc('.wrap { stack: center }');
|
| 46 |
+
ok('1i. stack: center unchanged (place-items center center)', val(declsOf(stack.doc), 'place-items') === 'center center');
|
| 47 |
+
const col = compileDoc('.list { column: gap 2 evenly stretch }');
|
| 48 |
+
const cd = declsOf(col.doc);
|
| 49 |
+
ok('1j. column evenly stretch: main space-evenly + cross stretch', val(cd, 'justify-content') === 'space-evenly' && val(cd, 'align-items') === 'stretch');
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* ---- bug 2: @font-face must not throw ---- */
|
| 53 |
+
{
|
| 54 |
+
let threw = null, doc = null, errors = [];
|
| 55 |
+
try {
|
| 56 |
+
const r = compileDoc('@font-face { font-family: X; src: url(x.woff2) }');
|
| 57 |
+
doc = r.doc; errors = r.errors;
|
| 58 |
+
} catch (e) { threw = e; }
|
| 59 |
+
ok('2a. @font-face does not throw', !threw, threw && threw.message);
|
| 60 |
+
ok('2b. @font-face reports exactly one clean error', errors.length === 1 && /@font-face/.test(errors[0].message));
|
| 61 |
+
ok('2c. @font-face inner decls leak nowhere', !doc.rules.some(r => r.decls?.some(d => d.prop === 'src' || d.prop === 'font-family')));
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* ---- bug 3: unknown at-rules skip their whole block ---- */
|
| 65 |
+
{
|
| 66 |
+
const { doc, errors } = compileDoc('@supports (display:grid) { .a { display: grid } }\n.b { color: blue }');
|
| 67 |
+
ok('3a. @supports inner rule does NOT apply unconditionally', !doc.rules.some(r => r.sel === 'a' || r.sel === '.a'));
|
| 68 |
+
ok('3b. one clean error for @supports (no stray unexpected })', errors.length === 1 && /@supports/.test(errors[0].message));
|
| 69 |
+
ok('3c. the rule after the block still parses', doc.rules.some(r => (r.sel || '').includes('b')));
|
| 70 |
+
ok('3d. the hint points at raw { }', /raw \{ \}/.test(errors[0].hint || ''));
|
| 71 |
+
const unclosed = compileDoc('@font-face { font-family: X');
|
| 72 |
+
ok('3e. unclosed unknown block says so instead of eating the file', unclosed.errors.some(e => /never closed/.test(e.message)));
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* ---- bug 4: url() is atomic ---- */
|
| 76 |
+
{
|
| 77 |
+
const { doc, errors } = compileDoc('.a { background: url(a.png) no-repeat }');
|
| 78 |
+
ok('4a. unquoted url() + no-repeat: zero errors', errors.length === 0);
|
| 79 |
+
ok('4b. value passes through untouched', val(declsOf(doc), 'background') === 'url(a.png) no-repeat');
|
| 80 |
+
const q = compileDoc('.a { background: url("a.png") no-repeat center / cover }');
|
| 81 |
+
ok('4c. quoted url + position/size also clean', q.errors.length === 0 && /url\("a\.png"\) no-repeat center \/ cover/.test(val(declsOf(q.doc), 'background')));
|
| 82 |
+
const s = compileDoc('.a { background-image: url(img/hero.png), linear-gradient(90, red -> blue) }');
|
| 83 |
+
ok('4d. unquoted url inside a list with a gradient: clean', s.errors.length === 0);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/* ---- bug 5a: animation gets motion guidance, not "span" ---- */
|
| 87 |
+
{
|
| 88 |
+
const defined = compileDoc('motion spin { from { opacity: 0 } to { opacity: 1 } }\n.a { animation: spin 1s infinite }');
|
| 89 |
+
ok('5a-1. animation with a defined motion: silent', defined.errors.length === 0 && defined.doc.warnings.length === 0);
|
| 90 |
+
const missing = compileDoc('.a { animation: ghost 1s infinite }');
|
| 91 |
+
ok('5a-2. animation with an undefined motion: warning names the DSSL way', missing.doc.warnings.some(w => /motion/.test(w.message) && /animate/.test(w.hint || '')));
|
| 92 |
+
ok('5a-3. no "span" suggestion anywhere', !JSON.stringify(missing.errors).includes('span'));
|
| 93 |
+
ok('5a-4. bare duration becomes ms, not px', /400ms/.test((() => { const r = compileDoc('.a { animation: ghost 400 }'); return val(declsOf(r.doc), 'animation'); })()));
|
| 94 |
+
const animateHint = compileDoc('.a { animate: spin 400 }');
|
| 95 |
+
ok('5a-5. unknown motion hint teaches the motion block', /motion spin \{/.test(animateHint.errors[0].hint || ''));
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* ---- bug 5b: !important is loud and NOT emitted ---- */
|
| 99 |
+
{
|
| 100 |
+
const { doc, errors } = compileDoc('.a { color: red !important }');
|
| 101 |
+
ok('5b-1. !important errors', errors.length === 1 && /!important does not exist/.test(errors[0].message));
|
| 102 |
+
ok('5b-2. the error names pin { } as the answer', /pin \{ \}/.test(errors[0].hint || ''));
|
| 103 |
+
ok('5b-3. nothing is emitted', declsOf(doc).length === 0);
|
| 104 |
+
const quoted = compileDoc('.a { content: "!important" }');
|
| 105 |
+
ok('5b-4. quoted "!important" is just text, no false trip', quoted.errors.length === 0 && val(declsOf(quoted.doc), 'content') === '"!important"');
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* ---- bug 5c: CSS transition form teaches the DSSL form ---- */
|
| 109 |
+
{
|
| 110 |
+
const { errors } = compileDoc('.a { transition: color .2s ease }');
|
| 111 |
+
ok('5c-1. CSS transition form errors (not silently dropped)', errors.length === 1);
|
| 112 |
+
ok('5c-2. the hint shows the exact conversion', /transition: 200 ease color/.test(errors[0].hint || ''));
|
| 113 |
+
const dssl = compileDoc('.a { transition: 200 ease color }');
|
| 114 |
+
ok('5c-3. the DSSL form still compiles clean', dssl.errors.length === 0 && val(declsOf(dssl.doc), 'transition') === 'color 200ms ease');
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/* ---- feature: raw { } / @css { } verbatim passthrough ---- */
|
| 118 |
+
{
|
| 119 |
+
const one = compileDoc('raw { @font-face { font-family: X; src: url(x.woff2) } }');
|
| 120 |
+
ok('6a. raw { } captured verbatim', one.doc.raws.length === 1 && one.doc.raws[0].css.includes('@font-face') && one.doc.raws[0].css.includes('url(x.woff2)'));
|
| 121 |
+
const alias = compileDoc('@css { @keyframes x { from { opacity: 0 } } }');
|
| 122 |
+
ok('6b. @css alias works', alias.doc.raws.length === 1 && alias.doc.raws[0].css.includes('@keyframes x'));
|
| 123 |
+
const multi = compileDoc('raw {\n @supports (display: grid) {\n .a { display: grid }\n }\n}\n.b { color: red }');
|
| 124 |
+
ok('6c. multi-line raw captured whole, parsing resumes after', multi.doc.raws.length === 1 && multi.doc.raws[0].css.includes('@supports') && multi.doc.rules.some(r => (r.sel || '').includes('b')));
|
| 125 |
+
const nested = compileDoc('.card { raw { a } }');
|
| 126 |
+
ok('6d. nested raw is a loud error', nested.errors.some(e => /raw \{ \} must be top-level/.test(e.message)));
|
| 127 |
+
const unclosed = compileDoc('raw { @font-face {');
|
| 128 |
+
ok('6e. unclosed raw is a loud error', unclosed.errors.some(e => /raw \{ \} block was never closed/.test(e.message)));
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
/* ---- feature: fluid sugar ---- */
|
| 132 |
+
{
|
| 133 |
+
const inline = compileDoc('.a { font-size: fluid 16 24 }');
|
| 134 |
+
ok('7a. font-size: fluid 16 24 β clamp', val(declsOf(inline.doc), 'font-size') === 'clamp(16px, calc(16px + 0.952vw), 24px)');
|
| 135 |
+
const bounds = compileDoc('.a { font-size: fluid 16 24 400 800 }');
|
| 136 |
+
ok('7b. custom viewport bounds change the slope', val(declsOf(bounds.doc), 'font-size') === 'clamp(16px, calc(16px + 2.000vw), 24px)');
|
| 137 |
+
const gap = compileDoc('.a { gap: fluid 8 16 }');
|
| 138 |
+
ok('7c. gap takes inline fluid too', val(declsOf(gap.doc), 'gap') === 'clamp(8px, calc(8px + 0.952vw), 16px)');
|
| 139 |
+
const legacy = compileDoc('.a { fluid: 16, 24 }');
|
| 140 |
+
ok('7d. the fluid: 16, 24 form still works', val(declsOf(legacy.doc), 'font-size') === 'clamp(16px, calc(16px + 0.952vw), 24px)');
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* ---- feature: toCSS β the exit lane ---- */
|
| 144 |
+
{
|
| 145 |
+
const src = `tokens { color { ink: #16161d } }
|
| 146 |
+
@layer utilities { .toolbar { row: gap 8 between center } }
|
| 147 |
+
.card {
|
| 148 |
+
background: color.card
|
| 149 |
+
hover { background: #f5f0e8 }
|
| 150 |
+
seen { opacity: 1 }
|
| 151 |
+
display: grid
|
| 152 |
+
animate: rise 400
|
| 153 |
+
::before { content: "β" }
|
| 154 |
+
}
|
| 155 |
+
pin { .legal { color: red } }
|
| 156 |
+
@media dark { .card { background: #111 } }
|
| 157 |
+
@signal theme == dark { .card { background: #000 } }
|
| 158 |
+
motion rise { from { opacity: 0 } to { opacity: 1 } }
|
| 159 |
+
raw { @font-face { font-family: X; src: url(x.woff2) } }`;
|
| 160 |
+
const { ast, errors } = parse(src, 'build.dssl');
|
| 161 |
+
const doc = resolve(ast, 'build.dssl');
|
| 162 |
+
const built = toCSS(doc, { file: 'build.dssl', banner: false });
|
| 163 |
+
const css = built.css;
|
| 164 |
+
ok('8a. @layer statement declares the engine order', css.includes('@layer tokens, base, components, utilities, pin;'));
|
| 165 |
+
ok('8b. tokens become :root custom properties', css.includes('--dssl-color-ink: #16161d'));
|
| 166 |
+
ok('8c. nested hover becomes .card:hover', /\.card:hover\s*\{[^}]*#f5f0e8/.test(css));
|
| 167 |
+
ok('8d. seen applies unconditionally in the build', /\.card\s*\{[^}]*opacity: 1/.test(css) && !/\.card:seen/.test(css));
|
| 168 |
+
ok('8e. ::before is native CSS in the build', /\.card::before\s*\{[^}]*content: "β"/.test(css));
|
| 169 |
+
ok('8f. pin becomes !important', /\.legal\s*\{[^}]*color: red !important/.test(css));
|
| 170 |
+
ok('8g. @media dark wraps the rule', css.includes('@media (prefers-color-scheme: dark)'));
|
| 171 |
+
ok('8h. motion becomes @keyframes rise', css.includes('@keyframes rise'));
|
| 172 |
+
ok('8i. raw { } lands verbatim', css.includes('@font-face { font-family: X; src: url(x.woff2) }'));
|
| 173 |
+
ok('8i-2. animate-only decls are dropped, static ones kept', /\.card\s*\{[^}]*display: grid/.test(css) && !css.includes('@animate'));
|
| 174 |
+
ok('8j. @signal rules are skipped and counted', built.report.skippedSignals === 1 && !css.includes('theme == dark'));
|
| 175 |
+
ok('8k. report counts what shipped', built.report.rules >= 5 && built.report.keyframes === 1 && built.report.raws === 1 && built.report.seenStripped === 1);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/* ---- feature: the CLI ---- */
|
| 179 |
+
{
|
| 180 |
+
const dir = mkdtempSync(join(tmpdir(), 'dssl-cli-'));
|
| 181 |
+
const cleanFile = join(dir, 'clean.dssl');
|
| 182 |
+
const badFile = join(dir, 'bad.dssl');
|
| 183 |
+
const warnFile = join(dir, 'warn.dssl');
|
| 184 |
+
writeFileSync(cleanFile, '.a { color: red\n hover { color: blue } }\n');
|
| 185 |
+
writeFileSync(badFile, '.a { color: red !important }\n');
|
| 186 |
+
writeFileSync(warnFile, '.a { animation: ghost 400 }\n');
|
| 187 |
+
const cli = join(import.meta.dirname, '..', 'cli', 'dssl.mjs');
|
| 188 |
+
const run = (args) => {
|
| 189 |
+
try { return { out: execFileSync('node', [cli, ...args], { encoding: 'utf8' }), code: 0 }; }
|
| 190 |
+
catch (e) { return { out: (e.stdout || '') + (e.stderr || ''), code: e.status }; }
|
| 191 |
+
};
|
| 192 |
+
const c0 = run(['check', cleanFile]);
|
| 193 |
+
ok('9a. check clean file exits 0', c0.code === 0);
|
| 194 |
+
const c1 = run(['check', badFile]);
|
| 195 |
+
ok('9b. check file with errors exits 1', c1.code === 1 && /!important/.test(c1.out));
|
| 196 |
+
const cj = JSON.parse(run(['check', badFile, '--json']).out);
|
| 197 |
+
ok('9c. check --json is machine-readable', cj.files[0].ok === false && cj.files[0].errors[0].line === 1);
|
| 198 |
+
const cs = run(['check', warnFile, '--strict']);
|
| 199 |
+
ok('9d. check --strict makes warnings fatal', cs.code === 1);
|
| 200 |
+
const cw = run(['check', warnFile]);
|
| 201 |
+
ok('9e. check without --strict: warnings are not fatal', cw.code === 0);
|
| 202 |
+
const cssOut = join(dir, 'out.css');
|
| 203 |
+
const b = run(['build', cleanFile, '-o', cssOut]);
|
| 204 |
+
ok('9f. build writes a .css file and reports', b.code === 0 && /\.css/.test(b.out));
|
| 205 |
+
const built = readFileSync(cssOut, 'utf8');
|
| 206 |
+
ok('9g. built CSS contains the hover rule', /\.a:hover\s*\{[^}]*blue/.test(built));
|
| 207 |
+
const bj = JSON.parse(run(['build', cleanFile, '--json']).out);
|
| 208 |
+
ok('9h. build --json carries css + report', typeof bj.css === 'string' && bj.report.rules >= 1);
|
| 209 |
+
const missing = run(['check', join(dir, 'nope.dssl')]);
|
| 210 |
+
ok('9i. missing file exits 2', missing.code === 2);
|
| 211 |
+
const v = run(['--version']);
|
| 212 |
+
ok('9j. --version prints 0.8.0', v.out.trim() === '0.8.0');
|
| 213 |
+
rmSync(dir, { recursive: true, force: true });
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
console.log(`\n${pass} passed, ${fail} failed`);
|
| 217 |
+
process.exit(fail ? 1 : 0);
|
src/components/dssl/SiteRoot.tsx
CHANGED
|
@@ -122,7 +122,7 @@ export default function SiteRoot() {
|
|
| 122 |
</nav>
|
| 123 |
|
| 124 |
<div className="nav-side">
|
| 125 |
-
<a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.
|
| 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>
|
|
@@ -151,7 +151,7 @@ export default function SiteRoot() {
|
|
| 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.
|
| 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.
|
|
|
|
| 122 |
</nav>
|
| 123 |
|
| 124 |
<div className="nav-side">
|
| 125 |
+
<a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.8.0</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>
|
|
|
|
| 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.8.0 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
CHANGED
|
File without changes
|
src/components/dssl/views/ChangelogView.tsx
CHANGED
|
@@ -11,6 +11,50 @@ const ENTRIES: {
|
|
| 11 |
code?: string;
|
| 12 |
kind?: "spec" | "vote" | "add";
|
| 13 |
}[] = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
{
|
| 15 |
version: "v0.7.1",
|
| 16 |
date: "growth phase Β· the credibility drop",
|
|
|
|
| 11 |
code?: string;
|
| 12 |
kind?: "spec" | "vote" | "add";
|
| 13 |
}[] = [
|
| 14 |
+
{
|
| 15 |
+
version: "v0.8.0",
|
| 16 |
+
date: "growth phase Β· the exit drop",
|
| 17 |
+
title: "A second review found five bugs. The answer fixes them and adds the exit lane.",
|
| 18 |
+
why: "another external review landed with repro snippets: a layout word bug that compiled clean and laid out wrong, an at-rule that crashed the compiler, an @supports leak, url() false errors, and a wishlist that said build-a-CSS-exit first. Every claim was reproduced against the real compiler before anything moved, the same discipline as last time",
|
| 19 |
+
kind: "spec",
|
| 20 |
+
body:
|
| 21 |
+
"The layout bug was the worst kind: silent and documented. row: gap 8 between center β the guide's own example β compiled with zero errors and lost the between, because every place word wrote BOTH axes and the later center overwrote justify-content. Words now own their axis: between/around/evenly are main-axis words, stretch/baseline are cross-axis words, center/start/end fill whichever axes are still free, and a word with nowhere to go is a loud warning instead of a silent overwrite. @font-face used to crash the whole compiler (node.children is not iterable β an uncaught throw, the entire stylesheet dead); unknown at-rules now skip their whole balanced block with one clean error, which also kills the @supports leak where the inner rule applied unconditionally after an error. url() is atomic now, so url(a.png) no-repeat stopped raising two false errors. !important errors loudly and emits NOTHING (it used to error AND still emit red !important, which the browser then silently dropped). The animation shorthand stopped suggesting did you mean \"span\" for motion names and warns the DSSL way instead (motion name { } + animate:), and the CSS transition form now teaches the conversion in the error: transition: color .2s ease β write transition: 200 ease color. Then the wishlist's top four, because they fix tested problems: raw { } / @css { } drops verbatim CSS (@font-face, @keyframes, @supports, @import) into the page stylesheet; DSSL.toCSS() and dssl build compile the static subset to a real .css file β tokens to custom properties, states to :hover, layers to @layer, motions to @keyframes, pin to !important, seen to its no-JS final state, with a report that counts what stays in the runtime; dssl check puts the real compiler in CI with exit codes and --json; and the docs examples are now rendered in headless Chromium with computed-style assertions (real mouse hover, real IntersectionObserver, dark mode, viewport widths) because \"it compiles\" is what let the layout bug ship. Bonus sugar: font-size: fluid 16 24 compiles to the clamp() everyone writes wrong by hand.",
|
| 22 |
+
code: `# the exit lane: same source, real CSS, no runtime required
|
| 23 |
+
$ dssl check styles.dssl # CI: exit 1 on errors, --json for editors
|
| 24 |
+
$ dssl build styles.dssl -o styles.css
|
| 25 |
+
built styles.css from styles.dssl
|
| 26 |
+
6 rule(s), 1 pseudo-element rule(s), 1 @keyframes, 1 raw block(s)
|
| 27 |
+
layers: tokens < components < utilities
|
| 28 |
+
1 seen rule(s) applied unconditionally (the no-JS state)
|
| 29 |
+
|
| 30 |
+
/* tokens β custom properties Β· states β :hover Β· layers β @layer
|
| 31 |
+
motions β @keyframes Β· raw { } β verbatim Β· pin β !important */
|
| 32 |
+
.toolbar {
|
| 33 |
+
display: flex;
|
| 34 |
+
justify-content: space-between; /* the guide example, now true */
|
| 35 |
+
align-items: center;
|
| 36 |
+
font-size: clamp(16px, calc(16px + 0.952vw), 24px);
|
| 37 |
+
}`,
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
version: "v0.7.2",
|
| 41 |
+
date: "growth phase Β· the review drop",
|
| 42 |
+
title: "An external review reproduced twelve bugs. This release is the answer.",
|
| 43 |
+
why: "a thorough third-party agent review landed with repro cases instead of vibes: nested-state cascade loss, dead nested @media, broken docs examples, unitless delays, silent hex alpha, page-wide hover recomputes. The only honest response was to reproduce every claim, fix what was real, and make the docs examples test-verified",
|
| 44 |
+
kind: "spec",
|
| 45 |
+
body:
|
| 46 |
+
"The headline bug: a nested hover block sorted BEFORE its parent\u2019s own declarations, so background: red on the parent always beat hover { background: red } nested inside it \u2014 the exact pattern the docs and the starter teach. Same walk, worse: nested @media, @signal and @layer blocks flushed with the parent context, so their condition was dropped entirely and they sorted before base. A nested dark mode was silently dead. Both fixed at the resolver walk: nested blocks now flush after everything the parent said so far, and every block flushes with the context it was actually walked with. The parser got its own fix: declarations and a nested block sharing one line (background: red; hover { ... }) used to swallow the declaration into the block\u2019s selector, so it vanished. Raw hex alpha is real now: #ff0000/50 compiles to color-mix instead of the invalid #ff0000/50px. transition-delay and friends take bare numbers as ms (they used to ship unitless-invalid or px). grid: 3 gap 12 \u2014 the documented form \u2014 actually works, and rows N joined it. seen { opacity: 0 -> 1 } now fails LOUD with the two real patterns in the hint. A nested hovr typo warns did-you-mean instead of silently becoming a descendant selector. fill, stroke, text-wrap and content-visibility joined the vocabulary. ::part() rides the shadow pipeline; ::slotted() gets a loud explanation of why it cannot work from the light DOM. The engine caught up too: the gate prefilter actually runs before native matches() now (the review caught it in the wrong order), hover recomputes are scoped to the hovered subtree and its ancestors instead of the whole page (the review measured 33,060 recomputes for hovering 8 cards in a 1k list \u2014 the candidate collection that caused it is gone), and checkbox states finally hear change and toggle events, so unchecking with Space unstyles immediately. And the feature everyone asked about shipped as documentation: DSSL has been embeddable in HTML all along via <style type=\"text/dssl\">, and now <script type=\"text/dssl\"> works too. One distribution note the release has to carry: the editor extension bundles this same engine, but the packaged 0.7.1 vsix was built before these fixes landed, so anyone who installed it was still getting the old compiler's squiggles. The 0.7.2 vsix replaces it in the repo files and is the first package that actually contains this changelog.",
|
| 47 |
+
code: `// the pattern the docs teach, now actually true:
|
| 48 |
+
.card {
|
| 49 |
+
background: color.card
|
| 50 |
+
shadow: sm
|
| 51 |
+
hover { background: color.card-hover; shadow: lg }
|
| 52 |
+
|
| 53 |
+
@media dark { background: color.card-dark }
|
| 54 |
+
|
| 55 |
+
seen { animate: rise-in 400 snap }
|
| 56 |
+
}`,
|
| 57 |
+
},
|
| 58 |
{
|
| 59 |
version: "v0.7.1",
|
| 60 |
date: "growth phase Β· the credibility drop",
|
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.
|
| 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.8.0 (see the
|
| 15 |
changelog for what the site demanded).
|
| 16 |
</p>
|
| 17 |
</div>
|