Z User commited on
Commit
7bd797e
·
1 Parent(s): 4a57e20

v0.5.0: the pseudo-element trap closes — full selectors compile to shadow CSS; infinite/alternate; bare font names; hyphens: manual; docs gained the missing section (+ HomeView get-the-engine sync)

Browse files
README.md CHANGED
@@ -48,7 +48,7 @@ Design tokens are built in (`color.paper`, `space.4`, and the `/92` at the end i
48
 
49
  This repo eats its own cooking. Everything you see on the website is styled by the engine in `public/dssl/` running the files in `public/styles/`. If the engine breaks, the site shows up naked. That is a feature.
50
 
51
- ## What works today (v0.4.2)
52
 
53
  - flexbox layout
54
  - behaviors and `use`
@@ -56,8 +56,10 @@ This repo eats its own cooking. Everything you see on the website is styled by t
56
  - seen states and stagger delays
57
  - per-property transitions
58
  - visually-hidden
59
- - themed native scrollbars (vendor pseudo-elements, the shadow CSS exception grown wider)
60
- - pseudo elements (the one exception: they compile to shadow CSS, because the DOM gives you no other way to reach them)
 
 
61
 
62
  ## Try it
63
 
@@ -102,7 +104,7 @@ Clone this repo if you want to read the engine. Everything runs in the browser.
102
 
103
  ## Status
104
 
105
- v0.4.2, experimental, moving fast. The language grows whenever the site needs something it cannot express yet, so the site doubles as the test suite.
106
 
107
  ## License
108
 
 
48
 
49
  This repo eats its own cooking. Everything you see on the website is styled by the engine in `public/dssl/` running the files in `public/styles/`. If the engine breaks, the site shows up naked. That is a feature.
50
 
51
+ ## What works today (v0.5.0)
52
 
53
  - flexbox layout
54
  - behaviors and `use`
 
56
  - seen states and stagger delays
57
  - per-property transitions
58
  - visually-hidden
59
+ - pseudo elements in any spelling: `.card::before`, nested `::before`, or `&::before` (full selectors are split and compiled to shadow CSS, because the DOM gives you no other way to reach them; unknown names, `:seen` mixes and mixed selector lists are loud errors)
60
+ - looping animation: `animate: pulse 1200 infinite alternate`
61
+ - bare font names: `font-family: JetBrains Mono, monospace` and `font.*` tokens without quotes
62
+ - themed native scrollbars (vendor pseudo-elements, trailing states included)
63
 
64
  ## Try it
65
 
 
104
 
105
  ## Status
106
 
107
+ v0.5.0, experimental, moving fast. The 7.5/10 review's bump condition (fix the silent pseudo-element trap) shipped as the headline of this release. The language grows whenever the site needs something it cannot express yet, so the site doubles as the test suite.
108
 
109
  ## License
110
 
public/dssl/engine.js CHANGED
@@ -625,6 +625,8 @@ export class DsslEngine {
625
  const anim = el.animate(frames, {
626
  duration: spec.duration, easing: spec.easing,
627
  delay: spec.delay || 0, fill: spec.fill,
 
 
628
  });
629
  anims.set(spec.key, anim);
630
  } catch { /* WAAPI unsupported property — skip */ }
 
625
  const anim = el.animate(frames, {
626
  duration: spec.duration, easing: spec.easing,
627
  delay: spec.delay || 0, fill: spec.fill,
628
+ iterations: spec.iterations || 1, // v0.5.0 — `infinite`
629
+ direction: spec.direction || 'normal', // v0.5.0 — `alternate`
630
  });
631
  anims.set(spec.key, anim);
632
  } catch { /* WAAPI unsupported property — skip */ }
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.4.2';
22
 
23
  let counter = 0;
24
  const engines = new Map();
 
18
  import { resolve, DEFAULT_TOKENS } from './resolver.js';
19
  import { DsslEngine } from './engine.js';
20
 
21
+ export const VERSION = '0.5.0';
22
 
23
  let counter = 0;
24
  const engines = new Map();
public/dssl/parser.js CHANGED
@@ -21,7 +21,11 @@ const STATE_WORDS = new Set([
21
  'seen', // v0.4.0 — engine-native: fires once when the element enters the viewport
22
  ]);
23
 
24
- const PSEUDO_ELEMENTS = new Set([
 
 
 
 
25
  'before', 'after', 'placeholder', 'selection', 'marker',
26
  'first-line', 'first-letter', 'backdrop', 'file-selector-button',
27
  ]);
