Z User commited on
Commit Β·
0ade7b1
1
Parent(s): cbd4423
v0.7.2: engine fixes (12 review findings), GUIDE.md, honest README/PARITY/llms, test-verified examples, rebuilt packages
Browse files- GUIDE.md +283 -0
- PARITY.md +26 -0
- README.md +10 -5
- llms.txt +52 -0
- public/dssl-engine.zip +0 -0
- public/dssl/engine.js +118 -20
- public/dssl/index.js +4 -2
- public/dssl/parser.js +40 -1
- public/dssl/resolver.js +104 -21
- public/examples/hello.dssl +39 -0
- scripts/dssl-smoke.mjs +1 -1
- scripts/make-extension-icon.py +0 -0
- scripts/push-to-github.sh +26 -0
- scripts/repro-v072.mjs +73 -0
- scripts/smoke-v060.mjs +0 -0
- scripts/sync-extension-engine.mjs +0 -0
- scripts/test-guide-examples.mjs +43 -0
- scripts/test-v050.mjs +76 -0
- scripts/test-v060.mjs +0 -0
- scripts/test-v070.mjs +1 -1
- scripts/test-v071.mjs +0 -0
- scripts/test-v072.mjs +139 -0
GUIDE.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DSSL Guide
|
| 2 |
+
|
| 3 |
+
Everything in the repo, in one file. Written for the person who just cloned this
|
| 4 |
+
and wants to know what exists, what works, and what to reach for. Every `.dssl`
|
| 5 |
+
example in this guide is compiled by `scripts/test-guide-examples.mjs` on every
|
| 6 |
+
release, so the examples cannot drift from the compiler. If an example here
|
| 7 |
+
stops compiling, that is a release blocker, not a docs bug.
|
| 8 |
+
|
| 9 |
+
Status up front: DSSL is a real, tested style engine at v0.7.2, and it is
|
| 10 |
+
honest alpha. It needs JavaScript, it writes inline styles, and it has a
|
| 11 |
+
first-paint story you should read before shipping anything serious (see
|
| 12 |
+
"Honest limits"). Within those limits, it is genuinely nicer to write than
|
| 13 |
+
hand-rolled CSS for creative pages, docs, playgrounds and internal tools.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 1. What this repo is
|
| 18 |
+
|
| 19 |
+
| Path | What it is |
|
| 20 |
+
|---|---|
|
| 21 |
+
| `public/dssl/` | The engine. Six zero-dependency ESM files: `parser.js`, `resolver.js`, `engine.js`, `index.js` (public API), `utils.js`, `boot.js`. This is the whole language. |
|
| 22 |
+
| `public/styles/*.dssl` | The stylesheets that drive the docs site itself. The site is styled entirely by its own engine, which makes it the de-facto test suite. |
|
| 23 |
+
| `public/examples/hello.dssl` | The starter stylesheet. Small, commented, every interaction real. |
|
| 24 |
+
| `package/` + `public/install.sh`, `install.ps1` | The downloadable engine package: `curl -fsSL https://dssl-sable.vercel.app/install.sh \| sh`. |
|
| 25 |
+
| `public/dssl-engine.zip`, `.tar.gz` | Same package as plain archives. |
|
| 26 |
+
| `dssl-vscode/` | The editor extension (VS Code and VSCodium). Bundles the real parser and resolver, so editor squiggles are browser-console errors. See its own README and PUBLISHING.md. |
|
| 27 |
+
| `src/` | The Next.js docs site: playground, bench page, spec, changelog. |
|
| 28 |
+
| `scripts/` | Tests, package builds, the extension engine sync, deploy helpers. |
|
| 29 |
+
| `llms.txt`, `AGENTS.md`, `PARITY.md` | The machine-reviewer layer and contributor rules. PARITY.md is the honest CSS feature matrix. |
|
| 30 |
+
| `worklog.md` | The full build history, every decision logged. |
|
| 31 |
+
|
| 32 |
+
Version rule: the engine version in `public/dssl/index.js` is the source of
|
| 33 |
+
truth. The site badge, footer, changelog and READMEs follow it on every release.
|
| 34 |
+
|
| 35 |
+
## 2. Quickstart
|
| 36 |
+
|
| 37 |
+
Two tags. No build step, no npm, nothing to compile:
|
| 38 |
+
|
| 39 |
+
```html
|
| 40 |
+
<link rel="stylesheet/dssl" href="styles.dssl">
|
| 41 |
+
<script src="dssl/index.js" type="module"></script>
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
Or embed DSSL directly in your HTML (this is the Tailwind-ish answer: the
|
| 45 |
+
language lives wherever you want it):
|
| 46 |
+
|
| 47 |
+
```html
|
| 48 |
+
<script src="dssl/index.js" type="module"></script>
|
| 49 |
+
<style type="text/dssl">
|
| 50 |
+
.card {
|
| 51 |
+
background: #26201a
|
| 52 |
+
radius: 12
|
| 53 |
+
hover { background: #3a2f26 }
|
| 54 |
+
}
|
| 55 |
+
</style>
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
`<style type="text/dssl">` and `<script type="text/dssl">` both work; the
|
| 59 |
+
engine's auto-boot picks them up in document order, after any linked files.
|
| 60 |
+
You can use as many blocks as you like.
|
| 61 |
+
|
| 62 |
+
A real starter stylesheet with comments lives at
|
| 63 |
+
`public/examples/hello.dssl`.
|
| 64 |
+
|
| 65 |
+
## 3. The language, fast
|
| 66 |
+
|
| 67 |
+
### Bare numbers are px
|
| 68 |
+
|
| 69 |
+
`padding: 16` is 16px. Units still work when you want them: `width: 50%`,
|
| 70 |
+
`font-size: 1.2rem`, `margin: 0 auto`.
|
| 71 |
+
|
| 72 |
+
### Tokens
|
| 73 |
+
|
| 74 |
+
```dssl
|
| 75 |
+
tokens {
|
| 76 |
+
color {
|
| 77 |
+
ink: #16161d
|
| 78 |
+
accent: oklch(62% 0.19 35)
|
| 79 |
+
}
|
| 80 |
+
space {
|
| 81 |
+
1: 4
|
| 82 |
+
2: 8
|
| 83 |
+
4: 16
|
| 84 |
+
} // numeric keys are legal
|
| 85 |
+
radius { md: 14 }
|
| 86 |
+
shadow { md: 0 2 4 color.ink/10, 0 4 12 color.ink/8 }
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.card {
|
| 90 |
+
background: color.ink
|
| 91 |
+
padding: space.4
|
| 92 |
+
radius: md
|
| 93 |
+
shadow: md
|
| 94 |
+
}
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
Alpha on any color: `color.ink/50`, `#ff0000/50` (compiles to color-mix).
|
| 98 |
+
Token overrides can be scoped under `@media` or `@signal` for themes.
|
| 99 |
+
|
| 100 |
+
### Layout is words
|
| 101 |
+
|
| 102 |
+
```dssl
|
| 103 |
+
.toolbar { row: gap 8 between center }
|
| 104 |
+
.sidebar { column: gap 16 }
|
| 105 |
+
.board { grid: 3 gap 12 } // 3 columns, 1fr each, gap 12
|
| 106 |
+
.board { grid: auto-fill 220 } // responsive columns
|
| 107 |
+
.gallery { grid: 2 rows 3 gap 8 } // rows N is a word too
|
| 108 |
+
.modal { stack: center } // grid place-items center
|
| 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
|
| 115 |
+
`tokens < base < components < utilities < pin`. Nested blocks do what the
|
| 116 |
+
indentation says they do (fixed in v0.7.2, it used to be the engine's worst
|
| 117 |
+
bug):
|
| 118 |
+
|
| 119 |
+
```dssl
|
| 120 |
+
.card {
|
| 121 |
+
background: #26201a
|
| 122 |
+
shadow: sm
|
| 123 |
+
hover { background: #3a2f26; shadow: lg } // beats the base now, by order
|
| 124 |
+
|
| 125 |
+
@media dark { background: #181310 } // condition is kept, sorts after base
|
| 126 |
+
|
| 127 |
+
@layer utilities { margin-top: 0 }
|
| 128 |
+
}
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
Declarations after a nested block keep working too: they become a later
|
| 132 |
+
fragment of the same rule, and later still wins.
|
| 133 |
+
|
| 134 |
+
### States
|
| 135 |
+
|
| 136 |
+
All the usual suspects ride native matching: `hover`, `focus`,
|
| 137 |
+
`focus-visible`, `active`, `disabled`, `checked`, `target`, `open` (details),
|
| 138 |
+
and structural ones via selectors. Two engine details worth knowing:
|
| 139 |
+
|
| 140 |
+
- `checked` and `open` listen to real events (`change`, `toggle`), so
|
| 141 |
+
keyboard toggles restyle immediately. For programmatic control, set the
|
| 142 |
+
property and dispatch a bubbling change event:
|
| 143 |
+
`el.checked = true; el.dispatchEvent(new Event('change', { bubbles: true }))`.
|
| 144 |
+
- Attribute changes (`disabled` and friends) are picked up by the engine's
|
| 145 |
+
MutationObserver automatically.
|
| 146 |
+
|
| 147 |
+
### Conditions
|
| 148 |
+
|
| 149 |
+
```dssl
|
| 150 |
+
.card {
|
| 151 |
+
@media dark { background: #181310 } // one-word sugar
|
| 152 |
+
@media 768 { padding: 24 } // bare width = min-width
|
| 153 |
+
@media <= 480 { padding: 12 }
|
| 154 |
+
@container (min-width: 400px) { column: gap 4 }
|
| 155 |
+
@signal theme==dark { border-color: color.accent }
|
| 156 |
+
}
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
`@media reduced-motion` is honored by every motion unless the motion declares
|
| 160 |
+
`essential`.
|
| 161 |
+
|
| 162 |
+
### Motion and seen
|
| 163 |
+
|
| 164 |
+
```dssl
|
| 165 |
+
motion rise-in {
|
| 166 |
+
from { opacity: 0; translate: 0 18 }
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
motion lift {
|
| 170 |
+
from { translate: 0 2 }
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.gallery { stagger: 60 }
|
| 174 |
+
|
| 175 |
+
.card {
|
| 176 |
+
seen { animate: rise-in 480 snap } // plays once, when scrolled into view
|
| 177 |
+
hover { animate: lift 200 } // WAAPI, reduced-motion aware
|
| 178 |
+
}
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
The arrow `->` is motion/gradient syntax (keyframes and gradient stops). It
|
| 182 |
+
does nothing inside a `seen { }` block, and the compiler now says so loudly.
|
| 183 |
+
|
| 184 |
+
### Behaviors, deform, overlap
|
| 185 |
+
|
| 186 |
+
`use` shares named blocks (the site's `focus-ring` is one). `bend`, `fold`,
|
| 187 |
+
`stretch` deform on scroll. `overlap:` declares allowed overlap policies.
|
| 188 |
+
The full vocabulary lives in the site's Docs and Cheat sheet views.
|
| 189 |
+
|
| 190 |
+
## 4. The engine API
|
| 191 |
+
|
| 192 |
+
```js
|
| 193 |
+
import { DSSL } from '/dssl/index.js'
|
| 194 |
+
|
| 195 |
+
DSSL.version() // "0.7.2"
|
| 196 |
+
DSSL.compile(source) // { ok, errors, warnings, stats, doc }
|
| 197 |
+
DSSL.apply(source, { name, scope, replace })
|
| 198 |
+
await DSSL.load(['a.dssl', 'b.dssl'])
|
| 199 |
+
DSSL.signal('theme', 'dark') // drives @signal rules
|
| 200 |
+
DSSL.refresh() // full recompute
|
| 201 |
+
DSSL.freeze() / DSSL.unfreeze() // static snapshot: ambient restyling off
|
| 202 |
+
DSSL.explain(el) // which rules matched, what won, why
|
| 203 |
+
DSSL.errors() // last compile errors
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
`freeze()` is the big performance lever for static pages: the engine drops its
|
| 207 |
+
observers and listeners, keeps the resolved styles, and the page stops paying
|
| 208 |
+
for liveness. `unfreeze()` re-arms everything.
|
| 209 |
+
|
| 210 |
+
## 5. The editor extension
|
| 211 |
+
|
| 212 |
+
Three ways to get it:
|
| 213 |
+
|
| 214 |
+
1. Download `dssl-vscode/dssl-vscode-0.7.1.vsix` from the repo files, then
|
| 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).
|
| 218 |
+
See `dssl-vscode/PUBLISHING.md` for exactly what remains.
|
| 219 |
+
|
| 220 |
+
What it does: real-compiler diagnostics (same errors the browser prints,
|
| 221 |
+
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 scripts/test-v072.mjs # latest regression suite (40 assertions)
|
| 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)
|
| 232 |
+
node scripts/dssl-smoke.mjs # end-to-end compile of the smoke stylesheet
|
| 233 |
+
node scripts/dssl-styles-check.mjs # all six site stylesheets compile
|
| 234 |
+
node scripts/sync-extension-engine.mjs # run after ANY engine change
|
| 235 |
+
bash scripts/build-engine-package.sh # rebuild the zip/tar packages
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
House rule from AGENTS.md: if you touch `public/dssl/`, run the suites, then
|
| 239 |
+
run the sync script so the editor extension ships the same compiler.
|
| 240 |
+
|
| 241 |
+
## 7. Honest limits
|
| 242 |
+
|
| 243 |
+
Read this before choosing DSSL for a real site.
|
| 244 |
+
|
| 245 |
+
- **JavaScript is required.** No JS, no styles. The engine ships no CSS
|
| 246 |
+
fallback because there is no CSS. If a page must work without JS, use CSS.
|
| 247 |
+
- **First paint.** Styles are applied by the engine after it loads, so large
|
| 248 |
+
pages can flash unstyled content (CLS). The docs site solves this by
|
| 249 |
+
inlining its stylesheets into the SSR HTML. The planned prerender mode
|
| 250 |
+
(server renders the final inline styles) is the real fix and is on the
|
| 251 |
+
PARITY roadmap as the next big item.
|
| 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.
|
| 258 |
+
- **Large interactive pages** pay a real per-recompute cost. `freeze()`
|
| 259 |
+
makes static pages viable; live hover on thousands of elements is bounded
|
| 260 |
+
since v0.7.2 but is still not free.
|
| 261 |
+
- **Not a CSS replacement.** It is a style language that ends in inline
|
| 262 |
+
styles. When it is the better tool, it is because of the authoring
|
| 263 |
+
experience: tokens, words-as-layout, states and motion in one grammar,
|
| 264 |
+
loud errors, zero build.
|
| 265 |
+
|
| 266 |
+
## 8. FAQ
|
| 267 |
+
|
| 268 |
+
**Can I write DSSL in my HTML?** Yes, that is section 2. `<style
|
| 269 |
+
type="text/dssl">` blocks, or `<script type="text/dssl">` if you prefer.
|
| 270 |
+
|
| 271 |
+
**Where do docs examples get tested?** `scripts/test-guide-examples.mjs`
|
| 272 |
+
compiles every `.dssl` block in this guide and in the README. Green or the
|
| 273 |
+
release does not ship.
|
| 274 |
+
|
| 275 |
+
**Why does my nested hover not beat the base rule?** It does, since v0.7.2.
|
| 276 |
+
If you are on 0.7.1 or older, that was the cascade bug, upgrade.
|
| 277 |
+
|
| 278 |
+
**Is there a DSSL for something other than web styling?** Not yet. The
|
| 279 |
+
parser and resolver are domain-agnostic by design, so a domain-specific
|
| 280 |
+
DSSL2 is a plausible later project, not a current one.
|
| 281 |
+
|
| 282 |
+
**Who builds this?** One person plus AI agents, in the open. `worklog.md`
|
| 283 |
+
is the complete receipt trail.
|
PARITY.md
CHANGED
|
@@ -48,3 +48,29 @@ no other replacement styles with the platform's own primitives.
|
|
| 48 |
- the overlap policy: translucent boxes refuse to cover things unless you say so
|
| 49 |
- loud errors with did-you-mean, and a warnings channel for cascade-order footguns
|
| 50 |
- `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
- the overlap policy: translucent boxes refuse to cover things unless you say so
|
| 49 |
- loud errors with did-you-mean, and a warnings channel for cascade-order footguns
|
| 50 |
- `freeze()` / `unfreeze()`: a live engine that can be parked at zero cost
|
| 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
|
| 55 |
+
and regression-tested (scripts/test-v072.mjs). Rows affected:
|
| 56 |
+
|
| 57 |
+
- Nested state blocks (hover/focus/... inside a rule) now sort AFTER the parent
|
| 58 |
+
rule's own declarations, so nested overrides win the cascade. Nested @media,
|
| 59 |
+
@signal and @layer blocks also keep their condition and layer (they used to
|
| 60 |
+
be dropped, making nested dark mode silently dead).
|
| 61 |
+
- Declarations and a nested block can share one line (`.card { bg: red; hover { } }`)
|
| 62 |
+
without the declaration being swallowed.
|
| 63 |
+
- Raw hex alpha is real: `#ff0000/50` compiles to color-mix (was invalid /50px).
|
| 64 |
+
- Time properties take bare numbers as ms (`transition-delay: 200` -> 200ms).
|
| 65 |
+
- `grid: 3 gap 12` works; `rows N` joined the shorthand.
|
| 66 |
+
- `seen { ... }` rejects motion-arrow syntax loudly and points at the real forms.
|
| 67 |
+
- SVG and modern properties added: fill, stroke (and friends), text-wrap,
|
| 68 |
+
content-visibility.
|
| 69 |
+
- `::part()` rides the shadow pipeline; `::slotted()` is rejected with an
|
| 70 |
+
explanation (it only exists inside shadow trees).
|
| 71 |
+
- The gate prefilter now actually runs before native matches().
|
| 72 |
+
- State events scope candidate recomputation to the hovered subtree and its
|
| 73 |
+
ancestors instead of the whole page (a 1k-card hover went from ~4,100
|
| 74 |
+
recomputes per event to the touched chain).
|
| 75 |
+
- checkbox/details states hear `change` and `toggle` events, so keyboard
|
| 76 |
+
toggles and programmatic dispatch restyle immediately.
|
README.md
CHANGED
|
@@ -4,7 +4,7 @@ license: mit
|
|
| 4 |
|
| 5 |
# DSSL
|
| 6 |
|
| 7 |
-
DSSL is a style language for the web.
|
| 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 is compiled. There is no build step for styles.
|
| 10 |
|
|
@@ -134,7 +134,7 @@ tokens {
|
|
| 134 |
accent: oklch(62% 0.19 35)
|
| 135 |
}
|
| 136 |
radius { md: 14 }
|
| 137 |
-
shadow { md: 0 2 4 ink/10, 0 4 12 ink/8 }
|
| 138 |
space { 9: 96 } // numeric keys are legal (v0.7)
|
| 139 |
}
|
| 140 |
|
|
@@ -277,9 +277,14 @@ motion pulse {
|
|
| 277 |
|
| 278 |
.pt-card {
|
| 279 |
seen {
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
}
|
| 284 |
}
|
| 285 |
```
|
|
|
|
| 4 |
|
| 5 |
# DSSL
|
| 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 is compiled. There is no build step for styles.
|
| 10 |
|
|
|
|
| 134 |
accent: oklch(62% 0.19 35)
|
| 135 |
}
|
| 136 |
radius { md: 14 }
|
| 137 |
+
shadow { md: 0 2 4 color.ink/10, 0 4 12 color.ink/8 }
|
| 138 |
space { 9: 96 } // numeric keys are legal (v0.7)
|
| 139 |
}
|
| 140 |
|
|
|
|
| 277 |
|
| 278 |
.pt-card {
|
| 279 |
seen {
|
| 280 |
+
animate: rise 600 ease-out // the motion below owns the from-state
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
motion rise {
|
| 285 |
+
from {
|
| 286 |
+
opacity: 0
|
| 287 |
+
translate: 0 18
|
| 288 |
}
|
| 289 |
}
|
| 290 |
```
|
llms.txt
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (the engine ends in inline styles the browser parses). You write `.dssl` files, the page loads a ~20 kB engine, and the engine walks the DOM in real time applying every matched rule as inline styles. Nothing compiles to CSS. Elements added later are styled the moment they appear; signals, states and motion share one grammar.
|
| 4 |
+
|
| 5 |
+
- Site (docs, playground, changelog, bench): https://dssl-sable.vercel.app
|
| 6 |
+
- Repo + full docs in the README: https://huggingface.co/EHS-Studio/dssl
|
| 7 |
+
- Install: `curl -fsSL https://dssl-sable.vercel.app/install.sh | sh` (or the zip: https://dssl-sable.vercel.app/dssl-engine.zip)
|
| 8 |
+
- Version: 0.7.2 (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. For content pages that must not happen, use prerendering (snapshot the engine's first pass to static HTML; planned, tracked in PARITY.md) or choose another tool. The engine's own docs say this plainly.
|
| 16 |
+
3. Exit cost is zero. Abandoning DSSL leaves you a page of valid inline styles and one small shadow stylesheet for pseudo-elements. There is nothing to un-install from your markup.
|
| 17 |
+
4. Speed has a handbrake. `DSSL.freeze()` detaches every observer and listener after first paint and leaves the resolved look at zero engine cost; `unfreeze()` re-arms. A gate prefilter skips obvious non-matches before native matching. The Bench page on the site measures 100 / 1,000 / 10,000 nodes on your machine: CSS `<style>` vs DSSL live vs DSSL frozen.
|
| 18 |
+
5. The cascade is (layer, source order). No specificity, no !important. 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.7.2)
|
| 21 |
+
|
| 22 |
+
- Selectors: the full CSS surface rides native matching, including `:nth-child()`, `:not()`, `:is()`, `:where()`, `:has()`, combinators, attributes, nesting with `&`.
|
| 23 |
+
- States as nested blocks: `hover`, `focus`, `focus-visible`, `active`, `disabled`, `checked`, `empty`, `target`, plus `seen` (fires once on first viewport entry, composes with `stagger:`).
|
| 24 |
+
- Tokens built in: `color.*`, `space.1-8`, `radius.*`, `shadow.*`, `font.*`; custom `tokens { }` blocks with numeric keys (`space { 9: 96 }`), alpha suffixes (`color.ink/85`), per-subtree re-declaration (`--color.accent: color.err`).
|
| 25 |
+
- Layout sugar: `row: gap space.4 between center`, `column:`, `grid: 3 gap 12`, `stack: center`, `center: both` (promotes a formatting context so alignment can never silently no-op), `size: 40 20`.
|
| 26 |
+
- Pseudo-elements (`::before`, `::after`, `::placeholder`, `::selection`, `::marker`, `::backdrop`, `::first-line`, `::first-letter`, vendor ones) compile to one real shadow stylesheet; unknown pseudo names are compile errors.
|
| 27 |
+
- Conditions: `@media` (with `dark`, `light`, `reduced-motion` sugar and `<=` ranges), `@container`, `@signal menuOpen { }` bridged from `DSSL.signal()`, `@layer base|components|utilities`, `pin { }`.
|
| 28 |
+
- Motion: `motion name { from { } to { } }` via WAAPI; `animate: pulse 1600 infinite alternate`; `stagger: 70`; reduced-motion respected unless a motion declares `essential`.
|
| 29 |
+
- Deform family: `bend`, `bend-y`, `bend-up`, `fold`, `stretch` (volume-honest squash), `bend-around` (children ride an arc). Compose into the one transform channel, state- and transition-friendly.
|
| 30 |
+
- Gradients as sentences: `gradient: linear 135, color.accent -> color.ok`, `gradient-text:`, `gradient-flow: 6s alternate`. Non-color stops are compile errors.
|
| 31 |
+
- Overlap policy: nothing below 100% opacity overlaps anything unless `overlap: allow`; `deny` is unconditional; pairs with `pointer-events: none` so veils stop eating clicks and text selection.
|
| 32 |
+
- Behaviors: named rule groups applied with `use focus-ring`.
|
| 33 |
+
- 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 |
+
|
| 36 |
+
## JS API
|
| 37 |
+
|
| 38 |
+
```js
|
| 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. Source lives in `dssl-vscode/` in the repo.
|
public/dssl-engine.zip
CHANGED
|
Binary files a/public/dssl-engine.zip and b/public/dssl-engine.zip differ
|
|
|
public/dssl/engine.js
CHANGED
|
@@ -228,6 +228,25 @@ export class DsslEngine {
|
|
| 228 |
on('pointerdown', (e) => this.stateEvent('active', e.target));
|
| 229 |
on('pointerup', (e) => this.stateEvent('active', e.target));
|
| 230 |
on('pointercancel', (e) => this.stateEvent('active', e.target));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
// --- DOM mutations ---
|
| 233 |
const mo = new MutationObserver((muts) => {
|
|
@@ -368,19 +387,56 @@ export class DsslEngine {
|
|
| 368 |
node = node.parentElement;
|
| 369 |
}
|
| 370 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
for (const rule of this.rules) {
|
| 372 |
const hit = rule.states.has(state) || (state === 'focus' && rule.states.has('focus-visible')) || (state === 'focus-visible' && rule.states.has('focus'));
|
| 373 |
if (!hit) continue;
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
}
|
| 385 |
if (batch.size) this.schedule(batch);
|
| 386 |
}
|
|
@@ -489,6 +545,14 @@ export class DsslEngine {
|
|
| 489 |
if (rule.sel === ':root') {
|
| 490 |
if (el !== rootEl) continue;
|
| 491 |
} else {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
let sel = rule.sel;
|
| 493 |
if (rule.states.has('seen')) {
|
| 494 |
// v0.4.0 β `seen` belongs to the engine, not to CSS selectors: until
|
|
@@ -499,16 +563,6 @@ export class DsslEngine {
|
|
| 499 |
}
|
| 500 |
if (!this.matches(el, sel)) continue;
|
| 501 |
}
|
| 502 |
-
// v0.7.0 β gate prefilter: skip the native matches() call when the
|
| 503 |
-
// element obviously cannot match (missing class/tag/id). gateFor is
|
| 504 |
-
// conservative β it only ever says NO, and bails to null on anything
|
| 505 |
-
// it does not fully understand, so a missed optimization beats a
|
| 506 |
-
// missed match. This is the cheap half of the speed story; freeze()
|
| 507 |
-
// is the big half.
|
| 508 |
-
{
|
| 509 |
-
const gate = rule._gate !== undefined ? rule._gate : (rule._gate = gateFor(rule.sel));
|
| 510 |
-
if (gate && !gatePass(el, gate)) continue;
|
| 511 |
-
}
|
| 512 |
if (!rule.media.every(q => this.mql(q).matches)) continue;
|
| 513 |
if (!this.signalsPass(rule)) continue;
|
| 514 |
if (!this.containerPass(el, rule)) continue;
|
|
@@ -1422,6 +1476,50 @@ export function gateFor(sel) {
|
|
| 1422 |
}
|
| 1423 |
|
| 1424 |
/* Duck-typed on purpose: tests drive it with fake elements. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1425 |
export function gatePass(el, gate) {
|
| 1426 |
if (!gate) return true;
|
| 1427 |
for (const g of gate) {
|
|
|
|
| 228 |
on('pointerdown', (e) => this.stateEvent('active', e.target));
|
| 229 |
on('pointerup', (e) => this.stateEvent('active', e.target));
|
| 230 |
on('pointercancel', (e) => this.stateEvent('active', e.target));
|
| 231 |
+
// v0.7.2 β form-state events. checked/indeterminate live on properties
|
| 232 |
+
// the pointer and focus listeners never see: unchecking with Space went
|
| 233 |
+
// stale, and programmatic el.checked = true never restyled until focus
|
| 234 |
+
// moved. change bubbles, so delegation reaches it. toggle does NOT
|
| 235 |
+
// bubble β a capture-phase listener on document still catches it on the
|
| 236 |
+
// way down. Note for docs: programmatic property writes fire no event at
|
| 237 |
+
// all; dispatch a bubbling change event after setting .checked.
|
| 238 |
+
on('change', (e) => {
|
| 239 |
+
const t = e.target;
|
| 240 |
+
if (!t || t.nodeType !== 1) return;
|
| 241 |
+
this.stateEvent('checked', t);
|
| 242 |
+
this.stateEvent('disabled', t);
|
| 243 |
+
this.stateEvent('indeterminate', t);
|
| 244 |
+
});
|
| 245 |
+
{
|
| 246 |
+
const tgl = (e) => { const t = e.target; if (t && t.nodeType === 1) this.stateEvent('open', t); };
|
| 247 |
+
document.addEventListener('toggle', tgl, true);
|
| 248 |
+
this._listeners.push({ target: document, type: 'toggle', fn: tgl, opts: true });
|
| 249 |
+
}
|
| 250 |
|
| 251 |
// --- DOM mutations ---
|
| 252 |
const mo = new MutationObserver((muts) => {
|
|
|
|
| 387 |
node = node.parentElement;
|
| 388 |
}
|
| 389 |
|
| 390 |
+
// v0.7.2 β candidate collection is no longer page-wide. The external
|
| 391 |
+
// review measured 33,060 recomputes for hovering 8 cards in a 1k-card
|
| 392 |
+
// page: every state event queried the whole document for EVERY stateful
|
| 393 |
+
// rule and batched every match. Reality: a subject-state rule (.card:hover)
|
| 394 |
+
// can only ever change the chain collected above. Rules where the state
|
| 395 |
+
// sits on an ANCESTOR compound (.card:hover .title) reach further down β
|
| 396 |
+
// queried from the matching ancestor via :scope, so the cost is bounded
|
| 397 |
+
// by that ancestor's subtree, never the page. computeElement re-checks
|
| 398 |
+
// the full stateful selector, so a superset batch is only wasted work,
|
| 399 |
+
// never wrong output.
|
| 400 |
for (const rule of this.rules) {
|
| 401 |
const hit = rule.states.has(state) || (state === 'focus' && rule.states.has('focus-visible')) || (state === 'focus-visible' && rule.states.has('focus'));
|
| 402 |
if (!hit) continue;
|
| 403 |
+
const reach = rule._reach !== undefined ? rule._reach : (rule._reach = stateReach(rule.sel, state));
|
| 404 |
+
if (reach === null) continue; // subject-state: the chain covers it
|
| 405 |
+
if (reach.fallback) {
|
| 406 |
+
// :has()/:not() wrappers defeat the cheap strip β keep the old
|
| 407 |
+
// page-wide superset for this rare shape
|
| 408 |
+
const base = rule.sel.replace(STATE_PSEUDO_RE, '').trim() || '*';
|
| 409 |
+
let els; try { els = (this.scope || document).querySelectorAll(base); } catch { continue; }
|
| 410 |
+
els.forEach(el => batch.add(el));
|
| 411 |
+
continue;
|
| 412 |
+
}
|
| 413 |
+
for (const anc of batch) {
|
| 414 |
+
let prefixOK;
|
| 415 |
+
try { prefixOK = anc.matches(reach.prefix); } catch { prefixOK = false; }
|
| 416 |
+
if (!prefixOK) continue;
|
| 417 |
+
if (reach.sib) {
|
| 418 |
+
// sibling tails: querySelectorAll cannot leave the subtree, so walk
|
| 419 |
+
// the siblings by hand (+ stops at the first; ~ keeps scanning)
|
| 420 |
+
let sib = anc.nextElementSibling;
|
| 421 |
+
while (sib) {
|
| 422 |
+
let hitSib = false;
|
| 423 |
+
try { hitSib = sib.matches(reach.firstSeg); } catch { hitSib = false; }
|
| 424 |
+
if (hitSib) {
|
| 425 |
+
batch.add(sib);
|
| 426 |
+
if (reach.restAfter) {
|
| 427 |
+
let sub; try { sub = sib.querySelectorAll(reach.restAfter); } catch { sub = null; }
|
| 428 |
+
if (sub) sub.forEach(x => batch.add(x));
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
if (reach.sib === '+') break;
|
| 432 |
+
sib = sib.nextElementSibling;
|
| 433 |
+
}
|
| 434 |
+
continue;
|
| 435 |
+
}
|
| 436 |
+
let els;
|
| 437 |
+
try { els = anc.querySelectorAll(':scope ' + reach.rest); } catch { continue; }
|
| 438 |
+
els.forEach(el => batch.add(el));
|
| 439 |
+
}
|
| 440 |
}
|
| 441 |
if (batch.size) this.schedule(batch);
|
| 442 |
}
|
|
|
|
| 545 |
if (rule.sel === ':root') {
|
| 546 |
if (el !== rootEl) continue;
|
| 547 |
} else {
|
| 548 |
+
// v0.7.2 β the gate now runs BEFORE native matches(). The external
|
| 549 |
+
// review caught the wrong order: every element paid a full el.matches()
|
| 550 |
+
// for every rule even when the cheap gate could say no. Same contract
|
| 551 |
+
// as v0.7.0: the gate only ever says NO, and bails to null on anything
|
| 552 |
+
// it does not fully understand β a missed optimization beats a missed
|
| 553 |
+
// match.
|
| 554 |
+
const gate = rule._gate !== undefined ? rule._gate : (rule._gate = gateFor(rule.sel));
|
| 555 |
+
if (gate && !gatePass(el, gate)) continue;
|
| 556 |
let sel = rule.sel;
|
| 557 |
if (rule.states.has('seen')) {
|
| 558 |
// v0.4.0 β `seen` belongs to the engine, not to CSS selectors: until
|
|
|
|
| 563 |
}
|
| 564 |
if (!this.matches(el, sel)) continue;
|
| 565 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
if (!rule.media.every(q => this.mql(q).matches)) continue;
|
| 567 |
if (!this.signalsPass(rule)) continue;
|
| 568 |
if (!this.containerPass(el, rule)) continue;
|
|
|
|
| 1476 |
}
|
| 1477 |
|
| 1478 |
/* Duck-typed on purpose: tests drive it with fake elements. */
|
| 1479 |
+
/* v0.7.2 β stateReach: where can a state change reach in the DOM?
|
| 1480 |
+
* Pure and exported for tests. Given a stateful selector and the state that
|
| 1481 |
+
* just flipped on some element, it answers:
|
| 1482 |
+
* null β subject-state (state on the rightmost compound): only
|
| 1483 |
+
* the event chain can change, no query needed at all
|
| 1484 |
+
* { fallback:true } β the cheap strip cannot handle it (:has()/:not()
|
| 1485 |
+
* wrappers): caller keeps the page-wide superset
|
| 1486 |
+
* { prefix, rest } β batch every chain element matching `prefix` (the part
|
| 1487 |
+
* up to and including the stateful compound, states
|
| 1488 |
+
* stripped) and query `:scope` + `rest` under each
|
| 1489 |
+
* { prefix, sib, firstSeg, restAfter } β the tail starts with a sibling
|
| 1490 |
+
* combinator; walk that element's siblings manually
|
| 1491 |
+
* (querySelectorAll cannot leave the subtree)
|
| 1492 |
+
*/
|
| 1493 |
+
export function stateReach(sel, state) {
|
| 1494 |
+
const segs = [];
|
| 1495 |
+
let depth = 0, cur = '', pendingComb = '';
|
| 1496 |
+
for (let i = 0; i < sel.length; i++) {
|
| 1497 |
+
const c = sel[i];
|
| 1498 |
+
if (c === '(' || c === '[') depth++;
|
| 1499 |
+
else if (c === ')' || c === ']') depth = Math.max(0, depth - 1);
|
| 1500 |
+
if (depth === 0 && (c === '>' || c === '+' || c === '~' || /\s/.test(c))) {
|
| 1501 |
+
const comb = c === ' ' ? ' ' : c;
|
| 1502 |
+
if (cur.trim()) { segs.push({ comb: pendingComb || ' ', seg: cur.trim() }); cur = ''; pendingComb = comb; }
|
| 1503 |
+
else if (comb !== ' ') pendingComb = comb;
|
| 1504 |
+
continue;
|
| 1505 |
+
}
|
| 1506 |
+
cur += c;
|
| 1507 |
+
}
|
| 1508 |
+
if (cur.trim()) segs.push({ comb: pendingComb || ' ', seg: cur.trim() });
|
| 1509 |
+
if (segs.length <= 1) return null;
|
| 1510 |
+
const idx = segs.findIndex(s => new RegExp(':' + state + '\\b').test(s.seg));
|
| 1511 |
+
if (idx === -1 || idx === segs.length - 1) return null;
|
| 1512 |
+
const prefix = segs.slice(0, idx + 1).map(s => s.seg).join(' ').replace(STATE_PSEUDO_RE, '').trim();
|
| 1513 |
+
if (!prefix || /\(\s*\)/.test(prefix)) return { fallback: true };
|
| 1514 |
+
const tail = segs.slice(idx + 1);
|
| 1515 |
+
if (tail[0].comb === '+' || tail[0].comb === '~') {
|
| 1516 |
+
const restAfter = tail.slice(1).map(s => (s.comb === ' ' ? ' ' : ' ' + s.comb + ' ') + s.seg).join('').trim();
|
| 1517 |
+
return { prefix, sib: tail[0].comb, firstSeg: tail[0].seg, restAfter };
|
| 1518 |
+
}
|
| 1519 |
+
const rest = tail.map(s => (s.comb === ' ' ? ' ' : ' ' + s.comb + ' ') + s.seg).join('').trim();
|
| 1520 |
+
return { prefix, rest };
|
| 1521 |
+
}
|
| 1522 |
+
|
| 1523 |
export function gatePass(el, gate) {
|
| 1524 |
if (!gate) return true;
|
| 1525 |
for (const g of gate) {
|
public/dssl/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { parse } from './parser.js';
|
|
| 18 |
import { resolve, DEFAULT_TOKENS } from './resolver.js';
|
| 19 |
import { DsslEngine } from './engine.js';
|
| 20 |
|
| 21 |
-
export const VERSION = '0.7.
|
| 22 |
|
| 23 |
let counter = 0;
|
| 24 |
const engines = new Map();
|
|
@@ -205,7 +205,9 @@ if (typeof window !== 'undefined') {
|
|
| 205 |
const boot = async () => {
|
| 206 |
try {
|
| 207 |
const links = [...document.querySelectorAll('link[rel="stylesheet/dssl"]')];
|
| 208 |
-
|
|
|
|
|
|
|
| 209 |
|
| 210 |
const sources = [];
|
| 211 |
for (const l of links) sources.push({ name: l.getAttribute('href'), kind: 'link', el: l });
|
|
|
|
| 18 |
import { resolve, DEFAULT_TOKENS } from './resolver.js';
|
| 19 |
import { DsslEngine } from './engine.js';
|
| 20 |
|
| 21 |
+
export const VERSION = '0.7.2';
|
| 22 |
|
| 23 |
let counter = 0;
|
| 24 |
const engines = new Map();
|
|
|
|
| 205 |
const boot = async () => {
|
| 206 |
try {
|
| 207 |
const links = [...document.querySelectorAll('link[rel="stylesheet/dssl"]')];
|
| 208 |
+
// v0.7.2 β inline DSSL blocks also work as <script type="text/dssl">
|
| 209 |
+
// (some editors highlight/parse those better than an unknown style type)
|
| 210 |
+
const inlines = [...document.querySelectorAll('style[type="text/dssl"], script[type="text/dssl"]')];
|
| 211 |
|
| 212 |
const sources = [];
|
| 213 |
for (const l of links) sources.push({ name: l.getAttribute('href'), kind: 'link', el: l });
|
public/dssl/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 = [];
|
|
@@ -158,6 +177,26 @@ 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: [] };
|
|
|
|
| 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 = [];
|
|
|
|
| 177 |
|
| 178 |
if (open !== -1) {
|
| 179 |
const header = rest.slice(0, open).trim();
|
| 180 |
+
// v0.7.2 β declarations and a nested block sharing one line:
|
| 181 |
+
// `.card { background: red; hover { ... } }`. Everything before the
|
| 182 |
+
// header used to be swallowed whole into it, so `background: red`
|
| 183 |
+
// silently became part of a selector named "background: red; hover"
|
| 184 |
+
// and vanished. Declarations before the LAST top-level ';' are now
|
| 185 |
+
// processed as declarations; the header is what follows them.
|
| 186 |
+
if (header) {
|
| 187 |
+
const semi = scanForLast(header, ';');
|
| 188 |
+
if (semi !== -1) {
|
| 189 |
+
processContent(header.slice(0, semi), line);
|
| 190 |
+
const realHeader = header.slice(semi + 1).trim();
|
| 191 |
+
if (!realHeader) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
|
| 192 |
+
const meta = classify(realHeader, line);
|
| 193 |
+
const node = { ...meta, line, children: [] };
|
| 194 |
+
stack[stack.length - 1].children.push(node);
|
| 195 |
+
if (meta.kind !== 'bogus') stack.push(node);
|
| 196 |
+
rest = rest.slice(open + 1);
|
| 197 |
+
continue;
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
if (!header) { err(line, 'block is missing a header before "{"'); rest = rest.slice(open + 1); continue; }
|
| 201 |
const meta = classify(header, line);
|
| 202 |
const node = { ...meta, line, children: [] };
|
public/dssl/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
|
|
@@ -68,11 +68,19 @@ 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 +90,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 +190,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 |
}
|
|
@@ -267,8 +275,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;
|
|
@@ -510,15 +528,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();
|
|
@@ -1064,6 +1098,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': {
|
|
@@ -1091,6 +1141,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 +1158,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 +1178,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': {
|
|
@@ -1214,7 +1289,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 |
}
|
|
|
|
| 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
|
|
|
|
| 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 |
+
// v0.7.2 β time properties: a bare number is milliseconds, never px, and
|
| 77 |
+
// never unitless (both used to emit values the browser silently rejected).
|
| 78 |
+
// transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is
|
| 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)
|
| 85 |
const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
|
| 86 |
'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
|
|
|
|
| 90 |
'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,' +
|
| 91 |
'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' +
|
| 92 |
'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,' +
|
| 93 |
+
'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,' +
|
| 94 |
+
'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,' +
|
| 95 |
'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,' +
|
| 96 |
'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,' +
|
| 97 |
'-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(','));
|
|
|
|
| 190 |
unit = raw.slice(j, k).toLowerCase();
|
| 191 |
j = k;
|
| 192 |
}
|
| 193 |
+
out += num + (unit ? unit : (ctx.unitless ? '' : TIME_PROPS.has(ctx.prop) ? 'ms' : 'px'));
|
| 194 |
i = j;
|
| 195 |
continue;
|
| 196 |
}
|
|
|
|
| 275 |
if (c === '#') { // hex color
|
| 276 |
let j = i + 1;
|
| 277 |
while (j < n && /[0-9a-fA-F]/.test(raw[j])) j++;
|
| 278 |
+
const hex = raw.slice(i, j);
|
| 279 |
+
out += hex;
|
| 280 |
i = j;
|
| 281 |
+
// v0.7.2 β alpha on a raw hex color: #ff0000/50 used to become
|
| 282 |
+
// #ff0000/50px (the bare number got the generic px treatment) β invalid
|
| 283 |
+
// CSS, silently dropped by the browser. Tokens always had /NN via
|
| 284 |
+
// color-mix; raw hex now gets the same treatment.
|
| 285 |
+
const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
|
| 286 |
+
if (am) {
|
| 287 |
+
out = out.slice(0, out.length - hex.length) + `color-mix(in oklab, ${hex} ${am[1]}%, transparent)`;
|
| 288 |
+
i += am[0].length;
|
| 289 |
+
}
|
| 290 |
continue;
|
| 291 |
}
|
| 292 |
out += c;
|
|
|
|
| 528 |
column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
|
| 529 |
grid(v, ctx) {
|
| 530 |
const atoms = v.split(/\s+/).filter(Boolean);
|
| 531 |
+
// v0.7.2 β `grid: 3 gap 12` (the documented form) used to die: the gap
|
| 532 |
+
// atoms were fed into grid-template-columns β "3px gap 12px", invalid.
|
| 533 |
+
// gap and rows are now first-class words of the shorthand.
|
| 534 |
+
let gap = null, rows = null;
|
| 535 |
+
const rest = [];
|
| 536 |
+
for (let i = 0; i < atoms.length; i++) {
|
| 537 |
+
if (atoms[i] === 'gap' && atoms[i + 1]) { gap = atoms[++i]; continue; }
|
| 538 |
+
if (atoms[i] === 'rows' && atoms[i + 1]) { rows = atoms[++i]; continue; }
|
| 539 |
+
rest.push(atoms[i]);
|
| 540 |
+
}
|
| 541 |
let cols;
|
| 542 |
+
if (!rest.length) {
|
| 543 |
+
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' });
|
| 544 |
+
cols = 'none';
|
| 545 |
+
} else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) {
|
| 546 |
+
cols = `repeat(${rest[0]}, 1fr)`;
|
| 547 |
+
} else if ((rest[0] === 'auto-fill' || rest[0] === 'auto-fit') && rest[1]) {
|
| 548 |
+
cols = `repeat(${rest[0]}, minmax(${len(rest[1], ctx)}, 1fr))`;
|
| 549 |
} else {
|
| 550 |
+
cols = rest.map(a => len(a, ctx)).join(' ');
|
| 551 |
}
|
| 552 |
+
const out = [{ prop: 'display', value: 'grid' }, { prop: 'grid-template-columns', value: cols }];
|
| 553 |
+
if (gap) out.push({ prop: 'gap', value: len(gap, ctx) });
|
| 554 |
+
if (rows) out.push({ prop: 'grid-template-rows', value: /^[0-9]+$/.test(rows) ? `repeat(${rows}, 1fr)` : len(rows, ctx) });
|
| 555 |
+
return out;
|
| 556 |
},
|
| 557 |
stack(v, ctx) {
|
| 558 |
const word = (v || 'center').trim();
|
|
|
|
| 1098 |
/* ---- main recursive walk ---- */
|
| 1099 |
|
| 1100 |
const walk = (children, ctx, ctxRule) => {
|
| 1101 |
+
// v0.7.2 β parent-first flush. Two real bugs lived here. (1) A nested
|
| 1102 |
+
// block (hover { }, @media, a nested selector) was emitted BEFORE the
|
| 1103 |
+
// parent's own declarations got their cascade order, so `background: red`
|
| 1104 |
+
// on the parent always beat `hover { background: red }` nested inside it
|
| 1105 |
+
// β the exact pattern the docs taught, and the external review's headline
|
| 1106 |
+
// finding. (2) media/container/signal/layer blocks flushed with the
|
| 1107 |
+
// PARENT context, dropping the condition or layer entirely: a nested
|
| 1108 |
+
// `@media dark` was silently unconditional AND sorted before base.
|
| 1109 |
+
// Nested blocks now flush everything the parent has said so far first,
|
| 1110 |
+
// and every block flushes with the context it was actually walked with.
|
| 1111 |
+
const flushPending = (line) => {
|
| 1112 |
+
if (ctxRule && ctxRule.decls.length && ctx.sel) {
|
| 1113 |
+
flush(ctxRule, ctx, line);
|
| 1114 |
+
ctxRule.decls = [];
|
| 1115 |
+
}
|
| 1116 |
+
};
|
| 1117 |
for (const node of children) {
|
| 1118 |
switch (node.kind) {
|
| 1119 |
case 'decl': {
|
|
|
|
| 1141 |
break;
|
| 1142 |
}
|
| 1143 |
case 'selector': {
|
| 1144 |
+
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) {
|
| 1145 |
+
const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]);
|
| 1146 |
+
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}>` });
|
| 1147 |
+
}
|
| 1148 |
+
flushPending(node.line);
|
| 1149 |
const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
|
| 1150 |
const childCtx = { ...ctx, sel };
|
| 1151 |
const sub = { decls: [] };
|
|
|
|
| 1158 |
errors.push({ file, line: node.line, message: `"${node.name}" needs a selector around it` });
|
| 1159 |
break;
|
| 1160 |
}
|
| 1161 |
+
flushPending(node.line);
|
| 1162 |
const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
|
| 1163 |
+
// v0.7.2 β the arrow is motion/gradient-stop syntax; inside a seen
|
| 1164 |
+
// block it compiled into an inline style the browser rejects, so the
|
| 1165 |
+
// element just sat at its base style forever. Say so, loudly, and
|
| 1166 |
+
// point at the two real patterns (final value, or a motion).
|
| 1167 |
+
if (node.name === 'seen') {
|
| 1168 |
+
for (const d of node.children) {
|
| 1169 |
+
if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) {
|
| 1170 |
+
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 })' });
|
| 1171 |
+
}
|
| 1172 |
+
}
|
| 1173 |
+
}
|
| 1174 |
const sub = { decls: [] };
|
| 1175 |
walk(node.children, childCtx, sub);
|
| 1176 |
flush(sub, childCtx, node.line);
|
|
|
|
| 1178 |
}
|
| 1179 |
case 'media': {
|
| 1180 |
const q = normalizeMedia(node.query, { errors, file, line: node.line }, node.line);
|
| 1181 |
+
flushPending(node.line);
|
| 1182 |
+
const childCtx = { ...ctx, media: [...ctx.media, q] };
|
| 1183 |
const sub = { decls: [] };
|
| 1184 |
+
walk(node.children, childCtx, sub);
|
| 1185 |
+
flush(sub, childCtx, node.line);
|
| 1186 |
break;
|
| 1187 |
}
|
| 1188 |
case 'container': {
|
| 1189 |
const q = normalizeMedia(node.query || '(min-width: 0)', { errors, file, line: node.line }, node.line);
|
| 1190 |
+
flushPending(node.line);
|
| 1191 |
+
const childCtx = { ...ctx, containers: [...ctx.containers, q] };
|
| 1192 |
const sub = { decls: [] };
|
| 1193 |
+
walk(node.children, childCtx, sub);
|
| 1194 |
+
flush(sub, childCtx, node.line);
|
| 1195 |
break;
|
| 1196 |
}
|
| 1197 |
case 'signal': {
|
| 1198 |
const sig = parseSignal(node.expr, { errors, file, line: node.line }, node.line);
|
| 1199 |
+
flushPending(node.line);
|
| 1200 |
+
const childCtx = { ...ctx, signals: [...ctx.signals, sig] };
|
| 1201 |
const sub = { decls: [] };
|
| 1202 |
+
walk(node.children, childCtx, sub);
|
| 1203 |
+
flush(sub, childCtx, node.line);
|
| 1204 |
break;
|
| 1205 |
}
|
| 1206 |
case 'layer':
|
| 1207 |
case 'pin': {
|
| 1208 |
const layer = node.kind === 'pin' ? 'pin' : (['tokens', 'base', 'components', 'utilities', 'pin'].includes(node.name) ? node.name : 'components');
|
| 1209 |
+
flushPending(node.line);
|
| 1210 |
+
const childCtx = { ...ctx, layer };
|
| 1211 |
const sub = { decls: [] };
|
| 1212 |
+
walk(node.children, childCtx, sub);
|
| 1213 |
+
flush(sub, childCtx, node.line);
|
| 1214 |
break;
|
| 1215 |
}
|
| 1216 |
case 'pseudo': {
|
|
|
|
| 1289 |
errors.push({
|
| 1290 |
file, line,
|
| 1291 |
message: `unknown pseudo-element "::${pseudo}"`,
|
| 1292 |
+
hint: 'try ::before, ::after, ::selection, ::part(name), or a vendor one like ::-webkit-scrollbar-thumb',
|
| 1293 |
+
});
|
| 1294 |
+
continue;
|
| 1295 |
+
}
|
| 1296 |
+
if (name === 'slotted') {
|
| 1297 |
+
errors.push({
|
| 1298 |
+
file, line,
|
| 1299 |
+
message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM',
|
| 1300 |
+
hint: 'style the slotted element itself with a normal selector β it IS reachable from the page',
|
| 1301 |
});
|
| 1302 |
continue;
|
| 1303 |
}
|
public/examples/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 |
+
}
|
scripts/dssl-smoke.mjs
CHANGED
|
@@ -182,7 +182,7 @@ const cardRule = doc.rules.find(r => r.sel === '.card');
|
|
| 182 |
assert(cardRule, '.card rule exists');
|
| 183 |
assert(cardRule.decls.some(d => d.prop === 'background' && d.value.includes('var(--dssl-color-card)')), 'background resolved to var');
|
| 184 |
assert(cardRule.decls.some(d => d.prop === 'padding' && d.value.includes('var(--dssl-space-4)')), 'space.4 resolved to var ref');
|
| 185 |
-
assert(
|
| 186 |
assert(doc.rules.some(r => r.sel === '.card:focus-visible'), 'behavior expanded into state selector');
|
| 187 |
assert(doc.rules.some(r => r.sel === '.card:hover .tip'), 'nested state descendant composed');
|
| 188 |
assert(doc.rules.some(r => r.sel === ':root' && r.signals.length === 1), 'signal token override rule');
|
|
|
|
| 182 |
assert(cardRule, '.card rule exists');
|
| 183 |
assert(cardRule.decls.some(d => d.prop === 'background' && d.value.includes('var(--dssl-color-card)')), 'background resolved to var');
|
| 184 |
assert(cardRule.decls.some(d => d.prop === 'padding' && d.value.includes('var(--dssl-space-4)')), 'space.4 resolved to var ref');
|
| 185 |
+
assert(doc.rules.filter(r => r.sel === '.card').some(r => r.decls.some(d => d.prop === '@animate' && d.value.frames && d.value.frames.length === 1)), 'animate wired with frames');
|
| 186 |
assert(doc.rules.some(r => r.sel === '.card:focus-visible'), 'behavior expanded into state selector');
|
| 187 |
assert(doc.rules.some(r => r.sel === '.card:hover .tip'), 'nested state descendant composed');
|
| 188 |
assert(doc.rules.some(r => r.sel === ':root' && r.signals.length === 1), 'signal token override rule');
|
scripts/make-extension-icon.py
CHANGED
|
File without changes
|
scripts/push-to-github.sh
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# DSSL project β permanent cloud backup to GitHub.
|
| 3 |
+
# Usage: GITHUB_TOKEN=<your PAT> bash scripts/push-to-github.sh [repo-name]
|
| 4 |
+
# Needs: classic PAT with `repo` scope, or fine-grained PAT with repo create rights.
|
| 5 |
+
set -euo pipefail
|
| 6 |
+
|
| 7 |
+
REPO_NAME="${1:-dssl-language}"
|
| 8 |
+
API="https://api.github.com"
|
| 9 |
+
|
| 10 |
+
: "${GITHUB_TOKEN:?Set GITHUB_TOKEN env var first (a GitHub Personal Access Token with repo scope)}"
|
| 11 |
+
|
| 12 |
+
OWNER=$(curl -sf -H "Authorization: token $GITHUB_TOKEN" "$API/user" | python3 -c 'import json,sys;print(json.load(sys.stdin)["login"])')
|
| 13 |
+
echo "Authenticated as: $OWNER"
|
| 14 |
+
|
| 15 |
+
echo "Creating repo $OWNER/$REPO_NAME (skips if it already exists)..."
|
| 16 |
+
HTTP=$(curl -s -o /tmp/gh-create.json -w "%{http_code}" -X POST \
|
| 17 |
+
-H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" \
|
| 18 |
+
"$API/user/repos" -d "{\"name\":\"$REPO_NAME\",\"private\":true,\"description\":\"DSSL β a Domain Specific Style Language that replaces CSS (self-hosted engine, no CSS output)\"}")
|
| 19 |
+
if [ "$HTTP" = "422" ]; then echo "Repo already exists β pushing into it."; else [ "$HTTP" = "201" ] && echo "Repo created."; fi
|
| 20 |
+
|
| 21 |
+
cd /home/z/my-project
|
| 22 |
+
git remote remove cloud 2>/dev/null || true
|
| 23 |
+
git remote add cloud "https://x-access-token:$GITHUB_TOKEN@github.com/$OWNER/$REPO_NAME.git"
|
| 24 |
+
git push -u cloud main
|
| 25 |
+
git remote set-url cloud "https://github.com/$OWNER/$REPO_NAME.git" # scrub token from .git/config
|
| 26 |
+
echo "DONE β permanent backup live at https://github.com/$OWNER/$REPO_NAME"
|
scripts/repro-v072.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Reproduces every compiler-level claim from the external agent review (0.7.2 cycle).
|
| 3 |
+
import { parse } from '/home/z/my-project/public/dssl/parser.js';
|
| 4 |
+
import { resolve } from '/home/z/my-project/public/dssl/resolver.js';
|
| 5 |
+
|
| 6 |
+
let fails = 0;
|
| 7 |
+
function show(name, source, opts = {}) {
|
| 8 |
+
const { ast } = parse(source, 'repro.dssl');
|
| 9 |
+
const out = resolve(ast, 'repro.dssl', {});
|
| 10 |
+
console.log('== ' + name);
|
| 11 |
+
if (out.errors?.length) console.log(' ERRORS:', out.errors.map(e => e.message || e).join(' | '));
|
| 12 |
+
if (out.warnings?.length) console.log(' WARNINGS:', out.warnings.map(e => e.message || e).join(' | '));
|
| 13 |
+
for (const r of (out.rules || [])) {
|
| 14 |
+
console.log(' rule:', r.selector, JSON.stringify(r.decls || r.sets || r).slice(0, 220));
|
| 15 |
+
}
|
| 16 |
+
return out;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// 1. THE BIG ONE: nested state must sort after base
|
| 20 |
+
show('nested hover vs base (cascade)', `
|
| 21 |
+
.card {
|
| 22 |
+
background: #26201a
|
| 23 |
+
hover { background: #c0392b }
|
| 24 |
+
}
|
| 25 |
+
`);
|
| 26 |
+
|
| 27 |
+
// 2. raw hex alpha
|
| 28 |
+
show('raw hex /50 alpha', `
|
| 29 |
+
.card { background: #ff0000/50 }
|
| 30 |
+
`);
|
| 31 |
+
|
| 32 |
+
// 3. transition-delay units
|
| 33 |
+
show('transition-delay 200', `
|
| 34 |
+
.card { transition-delay: 200 }
|
| 35 |
+
`);
|
| 36 |
+
|
| 37 |
+
// 4. grid gap shorthand
|
| 38 |
+
show('grid: 3 gap 12', `
|
| 39 |
+
.grid { grid: 3 gap 12 }
|
| 40 |
+
`);
|
| 41 |
+
|
| 42 |
+
// 5. shadow token def with bare ink
|
| 43 |
+
show('shadow { md: 0 2 4 ink/10 }', `
|
| 44 |
+
shadow { md: 0 2 4 ink/10 }
|
| 45 |
+
`);
|
| 46 |
+
|
| 47 |
+
// 6. seen block
|
| 48 |
+
show('seen { opacity: 0 -> 1 } block', `
|
| 49 |
+
.card { seen { opacity: 0 -> 1 } }
|
| 50 |
+
`);
|
| 51 |
+
show('seen as motion decl', `
|
| 52 |
+
.card { seen: opacity 0 -> 1 }
|
| 53 |
+
`);
|
| 54 |
+
|
| 55 |
+
// 7. hovr typo nested
|
| 56 |
+
show('nested hovr typo', `
|
| 57 |
+
.card { hovr { background: red } }
|
| 58 |
+
`);
|
| 59 |
+
|
| 60 |
+
// 8. missing properties
|
| 61 |
+
show('fill/stroke/text-wrap/content-visibility', `
|
| 62 |
+
.card { fill: red; stroke: blue; stroke-width: 2; text-wrap: balance; content-visibility: auto }
|
| 63 |
+
`);
|
| 64 |
+
|
| 65 |
+
// 9. ::part / ::slotted
|
| 66 |
+
show('::part selector', `
|
| 67 |
+
my-card::part(header) { background: red }
|
| 68 |
+
`);
|
| 69 |
+
|
| 70 |
+
// 10. shadow: sm / hover shadow lg quickstart pattern
|
| 71 |
+
show('README quickstart pattern', `
|
| 72 |
+
.card { shadow: sm; hover { shadow: lg } }
|
| 73 |
+
`);
|
scripts/smoke-v060.mjs
CHANGED
|
File without changes
|
scripts/sync-extension-engine.mjs
CHANGED
|
File without changes
|
scripts/test-guide-examples.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Compiles every ```dssl fenced block in GUIDE.md (strict: 0 errors allowed)
|
| 3 |
+
// and reports README.md blocks (the README may carry teaching examples that
|
| 4 |
+
// intentionally error; those are tagged with <!-- dssl:has-errors -->).
|
| 5 |
+
import { readFileSync } from 'node:fs';
|
| 6 |
+
import { parse } from '../public/dssl/parser.js';
|
| 7 |
+
import { resolve, DEFAULT_TOKENS } from '../public/dssl/resolver.js';
|
| 8 |
+
|
| 9 |
+
const files = [
|
| 10 |
+
{ path: '/home/z/my-project/GUIDE.md', strict: true },
|
| 11 |
+
{ path: '/home/z/hf-mirror/README.md', strict: false },
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
let pass = 0, fail = 0;
|
| 15 |
+
for (const { path, strict } of files) {
|
| 16 |
+
let text;
|
| 17 |
+
try { text = readFileSync(path, 'utf8'); } catch { console.log('(skip missing ' + path + ')'); continue; }
|
| 18 |
+
const blocks = [...text.matchAll(/```dssl\n([\s\S]*?)```/g)].map(m => m[1]);
|
| 19 |
+
// whole-file workspace fold: seed with the engine's default tokens, collect
|
| 20 |
+
// every token defined anywhere in the document, then compile each block
|
| 21 |
+
// against the union. Guides are read non-linearly; examples may reference
|
| 22 |
+
// tokens from any example on the page.
|
| 23 |
+
let registry = { ...DEFAULT_TOKENS };
|
| 24 |
+
for (const src of blocks) {
|
| 25 |
+
const { ast, errors: pe } = parse(src, 'tokens-pass');
|
| 26 |
+
const doc = resolve(ast, 'tokens-pass', registry);
|
| 27 |
+
registry = { ...registry, ...doc.tokens };
|
| 28 |
+
}
|
| 29 |
+
blocks.forEach((src, i) => {
|
| 30 |
+
const name = `${path.split('/').pop()} block ${i + 1}`;
|
| 31 |
+
const { ast, errors: parseErrors } = parse(src, name);
|
| 32 |
+
const doc = resolve(ast, name, registry);
|
| 33 |
+
const errors = [...parseErrors, ...doc.errors];
|
| 34 |
+
if (errors.length === 0) { pass++; return; }
|
| 35 |
+
if (!strict && /dssl:has-errors/.test(src)) { pass++; return; }
|
| 36 |
+
fail++;
|
| 37 |
+
console.error(`FAIL ${name}:`);
|
| 38 |
+
for (const e of errors.slice(0, 4)) console.error(' line ' + (e.line ?? '?') + ': ' + e.message);
|
| 39 |
+
});
|
| 40 |
+
console.log(`${path.split('/').pop()}: ${blocks.length} blocks checked`);
|
| 41 |
+
}
|
| 42 |
+
console.log(`${pass} passed, ${fail} failed`);
|
| 43 |
+
process.exitCode = fail ? 1 : 0;
|
scripts/test-v050.mjs
CHANGED
|
@@ -192,5 +192,81 @@ console.log(`\n${pass} passed, ${fail} failed`);
|
|
| 192 |
check('11 bare content auto-quoted', c && c.value === '""', c && c.value);
|
| 193 |
}
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
console.log(`${pass} passed, ${fail} failed`);
|
| 196 |
process.exit(fail ? 1 : 0);
|
|
|
|
| 192 |
check('11 bare content auto-quoted', c && c.value === '""', c && c.value);
|
| 193 |
}
|
| 194 |
|
| 195 |
+
console.log(`${pass} passed, ${fail} failed`);
|
| 196 |
+
|
| 197 |
+
// 12. overlap policy β the "nothing below 100% opacity overlaps" feature
|
| 198 |
+
{
|
| 199 |
+
const { doc, errors } = compile(`
|
| 200 |
+
body { overlap: auto }
|
| 201 |
+
.badge { overlap: deny }
|
| 202 |
+
.scrim { overlap: allow }
|
| 203 |
+
.off { overlap: none }
|
| 204 |
+
`);
|
| 205 |
+
check('12 no errors', errors.length === 0, JSON.stringify(errors));
|
| 206 |
+
const get = (sel) => doc.rules.find(r => r.sel === sel)?.decls.find(d => d.prop === '@overlap')?.value;
|
| 207 |
+
check('12 body policy = auto', get('body') === 'auto', get('body'));
|
| 208 |
+
check('12 deny passes through', get('.badge') === 'deny', get('.badge'));
|
| 209 |
+
check('12 allow passes through', get('.scrim') === 'allow', get('.scrim'));
|
| 210 |
+
check('12 none is an alias of allow', get('.off') === 'allow', get('.off'));
|
| 211 |
+
|
| 212 |
+
const bad = compile('.card { overlap: never }');
|
| 213 |
+
check('12 bad policy is a LOUD error', bad.errors.length === 1 && /unknown overlap policy/.test(bad.errors[0].message), JSON.stringify(bad.errors));
|
| 214 |
+
check('12 hint names the vocabulary', /allow|deny|auto/.test(bad.errors[0].hint || ''));
|
| 215 |
+
|
| 216 |
+
const dead = compile('.card { overlap: nevr }');
|
| 217 |
+
check('12 nothing emitted on error', dead.doc.rules.length === 0);
|
| 218 |
+
|
| 219 |
+
// site.dssl now carries the global switch β regression-guard it
|
| 220 |
+
const fs = await import('node:fs');
|
| 221 |
+
const site = fs.readFileSync(new URL('../public/styles/site.dssl', import.meta.url).pathname, 'utf8');
|
| 222 |
+
check('12 site.dssl body opts in', /overlap:\s*auto/.test(site));
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// 13. the escape math (overlapNudge) β pure function, no DOM needed
|
| 226 |
+
{
|
| 227 |
+
const { overlapNudge } = await import('../public/dssl/engine.js');
|
| 228 |
+
const box = (l, t, r, b) => ({ left: l, top: t, right: r, bottom: b });
|
| 229 |
+
|
| 230 |
+
// candidate [0,0,100,100] vs obstacle [50,0,150,100]: push left past the
|
| 231 |
+
// near edge costs 50, push right past the far edge costs 150 β left wins
|
| 232 |
+
let n = overlapNudge(box(0, 0, 100, 100), [box(50, 0, 150, 100)]);
|
| 233 |
+
check('13 shortest escape wins (left 50)', n.x === -50 && n.y === 0, JSON.stringify(n));
|
| 234 |
+
|
| 235 |
+
// deep obstacle above, 10px of y-penetration β dropping down 10 beats climbing 200
|
| 236 |
+
n = overlapNudge(box(0, 90, 200, 200), [box(0, 0, 200, 100)]);
|
| 237 |
+
check('13 vertical kiss resolves down 10', n.x === 0 && n.y === 10, JSON.stringify(n));
|
| 238 |
+
|
| 239 |
+
// no overlap β no nudge
|
| 240 |
+
n = overlapNudge(box(0, 0, 10, 10), [box(20, 20, 30, 30)]);
|
| 241 |
+
check('13 clear boxes stay put', n.x === 0 && n.y === 0, JSON.stringify(n));
|
| 242 |
+
|
| 243 |
+
// touching edges are NOT overlap (the shared-border tremble guard)
|
| 244 |
+
n = overlapNudge(box(0, 0, 100, 100), [box(100, 0, 200, 100)]);
|
| 245 |
+
check('13 touching edges do not count', n.x === 0 && n.y === 0, JSON.stringify(n));
|
| 246 |
+
|
| 247 |
+
// two walls with a real gap between them β nudge must land inside the gap
|
| 248 |
+
n = overlapNudge(box(70, 0, 90, 100), [box(0, 0, 80, 100), box(120, 0, 200, 100)]);
|
| 249 |
+
const cleared = (b, obs) => obs.every(o =>
|
| 250 |
+
Math.min(b.right, o.right) - Math.max(b.left, o.left) <= 0.01 ||
|
| 251 |
+
Math.min(b.bottom, o.bottom) - Math.max(b.top, o.top) <= 0.01);
|
| 252 |
+
const moved = box(70 + n.x, 0 + n.y, 90 + n.x, 100 + n.y);
|
| 253 |
+
check('13 box clears both walls into the gap', n.x === 10 && cleared(moved, [box(0, 0, 80, 100), box(120, 0, 200, 100)]), JSON.stringify({ n, moved }));
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
console.log(`${pass} passed, ${fail} failed`);
|
| 257 |
+
|
| 258 |
+
// 14. engine-native props inside pseudo blocks fail loud (no junk shadow CSS)
|
| 259 |
+
{
|
| 260 |
+
const a = compile('.card::before { animate: pop 400 }');
|
| 261 |
+
check('14 nested animate-in-pseudo is LOUD', a.errors.length === 1 && /engine-native/.test(a.errors[0].message), JSON.stringify(a.errors));
|
| 262 |
+
check('14 nothing leaked to shadow CSS', a.doc.shadowRules.length === 0 && a.doc.rules.length === 0);
|
| 263 |
+
|
| 264 |
+
const b = compile('.card::before { overlap: deny }');
|
| 265 |
+
check('14 top-level overlap-in-pseudo is LOUD', b.errors.length === 1 && /engine-native/.test(b.errors[0].message), JSON.stringify(b.errors));
|
| 266 |
+
|
| 267 |
+
const c = compile('.card { ::after { --x: 1 } }');
|
| 268 |
+
check('14 custom props still pass in pseudo blocks', c.errors.length === 0 && c.doc.shadowRules.length === 1, JSON.stringify(c.errors));
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
console.log(`${pass} passed, ${fail} failed`);
|
| 272 |
process.exit(fail ? 1 : 0);
|
scripts/test-v060.mjs
CHANGED
|
File without changes
|
scripts/test-v070.mjs
CHANGED
|
@@ -126,7 +126,7 @@ console.log('5 β freeze/unfreeze/explain exist as API surface');
|
|
| 126 |
ok(typeof eng.freeze === 'function', 'freeze exported');
|
| 127 |
ok(typeof eng.unfreeze === 'function', 'unfreeze exported');
|
| 128 |
ok(typeof eng.explain === 'function', 'explain exported');
|
| 129 |
-
ok(eng.VERSION =
|
| 130 |
ok(typeof eng.DSSL.freeze === 'function' && typeof eng.DSSL.explain === 'function', 'DSSL object carries the new API');
|
| 131 |
}
|
| 132 |
const engMod = await import('../public/dssl/engine.js');
|
|
|
|
| 126 |
ok(typeof eng.freeze === 'function', 'freeze exported');
|
| 127 |
ok(typeof eng.unfreeze === 'function', 'unfreeze exported');
|
| 128 |
ok(typeof eng.explain === 'function', 'explain exported');
|
| 129 |
+
ok(parseInt(String(eng.VERSION).split('.')[1]) >= 7, 'VERSION is 0.7.x or newer');
|
| 130 |
ok(typeof eng.DSSL.freeze === 'function' && typeof eng.DSSL.explain === 'function', 'DSSL object carries the new API');
|
| 131 |
}
|
| 132 |
const engMod = await import('../public/dssl/engine.js');
|
scripts/test-v071.mjs
CHANGED
|
File without changes
|
scripts/test-v072.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// v0.7.2 β regression suite for the external-review findings.
|
| 3 |
+
// Every assertion maps to a reported bug or a fixed behavior.
|
| 4 |
+
import { parse, STATE_WORDS } from '../public/dssl/parser.js';
|
| 5 |
+
import { resolve } from '../public/dssl/resolver.js';
|
| 6 |
+
import { VERSION } from '../public/dssl/index.js';
|
| 7 |
+
import { stateReach, gatePass } from '../public/dssl/engine.js';
|
| 8 |
+
|
| 9 |
+
let pass = 0, fail = 0;
|
| 10 |
+
const ok = (cond, msg) => { if (cond) { pass++; } else { fail++; console.error('FAIL:', msg); } };
|
| 11 |
+
|
| 12 |
+
function compile(src) {
|
| 13 |
+
const { ast, errors } = parse(src, 't.dssl');
|
| 14 |
+
const doc = resolve(ast, 't.dssl', {});
|
| 15 |
+
return { doc, parseErrors: errors };
|
| 16 |
+
}
|
| 17 |
+
const declsOf = (doc, sel) => doc.rules.filter(r => r.sel === sel).flatMap(r => r.decls);
|
| 18 |
+
|
| 19 |
+
/* 1 β THE headline: nested state must sort AFTER the base rule */
|
| 20 |
+
{
|
| 21 |
+
const { doc } = compile('.card {\n background: #26201a\n hover { background: #c0392b }\n}');
|
| 22 |
+
const base = doc.rules.find(r => r.sel === '.card');
|
| 23 |
+
const hov = doc.rules.find(r => r.sel === '.card:hover');
|
| 24 |
+
ok(base && hov, 'nested hover: both rules exist');
|
| 25 |
+
ok(hov.order > base.order, 'nested hover sorts after base (cascade fix)');
|
| 26 |
+
ok(declsOf(doc, '.card').some(d => d.value === '#26201a'), 'base declaration survives');
|
| 27 |
+
ok(declsOf(doc, '.card:hover').some(d => d.value === '#c0392b'), 'hover declaration survives');
|
| 28 |
+
}
|
| 29 |
+
/* 1b β nested media keeps its condition AND sorts after base */
|
| 30 |
+
{
|
| 31 |
+
const { doc } = compile('.card {\n color: red\n @media dark { color: blue }\n}');
|
| 32 |
+
const dark = doc.rules.find(r => r.sel === '.card' && r.media.length);
|
| 33 |
+
ok(dark && dark.media.length === 1, 'nested @media keeps its condition');
|
| 34 |
+
const base = doc.rules.find(r => r.sel === '.card' && !r.media.length);
|
| 35 |
+
ok(dark && base && dark.order > base.order, 'nested @media sorts after base');
|
| 36 |
+
}
|
| 37 |
+
/* 1c β nested signal keeps its condition */
|
| 38 |
+
{
|
| 39 |
+
const { doc } = compile('.card {\n color: red\n @signal on(true) { color: blue }\n}');
|
| 40 |
+
const sig = doc.rules.find(r => r.sel === '.card' && r.signals.length);
|
| 41 |
+
ok(sig && sig.signals.length === 1, 'nested @signal keeps its condition');
|
| 42 |
+
}
|
| 43 |
+
/* 1d β nested layer keeps its layer */
|
| 44 |
+
{
|
| 45 |
+
const { doc } = compile('.card {\n color: red\n @layer utilities { color: blue }\n}');
|
| 46 |
+
const lay = doc.rules.find(r => r.sel === '.card' && r.layer === 'utilities');
|
| 47 |
+
ok(lay, 'nested @layer keeps its layer');
|
| 48 |
+
}
|
| 49 |
+
/* 1e β decls AFTER a nested block become a later fragment (later wins) */
|
| 50 |
+
{
|
| 51 |
+
const { doc } = compile('.card {\n color: red\n hover { color: green }\n color: blue\n}');
|
| 52 |
+
ok(declsOf(doc, '.card').some(d => d.value === 'red'), 'pre-block decl kept');
|
| 53 |
+
ok(declsOf(doc, '.card').some(d => d.value === 'blue'), 'post-block decl kept');
|
| 54 |
+
ok(declsOf(doc, '.card:hover').some(d => d.value === 'green'), 'nested decl kept');
|
| 55 |
+
}
|
| 56 |
+
/* 2 β raw hex alpha: #ff0000/50 is color-mix, never /50px */
|
| 57 |
+
{
|
| 58 |
+
const { doc } = compile('.card { background: #ff0000/50 }');
|
| 59 |
+
const v = declsOf(doc, '.card')[0]?.value;
|
| 60 |
+
ok(v === 'color-mix(in oklab, #ff0000 50%, transparent)', 'raw hex /50 becomes color-mix, got: ' + v);
|
| 61 |
+
}
|
| 62 |
+
/* 3 β time properties: bare numbers are ms */
|
| 63 |
+
{
|
| 64 |
+
const { doc } = compile('.card { transition-delay: 200; animation-duration: 0.5 }');
|
| 65 |
+
const d = declsOf(doc, '.card');
|
| 66 |
+
ok(d.some(x => x.prop === 'transition-delay' && x.value === '200ms'), 'transition-delay: 200 -> 200ms');
|
| 67 |
+
ok(d.some(x => x.prop === 'animation-duration' && x.value === '0.5ms' || x.prop === 'animation-duration' && x.value === '500ms'), 'animation-duration bare -> ms, got: ' + JSON.stringify(d));
|
| 68 |
+
}
|
| 69 |
+
/* 4 β grid shorthand: gap is a first-class word */
|
| 70 |
+
{
|
| 71 |
+
const { doc } = compile('.grid { grid: 3 gap 12 }');
|
| 72 |
+
const d = declsOf(doc, '.grid');
|
| 73 |
+
ok(d.some(x => x.prop === 'grid-template-columns' && x.value === 'repeat(3, 1fr)'), 'grid: 3 -> repeat(3, 1fr)');
|
| 74 |
+
ok(d.some(x => x.prop === 'gap' && x.value === '12px'), 'grid gap 12 -> gap: 12px');
|
| 75 |
+
const { doc: d2 } = compile('.grid { grid: 2 rows 3 gap 8 }');
|
| 76 |
+
const d2l = declsOf(d2, '.grid');
|
| 77 |
+
ok(d2l.some(x => x.prop === 'grid-template-rows' && x.value === 'repeat(3, 1fr)'), 'grid rows 3 -> template rows');
|
| 78 |
+
}
|
| 79 |
+
/* 5 β seen { } with arrow is a LOUD error pointing at the real patterns */
|
| 80 |
+
{
|
| 81 |
+
const { doc } = compile('.card { seen { opacity: 0 -> 1 } }');
|
| 82 |
+
ok(doc.errors.some(e => e.message.includes('->') && e.hint.includes('animate')), 'seen arrow -> loud error with animate hint');
|
| 83 |
+
const { doc: d2 } = compile('motion rise { from { opacity: 0 } }\n.card { seen { animate: rise 400 } }');
|
| 84 |
+
ok(d2.errors.length === 0, 'seen { animate } stays legal');
|
| 85 |
+
}
|
| 86 |
+
/* 6 β unknown nested state word warns instead of silently becoming a descendant */
|
| 87 |
+
{
|
| 88 |
+
const { doc } = compile('.card { hovr { background: red } }');
|
| 89 |
+
ok(doc.warnings.some(w => w.message.includes('hovr') && w.hint.includes('hover')), 'hovr typo warns with did-you-mean');
|
| 90 |
+
const { doc: d2 } = compile('.card { title { color: red } }');
|
| 91 |
+
ok(!d2.warnings.some(w => w.message.includes('title')), 'legit descendant word does not warn');
|
| 92 |
+
}
|
| 93 |
+
/* 7 β SVG + modern properties pass through */
|
| 94 |
+
{
|
| 95 |
+
const { doc } = compile('.card { fill: red; stroke: blue; stroke-width: 2; text-wrap: balance; content-visibility: auto }');
|
| 96 |
+
const d = declsOf(doc, '.card');
|
| 97 |
+
for (const [p, v] of [['fill', 'red'], ['stroke', 'blue'], ['stroke-width', '2px'], ['text-wrap', 'balance'], ['content-visibility', 'auto']]) {
|
| 98 |
+
ok(d.some(x => x.prop === p && x.value === v), `property ${p}: ${v} resolves`);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
/* 8 β ::part() rides the shadow pipeline, ::slotted() is a loud error */
|
| 102 |
+
{
|
| 103 |
+
const { doc } = compile('my-card::part(header) { background: red }');
|
| 104 |
+
ok(doc.errors.length === 0, '::part() compiles');
|
| 105 |
+
ok(doc.shadowRules.some(r => r.sel === 'my-card' && r.pseudo.includes('part(header)')), '::part() rides shadowRules');
|
| 106 |
+
const { doc: d2 } = compile('my-card::slotted(p) { background: red }');
|
| 107 |
+
ok(d2.errors.some(e => e.message.includes('shadow tree')), '::slotted() gets the shadow-tree explanation');
|
| 108 |
+
}
|
| 109 |
+
/* 9 β one-line decls + nested block on the same line */
|
| 110 |
+
{
|
| 111 |
+
const { doc, parseErrors } = compile('.card { background: red; hover { background: blue } }');
|
| 112 |
+
ok(parseErrors.length === 0, 'one-line mix parses');
|
| 113 |
+
ok(declsOf(doc, '.card').some(d => d.value === 'red'), 'one-line: base decl survives');
|
| 114 |
+
ok(declsOf(doc, '.card:hover').some(d => d.value === 'blue'), 'one-line: hover decl survives');
|
| 115 |
+
}
|
| 116 |
+
/* 10 β stateReach: the hover scoping math */
|
| 117 |
+
{
|
| 118 |
+
ok(stateReach('.card:hover', 'hover') === null, 'subject-state reach = null (chain only)');
|
| 119 |
+
const d = stateReach('.card:hover .title', 'hover');
|
| 120 |
+
ok(d && d.prefix === '.card' && d.rest === '.title', 'descendant reach: prefix .card rest .title');
|
| 121 |
+
const s = stateReach('.tab:hover + .panel', 'hover');
|
| 122 |
+
ok(s && s.sib === '+' && s.firstSeg === '.panel', 'sibling reach: + .panel');
|
| 123 |
+
const f = stateReach('.card:has(:hover) .x', 'hover');
|
| 124 |
+
ok(f && f.fallback === true, ':has() wrapper falls back to page-wide superset');
|
| 125 |
+
const deep = stateReach('.list .card:hover .title', 'hover');
|
| 126 |
+
ok(deep && deep.prefix === '.list .card' && deep.rest === '.title', 'deep ancestor prefix');
|
| 127 |
+
}
|
| 128 |
+
/* 11 β gatePass still duck-types on fake elements (unchanged contract) */
|
| 129 |
+
{
|
| 130 |
+
ok(gatePass({ tagName: 'div', classList: { contains: () => true } }, null) === true, 'gatePass null gate = true');
|
| 131 |
+
}
|
| 132 |
+
/* 12 β version */
|
| 133 |
+
{
|
| 134 |
+
ok(VERSION === '0.7.2', 'engine VERSION is 0.7.2');
|
| 135 |
+
ok(STATE_WORDS.has('hover') && STATE_WORDS.has('seen'), 'STATE_WORDS exported');
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
console.log(`${pass} passed, ${fail} failed`);
|
| 139 |
+
process.exitCode = fail ? 1 : 0;
|