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 +6 -4
- public/dssl/engine.js +2 -0
- public/dssl/index.js +1 -1
- public/dssl/parser.js +6 -2
- public/dssl/resolver.js +104 -4
- public/styles/docs.dssl +54 -1
- scripts/test-v050.mjs +196 -0
- src/components/dssl/SiteRoot.tsx +2 -2
- src/components/dssl/views/ChangelogView.tsx +18 -0
- src/components/dssl/views/DocsView.tsx +54 -1
- src/components/dssl/views/HomeView.tsx +74 -0
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.
|
| 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 |
-
-
|
| 60 |
-
-
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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'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's stylesheet — the spelling that used
|
| 307 |
+
to die silently. The dot runs{" "}
|
| 308 |
+
<code>animate: pe-pulse 1200 infinite alternate</code>, v0.5'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'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><style type="text/dssl"></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">
|