@@ -30,7 +34,7 @@ const PSEUDO_ELEMENTS = new Set([
30
  // and friends). The DOM gives you no other way to reach native scrollbars,
31
  // so they ride the same shadow-CSS exception as ::before/::after. The name
32
  // may carry trailing state (::hover, :horizontal) — it is emitted as-is.
33
- const VENDOR_PSEUDO = /^-(webkit|moz|ms)-/;
34
 
35
  /** find index of first top-level occurrence of `ch` (skips strings + parens) */
36
  function scanFor(str, ch, from = 0) {
 
21
  'seen', // v0.4.0 — engine-native: fires once when the element enters the viewport
22
  ]);
23
 
24
+ // v0.5.0 — exported so the resolver can validate the `::name` half whenever a
25
+ // full selector (.card::before) carries a pseudo-element. The resolver splits
26
+ // those into element + pseudo and routes them into shadow CSS — previously a
27
+ // top-level `.card::before { }` compiled ok and then matched NOTHING.
28
+ export const PSEUDO_ELEMENTS = new Set([
29
  'before', 'after', 'placeholder', 'selection', 'marker',
30
  'first-line', 'first-letter', 'backdrop', 'file-selector-button',
31
  ]);
 
34
  // and friends). The DOM gives you no other way to reach native scrollbars,
35
  // so they ride the same shadow-CSS exception as ::before/::after. The name
36
  // may carry trailing state (::hover, :horizontal) — it is emitted as-is.
37
+ export const VENDOR_PSEUDO = /^-(webkit|moz|ms)-/;
38
 
39
  /** find index of first top-level occurrence of `ch` (skips strings + parens) */
40
  function scanFor(str, ch, from = 0) {
public/dssl/resolver.js CHANGED
@@ -18,6 +18,9 @@
18
  * ========================================================================= */
19
 
20
  import { levenshtein, didYouMean } from './utils.js';
 
 
 
21
 
22
  /* ------------------------------------------------------------------ *
23
  * Vocabulary
@@ -54,6 +57,9 @@ const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,au
54
  'isolate,plaintext,sideways,upright,mixed,ltr,rtl,transform,auto,' +
55
  '-webkit-box,-webkit-inline-box,' +
56
  'thin,thick,medium,separate,collapse,vertical,horizontal,balance,pretty,orphans,' +
 
 
 
57
  'flex-start,flex-end,self-start,self-end,snap-block,snap-inline').split(','));
58
 
59
  const UNITLESS_PROPS = new Set([
@@ -241,6 +247,14 @@ function resolveValue(raw, ctx) {
241
  continue;
242
  }
243
 
 
 
 
 
 
 
 
 
244
  err(`unknown value "${ident}" for property "${ctx.prop}"`,
245
  didYouMean(ident, [...ctx.tokenNames, ...CSS_KEYWORDS]));
246
  out += ident; // keep going so the rest still resolves
@@ -523,7 +537,11 @@ const SEMANTIC = {
523
  }
524
  if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
525
  if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
526
- ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards'])) });
 
 
 
 
527
  }
528
  return [{ prop: '@animate', value: spec }];
529
  },
@@ -559,7 +577,7 @@ function resolveDecl(prop, value, ctx) {
559
  const outProp = prop.includes('.')
560
  ? '--dssl-' + prop.slice(2).replace(/\./g, '-')
561
  : prop;
562
- return [{ prop: outProp, value: resolveValue(value, { ...ctx2, unitless: false }) }];
563
  }
564
 
565
  const sem = SEMANTIC[prop];
@@ -573,7 +591,7 @@ function resolveDecl(prop, value, ctx) {
573
  });
574
  return [];
575
  }
576
- return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop) }) }];
577
  }
578
 
579
  /* ------------------------------------------------------------------ *
@@ -722,7 +740,8 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
722
  for (const key of keys) {
723
  const raw = tokenMap.get(key);
724
  const prop = '--dssl-' + key.replace(/\./g, '-');
725
- decls.push({ prop, value: resolveValue(raw, { ...ctxBase, prop: key }) });
 
726
  }
727
  if (!decls.length) return null;
728
  return {
@@ -850,6 +869,45 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
850
  errors.push({ file, line, message: 'declarations must live inside a selector', hint: 'top-level blocks are tokens, behavior, motion, @layer, @media… — or a selector like .card' });
851
  return;
852
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
853
  rules.push({
854
  id: ruleId++, sel: ctx.sel, media: ctx.media, signals: ctx.signals,
855
  containers: ctx.containers, layer: ctx.layer, order: order++,
@@ -897,6 +955,48 @@ function combine(parent, child) {
897
  return parent + ' ' + child;
898
  }
899
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900
  const STATE_SCAN = /:(hover|focus-visible|focus|active|disabled|checked|empty|target|invalid|valid|required|open|closed|read-only|read-write|placeholder-shown|first-child|last-child|only-child|seen)\b/g;
901
  function scanStates(sel) {
902
  const s = new Set();
 
18
  * ========================================================================= */
19
 
20
  import { levenshtein, didYouMean } 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
 
57
  'isolate,plaintext,sideways,upright,mixed,ltr,rtl,transform,auto,' +
58
  '-webkit-box,-webkit-inline-box,' +
59
  'thin,thick,medium,separate,collapse,vertical,horizontal,balance,pretty,orphans,' +
60
+ // v0.5.0 — hyphens: manual used to be rejected ("unknown value") even though
61
+ // the property existed; plus the rendering keywords people actually type.
62
+ 'manual,pixelated,crisp-edges,optimizeSpeed,optimizeLegibility,geometricPrecision,' +
63
  'flex-start,flex-end,self-start,self-end,snap-block,snap-inline').split(','));
64
 
65
  const UNITLESS_PROPS = new Set([
 
247
  continue;
248
  }
249
 
250
+ // v0.5.0 — font names are proper nouns: JetBrains Mono, IBM Plex Serif.
251
+ // In font-family values and font.* tokens, bare multi-word names pass
252
+ // through instead of demanding quotes. Everywhere else stays loud.
253
+ if (ctx.bareIdents && /^[A-Za-z][A-Za-z0-9_-]*$/.test(ident)) {
254
+ out += ident;
255
+ continue;
256
+ }
257
+
258
  err(`unknown value "${ident}" for property "${ctx.prop}"`,
259
  didYouMean(ident, [...ctx.tokenNames, ...CSS_KEYWORDS]));
260
  out += ident; // keep going so the rest still resolves
 
537
  }
538
  if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
539
  if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
540
+ // v0.5.0 — looping: `infinite` runs forever, `alternate` ping-pongs.
541
+ // animate: pulse 1600 infinite alternate — was impossible before.
542
+ if (a === 'infinite') { spec.iterations = Infinity; continue; }
543
+ if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
544
+ ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate'])) });
545
  }
546
  return [{ prop: '@animate', value: spec }];
547
  },
 
577
  const outProp = prop.includes('.')
578
  ? '--dssl-' + prop.slice(2).replace(/\./g, '-')
579
  : prop;
580
+ return [{ prop: outProp, value: resolveValue(value, { ...ctx2, unitless: false, bareIdents: true }) }];
581
  }
582
 
583
  const sem = SEMANTIC[prop];
 
591
  });
592
  return [];
593
  }
594
+ return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
595
  }
596
 
597
  /* ------------------------------------------------------------------ *
 
740
  for (const key of keys) {
741
  const raw = tokenMap.get(key);
742
  const prop = '--dssl-' + key.replace(/\./g, '-');
743
+ // font.* tokens hold font stacks — proper nouns, not keywords (v0.5.0)
744
+ decls.push({ prop, value: resolveValue(raw, { ...ctxBase, prop: key, bareIdents: /^font/.test(key) }) });
745
  }
746
  if (!decls.length) return null;
747
  return {
 
869
  errors.push({ file, line, message: 'declarations must live inside a selector', hint: 'top-level blocks are tokens, behavior, motion, @layer, @media… — or a selector like .card' });
870
  return;
871
  }
872
+ // v0.5.0 — the pseudo-element trap fix. A full selector like `.card::before`
873
+ // used to compile ok and then match NOTHING: inline styles can never reach
874
+ // pseudo-elements. Now the selector is split into element + pseudo halves
875
+ // and the rule rides the shadow-CSS pipeline — exactly what the nested
876
+ // form (`.card { ::before { } }`) always did. Both forms are now one thing.
877
+ const ps = analyzePseudo(ctx.sel);
878
+ if (ps.mode === 'all') {
879
+ // parity with the nested path: bare `content:` becomes content: ""
880
+ const decls = sub.decls.map(d => d.prop === 'content' && d.value === '' ? { ...d, value: '""' } : d);
881
+ for (const { el, pseudo } of ps.parts) {
882
+ const name = pseudo.split(/[\s:(]/)[0];
883
+ if (!PSEUDO_ELEMENTS.has(name) && !VENDOR_PSEUDO.test(name)) {
884
+ errors.push({
885
+ file, line,
886
+ message: `unknown pseudo-element "::${pseudo}"`,
887
+ hint: 'try ::before, ::after, ::selection, or a vendor one like ::-webkit-scrollbar-thumb',
888
+ });
889
+ continue;
890
+ }
891
+ if (/:seen\b/.test(el)) {
892
+ errors.push({
893
+ file, line,
894
+ message: `the engine-native "seen" state can't combine with "::${name}" in shadow CSS`,
895
+ hint: 'style the element itself in a seen { } block instead',
896
+ });
897
+ continue;
898
+ }
899
+ shadowRules.push({
900
+ id: ruleId++, sel: el, pseudo,
901
+ media: ctx.media, signals: ctx.signals, containers: ctx.containers,
902
+ layer: ctx.layer, order: order++, decls, file, line,
903
+ });
904
+ }
905
+ return;
906
+ }
907
+ if (ps.mode === 'mixed') {
908
+ errors.push({ file, line, message: `selector list mixes pseudo-element and plain selectors: "${ctx.sel}"`, hint: 'split it into two rules — one for the pseudo-element, one for the rest' });
909
+ return;
910
+ }
911
  rules.push({
912
  id: ruleId++, sel: ctx.sel, media: ctx.media, signals: ctx.signals,
913
  containers: ctx.containers, layer: ctx.layer, order: order++,
 
955
  return parent + ' ' + child;
956
  }
957
 
958
+ /* v0.5.0 — where does a selector carry a pseudo-element?
959
+ * 'none' — plain selector, inline-style path
960
+ * 'all' — every comma-part has one: { parts: [{ el, pseudo }] } → shadow CSS
961
+ * 'mixed' — some parts have one, some don't → loud error (split the list)
962
+ * The `::` scan is string- and paren-aware so [data-x="::a"] can't false-hit. */
963
+ function analyzePseudo(sel) {
964
+ // top-level comma split
965
+ const parts = [];
966
+ let depth = 0, q = null, cur = '';
967
+ for (const ch of sel) {
968
+ if (q) { cur += ch; if (ch === q) q = null; continue; }
969
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
970
+ if (ch === '(' || ch === '[') depth++;
971
+ else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
972
+ if (ch === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
973
+ cur += ch;
974
+ }
975
+ parts.push(cur);
976
+
977
+ const splits = [];
978
+ let withPseudo = 0;
979
+ for (const part of parts) {
980
+ let idx = -1; depth = 0; q = null;
981
+ for (let i = 0; i < part.length - 1; i++) {
982
+ const ch = part[i];
983
+ if (q) { if (ch === q && part[i - 1] !== '\\') q = null; continue; }
984
+ if (ch === '"' || ch === "'") { q = ch; continue; }
985
+ if (ch === '(' || ch === '[') depth++;
986
+ else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
987
+ else if (depth === 0 && ch === ':' && part[i + 1] === ':') { idx = i; break; }
988
+ }
989
+ if (idx === -1) { splits.push(null); continue; }
990
+ withPseudo++;
991
+ const el = part.slice(0, idx).trim();
992
+ const pseudo = part.slice(idx + 2).trim();
993
+ splits.push(el && pseudo ? { el, pseudo } : null);
994
+ }
995
+ if (withPseudo === 0) return { mode: 'none' };
996
+ if (withPseudo !== parts.length || splits.some(s => !s)) return { mode: 'mixed', parts: splits };
997
+ return { mode: 'all', parts: splits };
998
+ }
999
+
1000
  const STATE_SCAN = /:(hover|focus-visible|focus|active|disabled|checked|empty|target|invalid|valid|required|open|closed|read-only|read-write|placeholder-shown|first-child|last-child|only-child|seen)\b/g;
1001
  function scanStates(sel) {
1002
  const s = new Set();
public/styles/docs.dssl CHANGED
@@ -117,8 +117,15 @@
117
  }
118
 
119
  // tables (semantic property reference, spec votes, …)
 
 
 
 
120
  .tbl {
121
- width: 100%
 
 
 
122
  border-collapse: collapse
123
  font-size: 13.5
124
  }
@@ -488,3 +495,49 @@ motion rise-in {
488
  font-size: 12.5
489
  color: color.muted
490
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  }
118
 
119
  // tables (semantic property reference, spec votes, …)
120
+ // v0.5 — display: block + fit-content + internal scroll: the raw table's
121
+ // min-content width (705px) used to push the whole page past 360px viewports
122
+ // — the last un-swept overflow on this page. Same rules, now owned by the
123
+ // stylesheet that actually styles the table, so last-word-wins stays honest.
124
  .tbl {
125
+ display: block
126
+ width: fit-content
127
+ max-width: 100%
128
+ overflow-x: auto
129
  border-collapse: collapse
130
  font-size: 13.5
131
  }
 
495
  font-size: 12.5
496
  color: color.muted
497
  }
498
+
499
+ // =========================================================================
500
+ // v0.5 — the pseudo-elements section's live demo. The rules below are
501
+ // written in the FULL-SELECTOR form (.pe-tag::before) — the spelling that
502
+ // used to compile ok and then match nothing. The demo renders right only
503
+ // if the trap stays closed. The dot is also the site's first `infinite`
504
+ // animation, so v0.5 dogfoods both headline features at once.
505
+ // =========================================================================
506
+
507
+ motion pe-pulse {
508
+ from { opacity: 1 }
509
+ to { opacity: 0.2 }
510
+ }
511
+
512
+ .pe-demo {
513
+ row: gap 12 center wrap
514
+ padding: space.4 space.5
515
+ background: color.card
516
+ border: 1 solid color.line
517
+ radius: sm
518
+ }
519
+
520
+ .pe-tag {
521
+ font: 13 color.muted
522
+ }
523
+
524
+ .pe-tag::before {
525
+ content: ""
526
+ display: inline-block
527
+ size: 10
528
+ background: color.accent
529
+ radius: 3
530
+ margin-inline-end: 8
531
+ }
532
+
533
+ .pe-dot {
534
+ size: 12
535
+ radius: full
536
+ background: color.accent
537
+ animate: pe-pulse 1200 infinite alternate
538
+ }
539
+
540
+ .pe-tail {
541
+ font: 12 color.muted
542
+ font-style: italic
543
+ }
scripts/test-v050.mjs ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* v0.5.0 regression tests — the review complaints, verbatim scenarios:
2
+ * 1. ".card::before { } compiles ok: true and then matches nothing" → now a real shadow rule
3
+ * 2. animate has no infinite → infinite + alternate accepted, WAAPI spec carries them
4
+ * 3. quoted-only font names in tokens → bare names pass in font-family + font.* tokens
5
+ * 4. hyphens: manual rejected → accepted
6
+ * + loud errors preserved: unknown pseudo, :seen in shadow, mixed lists, still-loud values
7
+ */
8
+ import { parse } from '../public/dssl/parser.js';
9
+ import { resolve } from '../public/dssl/resolver.js';
10
+
11
+ let pass = 0, fail = 0;
12
+ const check = (name, cond, extra = '') => {
13
+ if (cond) { pass++; console.log(` ok ${name}`); }
14
+ else { fail++; console.log(`FAIL ${name}${extra ? ' — ' + extra : ''}`); }
15
+ };
16
+
17
+ function compile(src) {
18
+ const { ast, errors: parseErrors } = parse(src, 'test.dssl');
19
+ const doc = resolve(ast, 'test.dssl', null);
20
+ return { doc, errors: [...parseErrors, ...doc.errors] };
21
+ }
22
+
23
+ console.log('1. the pseudo-element trap');
24
+ {
25
+ const { doc, errors } = compile(`
26
+ .card::before {
27
+ content: ""
28
+ background: color.accent
29
+ width: 12
30
+ }
31
+ `);
32
+ check('no errors', errors.length === 0, JSON.stringify(errors));
33
+ check('inline rules empty (nothing dead)', doc.rules.length === 0);
34
+ check('one shadow rule', doc.shadowRules.length === 1);
35
+ const r = doc.shadowRules[0];
36
+ check('sel split to .card', r.sel === '.card', r.sel);
37
+ check('pseudo = before', r.pseudo === 'before', r.pseudo);
38
+ check('content auto-quoted', r.decls.some(d => d.prop === 'content' && d.value === '""'));
39
+ check('token resolved', r.decls.some(d => d.prop === 'background' && d.value.includes('--dssl-color-accent')));
40
+ }
41
+
42
+ console.log('2. nested form still works (unchanged)');
43
+ {
44
+ const { doc, errors } = compile(`
45
+ .card {
46
+ ::after { content: "→" }
47
+ hover { color: color.accent }
48
+ }
49
+ `);
50
+ check('no errors', errors.length === 0, JSON.stringify(errors));
51
+ check('shadow rule via nested', doc.shadowRules.length === 1 && doc.shadowRules[0].pseudo === 'after');
52
+ check('state rule intact', doc.rules.length === 1 && doc.rules[0].sel === '.card:hover');
53
+ }
54
+
55
+ console.log('3. &::before inside a block (also was silently dead)');
56
+ {
57
+ const { doc, errors } = compile(`
58
+ .card {
59
+ &::before { content: "" }
60
+ }
61
+ `);
62
+ check('no errors', errors.length === 0, JSON.stringify(errors));
63
+ check('routed to shadow CSS', doc.shadowRules.length === 1 && doc.shadowRules[0].sel === '.card' && doc.shadowRules[0].pseudo === 'before');
64
+ }
65
+
66
+ console.log('4. top-level pseudo + vendor + states + media');
67
+ {
68
+ const { doc, errors } = compile(`
69
+ @media 768 {
70
+ .nav::-webkit-scrollbar-thumb:hover { background: color.ink/45 }
71
+ }
72
+ .card:hover::before { background: color.accent }
73
+ `);
74
+ check('no errors', errors.length === 0, JSON.stringify(errors));
75
+ check('two shadow rules', doc.shadowRules.length === 2, `got ${doc.shadowRules.length}`);
76
+ const vendor = doc.shadowRules.find(r => r.pseudo.startsWith('-webkit-'));
77
+ check('vendor pseudo keeps trailing state', vendor && vendor.pseudo === '-webkit-scrollbar-thumb:hover' && vendor.sel === '.nav', vendor && vendor.pseudo);
78
+ check('media attached to vendor rule', vendor && vendor.media.length === 1);
79
+ const hov = doc.shadowRules.find(r => r.pseudo === 'before');
80
+ check('state before :: survives split', hov && hov.sel === '.card:hover', hov && hov.sel);
81
+ }
82
+
83
+ console.log('5. loud errors stay loud');
84
+ {
85
+ const a = compile('.card::befor { content: "" }');
86
+ check('unknown pseudo is a LOUD error', a.errors.length === 1 && /unknown pseudo-element/.test(a.errors[0].message), JSON.stringify(a.errors));
87
+ check('and nothing dead is emitted', a.doc.rules.length === 0 && a.doc.shadowRules.length === 0);
88
+
89
+ const b = compile('.card:seen::before { content: "" }');
90
+ check(':seen + pseudo is a LOUD error', b.errors.length === 1 && /seen/.test(b.errors[0].message), JSON.stringify(b.errors));
91
+
92
+ const c = compile('.a, .b::before { content: "" }');
93
+ check('mixed selector list is a LOUD error', c.errors.length === 1 && /mixes/.test(c.errors[0].message), JSON.stringify(c.errors));
94
+
95
+ const d = compile('.card { color: oops }');
96
+ check('unknown value still loud', d.errors.length === 1 && /unknown value/.test(d.errors[0].message), JSON.stringify(d.errors));
97
+ }
98
+
99
+ console.log('6. infinite + alternate');
100
+ {
101
+ const { doc, errors } = compile(`
102
+ motion pulse {
103
+ from { opacity: 1 }
104
+ to { opacity: 0.4 }
105
+ }
106
+ .dot {
107
+ animate: pulse 1600 infinite alternate
108
+ }
109
+ `);
110
+ check('no errors', errors.length === 0, JSON.stringify(errors));
111
+ const anim = doc.rules[0].decls.find(d => d.prop === '@animate');
112
+ check('iterations = Infinity', anim && anim.value.iterations === Infinity, JSON.stringify(anim && anim.value));
113
+ check('direction = alternate', anim && anim.value.direction === 'alternate', JSON.stringify(anim && anim.value));
114
+ const one = compile('motion p { from { opacity: 1 } to { opacity: 0 } } .x { animate: p 400 }');
115
+ const a1 = one.doc.rules[0].decls.find(d => d.prop === '@animate');
116
+ check('default stays 1 iteration / normal', a1 && a1.value.iterations === undefined && a1.value.direction === undefined);
117
+ const bad = compile('motion p { from { opacity: 1 } } .x { animate: p 400 forevr }');
118
+ check('typo in animate part still loud', bad.errors.length === 1 && /unknown animate part/.test(bad.errors[0].message), JSON.stringify(bad.errors));
119
+ }
120
+
121
+ console.log('7. bare font names');
122
+ {
123
+ const { doc, errors } = compile(`
124
+ tokens {
125
+ font {
126
+ display: JetBrains Mono, monospace
127
+ quote: Iowan Old Style
128
+ }
129
+ }
130
+ .card { font-family: Public Sans, serif }
131
+ .quoted { font-family: "JetBrains Mono", monospace }
132
+ `);
133
+ check('no errors', errors.length === 0, JSON.stringify(errors));
134
+ const fam = doc.rules.find(r => r.sel === '.card').decls.find(d => d.prop === 'font-family');
135
+ check('bare font-family passes verbatim', fam && fam.value === 'Public Sans, serif', fam && fam.value);
136
+ const tok = doc.registryRule.decls.find(d => d.prop === '--dssl-font-display');
137
+ check('bare token font name passes', tok && tok.value === 'JetBrains Mono, monospace', tok && tok.value);
138
+ const q = doc.rules.find(r => r.sel === '.quoted').decls.find(d => d.prop === 'font-family');
139
+ check('quoted still fine', q && q.value === '"JetBrains Mono", monospace', q && q.value);
140
+ const bad = compile('.card { color: steel blue }');
141
+ check('bare words elsewhere still loud', bad.errors.length >= 1 && /unknown value/.test(bad.errors[0].message), JSON.stringify(bad.errors));
142
+ }
143
+
144
+ console.log('8. hyphens: manual');
145
+ {
146
+ const { doc, errors } = compile('.prose { hyphens: manual }');
147
+ check('no errors', errors.length === 0, JSON.stringify(errors));
148
+ check('emitted as-is', doc.rules[0].decls[0].prop === 'hyphens' && doc.rules[0].decls[0].value === 'manual');
149
+ }
150
+
151
+ console.log('9. site.dssl regression (the actual site styles, token registry chained like the runtime)');
152
+ {
153
+ const fs = await import('node:fs');
154
+ const path = await import('node:path');
155
+ const { DEFAULT_TOKENS } = await import('../public/dssl/resolver.js');
156
+ const here = path.dirname(new URL(import.meta.url).pathname);
157
+ const stylesDir = path.join(here, '..', 'public', 'styles');
158
+ const registry = new Map(Object.entries(DEFAULT_TOKENS));
159
+ for (const name of ['tokens', 'site', 'home', 'docs', 'playground', 'changelog']) {
160
+ const f = path.join(stylesDir, `${name}.dssl`);
161
+ if (!fs.existsSync(f)) { console.log(` skip ${name}`); continue; }
162
+ const src = fs.readFileSync(f, 'utf8');
163
+ const { doc, errors } = resolveChain(src, registry);
164
+ check(`${name}.dssl compiles clean`, errors.length === 0, JSON.stringify(errors.slice(0, 2)));
165
+ if (name === 'site.dssl') {
166
+ check('site.dssl scrollbar shadow rules present', doc.shadowRules.length >= 4, `got ${doc.shadowRules.length}`);
167
+ }
168
+ }
169
+ }
170
+
171
+ /** compile with a shared registry (like index.js does) and fold tokens back in */
172
+ function resolveChain(src, registry) {
173
+ const { ast, errors: parseErrors } = parse(src, 'chain.dssl');
174
+ const doc = resolve(ast, 'chain.dssl', Object.fromEntries(registry));
175
+ for (const [k, v] of Object.entries(doc.tokens)) registry.set(k, v);
176
+ return { doc, errors: [...parseErrors, ...doc.errors] };
177
+ }
178
+
179
+ console.log('10. compile() stats surface the shadow rules');
180
+ {
181
+ const { doc, errors } = compile('.card::before { content: "" } .card::after { content: "" } .card { padding: 12 }');
182
+ check('2 shadow + 1 inline', doc.shadowRules.length === 2 && doc.rules.length === 1);
183
+ }
184
+
185
+ console.log(`\n${pass} passed, ${fail} failed`);
186
+
187
+ // 11. bare content: gets auto-quoted in the full-selector path (parity with nested)
188
+ {
189
+ const { doc, errors } = compile('.card::before { content: }');
190
+ check('11 no errors', errors.length === 0, JSON.stringify(errors));
191
+ const c = doc.shadowRules[0]?.decls.find(d => d.prop === 'content');
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);
src/components/dssl/SiteRoot.tsx CHANGED
@@ -120,7 +120,7 @@ export default function SiteRoot() {
120
  </nav>
121
 
122
  <div className="nav-side">
123
- <a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.4.2</a>
124
  <button type="button" className="theme-btn" onClick={cycleTheme} aria-label="Toggle color theme" title="Theme: auto / light / dark">
125
  <span className="icon-sun"><SunIcon /></span>
126
  <span className="icon-moon"><MoonIcon /></span>
@@ -148,7 +148,7 @@ export default function SiteRoot() {
148
  <div className="wrap footer-inner">
149
  <div className="footer-col">
150
  <div className="footer-note">
151
- <strong>DSSL</strong> — domain specific style language. Built v0.1 → v0.4.2 while
152
  building this site; every feature it needed, it grew, and every bug you sent got
153
  fixed for real. This page runs on its own engine, no CSS files attached —
154
  the engine styles this DOM directly.
 
120
  </nav>
121
 
122
  <div className="nav-side">
123
+ <a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.5.0</a>
124
  <button type="button" className="theme-btn" onClick={cycleTheme} aria-label="Toggle color theme" title="Theme: auto / light / dark">
125
  <span className="icon-sun"><SunIcon /></span>
126
  <span className="icon-moon"><MoonIcon /></span>
 
148
  <div className="wrap footer-inner">
149
  <div className="footer-col">
150
  <div className="footer-note">
151
+ <strong>DSSL</strong> — domain specific style language. Built v0.1 → v0.5.0 while
152
  building this site; every feature it needed, it grew, and every bug you sent got
153
  fixed for real. This page runs on its own engine, no CSS files attached —
154
  the engine styles this DOM directly.
src/components/dssl/views/ChangelogView.tsx CHANGED
@@ -11,6 +11,24 @@ const ENTRIES: {
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  {
15
  version: "v0.4.2",
16
  date: "growth phase · distribution",
 
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
14
+ {
15
+ version: "v0.5.0",
16
+ date: "growth phase · the trap closes",
17
+ title: "Pseudo-elements work everywhere — and animate learns to loop",
18
+ why: "an external review scored DSSL 7.5/10 and named the bump condition: fix the trap, earn the 8",
19
+ kind: "add",
20
+ body:
21
+ "The review found the one wart this language could not afford: a top-level .card::before { } compiled ok: true and then matched nothing. Inline styles can never reach a pseudo-element, and the compiler said fine when it meant dead rule — twenty minutes lost, in a language whose whole pitch is loud errors. The fix lives in the flattener: any full selector carrying :: is split into element + pseudo halves and routed down the same shadow-CSS pipeline the nested form always used, so .card::before, .card { ::before } and .card { &::before } are one spelling of one rule, with states, @media, @container and @signal composing as everywhere else. The loud errors stayed loud: an unknown pseudo-element name, :seen combined with a pseudo, or a selector list mixing pseudo and plain parts each fail at compile time with a did-you-mean hint — a rule can no longer be dead on arrival. The review's other gripes rode along: animate accepts infinite (loop forever) and alternate (ping-pong direction) through the Web Animations API; font names are now treated as proper nouns, so font-family: JetBrains Mono, monospace and font tokens pass bare without quotes; and hyphens: manual stopped being rejected. The docs gained the pseudo-elements section the trap proved was missing, with a live demo styled entirely by full-selector pseudo rules — if the accent square renders, the fix is on.",
22
+ code: `// three spellings, one rule:
23
+ .card::before { content: "" }
24
+ .card { ::before { content: "" } }
25
+ .card { &::before { content: "" } }
26
+
27
+ // the sharp-edge sweep:
28
+ .dot { animate: pe-pulse 1200 infinite alternate }
29
+ .card { font-family: JetBrains Mono, monospace }
30
+ .prose { hyphens: manual }`,
31
+ },
32
  {
33
  version: "v0.4.2",
34
  date: "growth phase · distribution",
src/components/dssl/views/DocsView.tsx CHANGED
@@ -10,6 +10,7 @@ const SIDE = [
10
  { head: null, id: "tokens", label: "Tokens" },
11
  { head: null, id: "selectors", label: "Selectors & nesting" },
12
  { head: null, id: "states", label: "States" },
 
13
  { head: null, id: "world", label: "@media · @container · @signal" },
14
  { head: null, id: "cascade", label: "Cascade & layers" },
15
  { head: "reference", id: "semantic", label: "Semantic properties" },
@@ -264,6 +265,52 @@ background: // values can continue
264
  </div>
265
  </section>
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  <section className="doc-block" id="world">
268
  <h2 className="section-title">@media · @container · @signal</h2>
269
  <p className="doc-lead">
@@ -413,7 +460,9 @@ button { use focus-ring }
413
  element&apos;s natural state. Apply it with <code>animate</code>. The engine
414
  runs it through the Web Animations API, and every animation silently
415
  disables under reduced-motion unless the motion is{" "}
416
- <code>essential: true</code>.
 
 
417
  </p>
418
  <CodeBlock file="motion.dssl" code={`motion pop {
419
  from {
@@ -425,6 +474,10 @@ button { use focus-ring }
425
 
426
  .motion-box {
427
  animate: pop 500 snap // name · duration · easing · fill
 
 
 
 
428
  }`} />
429
  <div className="demo-box">
430
  <div className="motion-box">motion pop — replay me</div>
 
10
  { head: null, id: "tokens", label: "Tokens" },
11
  { head: null, id: "selectors", label: "Selectors & nesting" },
12
  { head: null, id: "states", label: "States" },
13
+ { head: null, id: "pseudo-elements", label: "Pseudo-elements" },
14
  { head: null, id: "world", label: "@media · @container · @signal" },
15
  { head: null, id: "cascade", label: "Cascade & layers" },
16
  { head: "reference", id: "semantic", label: "Semantic properties" },
 
265
  </div>
266
  </section>
267
 
268
+ <section className="doc-block" id="pseudo-elements">
269
+ <h2 className="section-title">Pseudo-elements</h2>
270
+ <p className="doc-lead">
271
+ Inline styles reach every element in the DOM — but a pseudo-element has no
272
+ DOM handle, so <code>::before</code>, <code>::after</code>,{" "}
273
+ <code>::selection</code> and friends are the one thing DSSL compiles into a
274
+ real stylesheet. Both spellings are the same rule: nested inside the block,
275
+ or written as a full selector. Before v0.5 the full-selector form compiled{" "}
276
+ <code>ok: true</code> and then matched nothing — a silent dead rule in a
277
+ language whose whole pitch is loud errors. The trap is closed: the resolver
278
+ splits any selector carrying <code>::</code> and routes it down the same
279
+ shadow-CSS pipeline as the nested form.
280
+ </p>
281
+ <CodeBlock file="pseudo.dssl" code={`// all three spellings are the same rule now
282
+ .card::before { content: "" } // full selector
283
+ .card {
284
+ ::before { content: "" } // nested
285
+ &::before { content: "" } // explicit &
286
+ }
287
+
288
+ // states, @media, @container and @signal compose as everywhere else
289
+ .card:hover::before { background: color.accent }
290
+
291
+ // vendor pseudo-elements pass through verbatim, trailing states included
292
+ input::-webkit-slider-thumb { background: color.accent }
293
+
294
+ // and the loud errors stayed loud:
295
+ // .card::befor { } → error: unknown pseudo-element "::befor"
296
+ // .card:seen::before { } → error: seen can't cross into shadow CSS
297
+ // .a, .b::before { } → error: mixed selector list — split it`} />
298
+ <div className="demo-box">
299
+ <div className="pe-demo">
300
+ <span className="pe-tag">styled by .pe-tag::before — the full-selector form</span>
301
+ <span className="pe-dot" />
302
+ <span className="pe-tail">pe-pulse · 1200ms · infinite alternate</span>
303
+ </div>
304
+ <p className="doc-lead fine-print">
305
+ The oxblood square is a <code>.pe-tag::before</code> rule written in the
306
+ full-selector form in this page&apos;s stylesheet — the spelling that used
307
+ to die silently. The dot runs{" "}
308
+ <code>animate: pe-pulse 1200 infinite alternate</code>, v0.5&apos;s first
309
+ loop. If both look right, the trap is staying closed.
310
+ </p>
311
+ </div>
312
+ </section>
313
+
314
  <section className="doc-block" id="world">
315
  <h2 className="section-title">@media · @container · @signal</h2>
316
  <p className="doc-lead">
 
460
  element&apos;s natural state. Apply it with <code>animate</code>. The engine
461
  runs it through the Web Animations API, and every animation silently
462
  disables under reduced-motion unless the motion is{" "}
463
+ <code>essential: true</code>. v0.5 adds <code>infinite</code> — loop
464
+ forever — and <code>alternate</code>, which ping-pongs the direction
465
+ instead of restarting.
466
  </p>
467
  <CodeBlock file="motion.dssl" code={`motion pop {
468
  from {
 
474
 
475
  .motion-box {
476
  animate: pop 500 snap // name · duration · easing · fill
477
+ }
478
+
479
+ .pe-dot {
480
+ animate: pe-pulse 1200 infinite alternate // v0.5: loops, ping-pongs
481
  }`} />
482
  <div className="demo-box">
483
  <div className="motion-box">motion pop — replay me</div>
src/components/dssl/views/HomeView.tsx CHANGED
@@ -106,6 +106,7 @@ export default function HomeView({ go }: { go: (v: ViewName) => void }) {
106
  <button type="button" className="btn btn-ghost" onClick={() => go("playground")}>
107
  <PlayIcon /> Try the playground
108
  </button>
 
109
  </div>
110
  <div className="hero-meta" aria-label="Engine facts">
111
  <span>~20 kB engine</span>
@@ -328,6 +329,79 @@ DSSL.signal('demo', null) // off`} />
328
  </div>
329
  </section>
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  {/* ------------------------------------------------ cta */}
332
  <section className="section">
333
  <div className="wrap">
 
106
  <button type="button" className="btn btn-ghost" onClick={() => go("playground")}>
107
  <PlayIcon /> Try the playground
108
  </button>
109
+ <a href="#get" className="btn btn-ghost">Get the engine</a>
110
  </div>
111
  <div className="hero-meta" aria-label="Engine facts">
112
  <span>~20 kB engine</span>
 
329
  </div>
330
  </section>
331
 
332
+ {/* ------------------------------------------------ get the engine */}
333
+ <section className="section" id="get">
334
+ <div className="wrap">
335
+ <div className="kicker">get the engine</div>
336
+ <h2 className="section-title">One line, any platform</h2>
337
+ <p className="section-sub">
338
+ The whole engine is a folder of plain JavaScript files. No package
339
+ manager, no build step, nothing installed globally. Pick your
340
+ platform, paste the line, and a <code>dssl-engine</code> folder
341
+ appears with the engine, a starter token set, an example stylesheet
342
+ and the usage guide.
343
+ </p>
344
+ <div className="get-grid sect-gap">
345
+ <div className="card card-hover get-card">
346
+ <span className="get-os">macos · linux · bsd</span>
347
+ <CodeBlock plain file="terminal" code="curl -fsSL https://dssl-sable.vercel.app/install.sh | sh" />
348
+ <p className="get-note">
349
+ Runs on plain sh — dash, ash and busybox included, bash not
350
+ required. Downloads with curl or wget, whichever you have, and
351
+ unpacks with tar, which every distro and macOS ship by default.
352
+ No tar? It falls back to unzip, then python3.
353
+ </p>
354
+ </div>
355
+ <div className="card card-hover get-card">
356
+ <span className="get-os">windows</span>
357
+ <CodeBlock plain file="powershell" code="irm https://dssl-sable.vercel.app/install.ps1 | iex" />
358
+ <p className="get-note">
359
+ PowerShell 5.1 or newer, no admin rights needed. Unpacks with
360
+ Expand-Archive into your current folder.
361
+ </p>
362
+ </div>
363
+ <div className="card card-hover get-card">
364
+ <span className="get-os">any os, no terminal</span>
365
+ <div className="get-downloads">
366
+ <a className="btn btn-ghost btn-sm" href="/dssl-engine.zip" download>dssl-engine.zip</a>
367
+ <a className="btn btn-ghost btn-sm" href="/dssl-engine.tar.gz" download>dssl-engine.tar.gz</a>
368
+ </div>
369
+ <p className="get-note">
370
+ The exact package the one-liners fetch, straight from the
371
+ browser. Unzip anywhere and follow USAGE.md inside.
372
+ </p>
373
+ </div>
374
+ </div>
375
+
376
+ <div className="get-wire">
377
+ <div className="get-wire-code">
378
+ <CodeBlock
379
+ file="your page"
380
+ code={`<script type="module" src="/dssl/boot.js"></script>
381
+ <link rel="stylesheet/dssl" href="/styles/tokens.dssl">
382
+ <link rel="stylesheet/dssl" href="/styles/hello.dssl">`}
383
+ />
384
+ </div>
385
+ <div className="get-wire-copy">
386
+ <h3>That is the whole install</h3>
387
+ <p>
388
+ Drop the <code>dssl/</code> folder where your page can serve
389
+ it, load <code>boot.js</code> as a module, and list your{" "}
390
+ <code>.dssl</code> files as links. The engine fetches them,
391
+ walks your DOM, and keeps restyling live as elements come and
392
+ go. Inline rules work too, via{" "}
393
+ <code>&lt;style type="text/dssl"&gt;</code>. The full tour
394
+ lives in the docs, and USAGE.md in the package covers the same
395
+ ground in two minutes.
396
+ </p>
397
+ <button type="button" className="btn btn-ghost btn-sm" onClick={() => go("docs")}>
398
+ Read the docs <ArrowIcon />
399
+ </button>
400
+ </div>
401
+ </div>
402
+ </div>
403
+ </section>
404
+
405
  {/* ------------------------------------------------ cta */}
406
  <section className="section">
407
  <div className="wrap">