File size: 13,427 Bytes
794b22f
7bd797e
 
 
 
 
 
 
 
 
 
 
 
794b22f
7bd797e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ade7b1
 
794b22f
0ade7b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794b22f
0ade7b1
 
 
 
 
794b22f
0ade7b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e6c76da
 
 
 
0ade7b1
 
 
 
 
 
 
 
7bd797e
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
/* v0.5.0 regression tests - the review complaints, verbatim scenarios:
 *  1. ".card::before { } compiles ok: true and then matches nothing" β†’ now a real shadow rule
 *  2. animate has no infinite β†’ infinite + alternate accepted, WAAPI spec carries them
 *  3. quoted-only font names in tokens β†’ bare names pass in font-family + font.* tokens
 *  4. hyphens: manual rejected β†’ accepted
 *  + loud errors preserved: unknown pseudo, :seen in shadow, mixed lists, still-loud values
 */
import { parse } from '../public/dssl/parser.js';
import { resolve } from '../public/dssl/resolver.js';

let pass = 0, fail = 0;
const check = (name, cond, extra = '') => {
  if (cond) { pass++; console.log(`  ok  ${name}`); }
  else { fail++; console.log(`FAIL  ${name}${extra ? '  - ' + extra : ''}`); }
};

function compile(src) {
  const { ast, errors: parseErrors } = parse(src, 'test.dssl');
  const doc = resolve(ast, 'test.dssl', null);
  return { doc, errors: [...parseErrors, ...doc.errors] };
}

console.log('1. the pseudo-element trap');
{
  const { doc, errors } = compile(`
    .card::before {
      content: ""
      background: color.accent
      width: 12
    }
  `);
  check('no errors', errors.length === 0, JSON.stringify(errors));
  check('inline rules empty (nothing dead)', doc.rules.length === 0);
  check('one shadow rule', doc.shadowRules.length === 1);
  const r = doc.shadowRules[0];
  check('sel split to .card', r.sel === '.card', r.sel);
  check('pseudo = before', r.pseudo === 'before', r.pseudo);
  check('content auto-quoted', r.decls.some(d => d.prop === 'content' && d.value === '""'));
  check('token resolved', r.decls.some(d => d.prop === 'background' && d.value.includes('--dssl-color-accent')));
}

console.log('2. nested form still works (unchanged)');
{
  const { doc, errors } = compile(`
    .card {
      ::after { content: "β†’" }
      hover { color: color.accent }
    }
  `);
  check('no errors', errors.length === 0, JSON.stringify(errors));
  check('shadow rule via nested', doc.shadowRules.length === 1 && doc.shadowRules[0].pseudo === 'after');
  check('state rule intact', doc.rules.length === 1 && doc.rules[0].sel === '.card:hover');
}

console.log('3. &::before inside a block (also was silently dead)');
{
  const { doc, errors } = compile(`
    .card {
      &::before { content: "" }
    }
  `);
  check('no errors', errors.length === 0, JSON.stringify(errors));
  check('routed to shadow CSS', doc.shadowRules.length === 1 && doc.shadowRules[0].sel === '.card' && doc.shadowRules[0].pseudo === 'before');
}

console.log('4. top-level pseudo + vendor + states + media');
{
  const { doc, errors } = compile(`
    @media 768 {
      .nav::-webkit-scrollbar-thumb:hover { background: color.ink/45 }
    }
    .card:hover::before { background: color.accent }
  `);
  check('no errors', errors.length === 0, JSON.stringify(errors));
  check('two shadow rules', doc.shadowRules.length === 2, `got ${doc.shadowRules.length}`);
  const vendor = doc.shadowRules.find(r => r.pseudo.startsWith('-webkit-'));
  check('vendor pseudo keeps trailing state', vendor && vendor.pseudo === '-webkit-scrollbar-thumb:hover' && vendor.sel === '.nav', vendor && vendor.pseudo);
  check('media attached to vendor rule', vendor && vendor.media.length === 1);
  const hov = doc.shadowRules.find(r => r.pseudo === 'before');
  check('state before :: survives split', hov && hov.sel === '.card:hover', hov && hov.sel);
}

console.log('5. loud errors stay loud');
{
  const a = compile('.card::befor { content: "" }');
  check('unknown pseudo is a LOUD error', a.errors.length === 1 && /unknown pseudo-element/.test(a.errors[0].message), JSON.stringify(a.errors));
  check('and nothing dead is emitted', a.doc.rules.length === 0 && a.doc.shadowRules.length === 0);

  const b = compile('.card:seen::before { content: "" }');
  check(':seen + pseudo is a LOUD error', b.errors.length === 1 && /seen/.test(b.errors[0].message), JSON.stringify(b.errors));

  const c = compile('.a, .b::before { content: "" }');
  check('mixed selector list is a LOUD error', c.errors.length === 1 && /mixes/.test(c.errors[0].message), JSON.stringify(c.errors));

  const d = compile('.card { color: oops }');
  check('unknown value still loud', d.errors.length === 1 && /unknown value/.test(d.errors[0].message), JSON.stringify(d.errors));
}

console.log('6. infinite + alternate');
{
  const { doc, errors } = compile(`
    motion pulse {
      from { opacity: 1 }
      to { opacity: 0.4 }
    }
    .dot {
      animate: pulse 1600 infinite alternate
    }
  `);
  check('no errors', errors.length === 0, JSON.stringify(errors));
  const anim = doc.rules[0].decls.find(d => d.prop === '@animate');
  check('iterations = Infinity', anim && anim.value.iterations === Infinity, JSON.stringify(anim && anim.value));
  check('direction = alternate', anim && anim.value.direction === 'alternate', JSON.stringify(anim && anim.value));
  const one = compile('motion p { from { opacity: 1 } to { opacity: 0 } } .x { animate: p 400 }');
  const a1 = one.doc.rules[0].decls.find(d => d.prop === '@animate');
  check('default stays 1 iteration / normal', a1 && a1.value.iterations === undefined && a1.value.direction === undefined);
  const bad = compile('motion p { from { opacity: 1 } } .x { animate: p 400 forevr }');
  check('typo in animate part still loud', bad.errors.length === 1 && /unknown animate part/.test(bad.errors[0].message), JSON.stringify(bad.errors));
}

console.log('7. bare font names');
{
  const { doc, errors } = compile(`
    tokens {
      font {
        display: JetBrains Mono, monospace
        quote: Iowan Old Style
      }
    }
    .card { font-family: Public Sans, serif }
    .quoted { font-family: "JetBrains Mono", monospace }
  `);
  check('no errors', errors.length === 0, JSON.stringify(errors));
  const fam = doc.rules.find(r => r.sel === '.card').decls.find(d => d.prop === 'font-family');
  check('bare font-family passes verbatim', fam && fam.value === 'Public Sans, serif', fam && fam.value);
  const tok = doc.registryRule.decls.find(d => d.prop === '--dssl-font-display');
  check('bare token font name passes', tok && tok.value === 'JetBrains Mono, monospace', tok && tok.value);
  const q = doc.rules.find(r => r.sel === '.quoted').decls.find(d => d.prop === 'font-family');
  check('quoted still fine', q && q.value === '"JetBrains Mono", monospace', q && q.value);
  const bad = compile('.card { color: steel blue }');
  check('bare words elsewhere still loud', bad.errors.length >= 1 && /unknown value/.test(bad.errors[0].message), JSON.stringify(bad.errors));
}

console.log('8. hyphens: manual');
{
  const { doc, errors } = compile('.prose { hyphens: manual }');
  check('no errors', errors.length === 0, JSON.stringify(errors));
  check('emitted as-is', doc.rules[0].decls[0].prop === 'hyphens' && doc.rules[0].decls[0].value === 'manual');
}

console.log('9. site.dssl regression (the actual site styles, token registry chained like the runtime)');
{
  const fs = await import('node:fs');
  const path = await import('node:path');
  const { DEFAULT_TOKENS } = await import('../public/dssl/resolver.js');
  const here = path.dirname(new URL(import.meta.url).pathname);
  const stylesDir = path.join(here, '..', 'public', 'styles');
  const registry = new Map(Object.entries(DEFAULT_TOKENS));
  for (const name of ['tokens', 'site', 'home', 'docs', 'playground', 'changelog']) {
    const f = path.join(stylesDir, `${name}.dssl`);
    if (!fs.existsSync(f)) { console.log(`  skip ${name}`); continue; }
    const src = fs.readFileSync(f, 'utf8');
    const { doc, errors } = resolveChain(src, registry);
    check(`${name}.dssl compiles clean`, errors.length === 0, JSON.stringify(errors.slice(0, 2)));
    if (name === 'site.dssl') {
      check('site.dssl scrollbar shadow rules present', doc.shadowRules.length >= 4, `got ${doc.shadowRules.length}`);
    }
  }
}

/** compile with a shared registry (like index.js does) and fold tokens back in */
function resolveChain(src, registry) {
  const { ast, errors: parseErrors } = parse(src, 'chain.dssl');
  const doc = resolve(ast, 'chain.dssl', Object.fromEntries(registry));
  for (const [k, v] of Object.entries(doc.tokens)) registry.set(k, v);
  return { doc, errors: [...parseErrors, ...doc.errors] };
}

console.log('10. compile() stats surface the shadow rules');
{
  const { doc, errors } = compile('.card::before { content: "" } .card::after { content: "" } .card { padding: 12 }');
  check('2 shadow + 1 inline', doc.shadowRules.length === 2 && doc.rules.length === 1);
}

console.log(`\n${pass} passed, ${fail} failed`);

// 11. bare content: gets auto-quoted in the full-selector path (parity with nested)
{
  const { doc, errors } = compile('.card::before { content: }');
  check('11 no errors', errors.length === 0, JSON.stringify(errors));
  const c = doc.shadowRules[0]?.decls.find(d => d.prop === 'content');
  check('11 bare content auto-quoted', c && c.value === '""', c && c.value);
}

console.log(`${pass} passed, ${fail} failed`);

// 12. overlap policy - the "nothing below 100% opacity overlaps" feature
{
  const { doc, errors } = compile(`
    body { overlap: auto }
    .badge { overlap: deny }
    .scrim { overlap: allow }
    .off { overlap: none }
  `);
  check('12 no errors', errors.length === 0, JSON.stringify(errors));
  const get = (sel) => doc.rules.find(r => r.sel === sel)?.decls.find(d => d.prop === '@overlap')?.value;
  check('12 body policy = auto', get('body') === 'auto', get('body'));
  check('12 deny passes through', get('.badge') === 'deny', get('.badge'));
  check('12 allow passes through', get('.scrim') === 'allow', get('.scrim'));
  check('12 none is an alias of allow', get('.off') === 'allow', get('.off'));

  const bad = compile('.card { overlap: never }');
  check('12 bad policy is a LOUD error', bad.errors.length === 1 && /unknown overlap policy/.test(bad.errors[0].message), JSON.stringify(bad.errors));
  check('12 hint names the vocabulary', /allow|deny|auto/.test(bad.errors[0].hint || ''));

  const dead = compile('.card { overlap: nevr }');
  check('12 nothing emitted on error', dead.doc.rules.length === 0);

  // site.dssl now carries the global switch - regression-guard it
  const fs = await import('node:fs');
  const site = fs.readFileSync(new URL('../public/styles/site.dssl', import.meta.url).pathname, 'utf8');
  check('12 site.dssl body opts in', /overlap:\s*auto/.test(site));
}

// 13. the escape math (overlapNudge) - pure function, no DOM needed
{
  const { overlapNudge } = await import('../public/dssl/engine.js');
  const box = (l, t, r, b) => ({ left: l, top: t, right: r, bottom: b });

  // candidate [0,0,100,100] vs obstacle [50,0,150,100]: push left past the
  // near edge costs 50, push right past the far edge costs 150 β†’ left wins
  let n = overlapNudge(box(0, 0, 100, 100), [box(50, 0, 150, 100)]);
  check('13 shortest escape wins (left 50)', n.x === -50 && n.y === 0, JSON.stringify(n));

  // deep obstacle above, 10px of y-penetration β†’ dropping down 10 beats climbing 200
  n = overlapNudge(box(0, 90, 200, 200), [box(0, 0, 200, 100)]);
  check('13 vertical kiss resolves down 10', n.x === 0 && n.y === 10, JSON.stringify(n));

  // no overlap β†’ no nudge
  n = overlapNudge(box(0, 0, 10, 10), [box(20, 20, 30, 30)]);
  check('13 clear boxes stay put', n.x === 0 && n.y === 0, JSON.stringify(n));

  // touching edges are NOT overlap (the shared-border tremble guard)
  n = overlapNudge(box(0, 0, 100, 100), [box(100, 0, 200, 100)]);
  check('13 touching edges do not count', n.x === 0 && n.y === 0, JSON.stringify(n));

  // two walls with a real gap between them β†’ nudge must land inside the gap
  n = overlapNudge(box(70, 0, 90, 100), [box(0, 0, 80, 100), box(120, 0, 200, 100)]);
  const cleared = (b, obs) => obs.every(o =>
    Math.min(b.right, o.right) - Math.max(b.left, o.left) <= 0.01 ||
    Math.min(b.bottom, o.bottom) - Math.max(b.top, o.top) <= 0.01);
  const moved = box(70 + n.x, 0 + n.y, 90 + n.x, 100 + n.y);
  check('13 box clears both walls into the gap', n.x === 10 && cleared(moved, [box(0, 0, 80, 100), box(120, 0, 200, 100)]), JSON.stringify({ n, moved }));
}

console.log(`${pass} passed, ${fail} failed`);

// 14. engine-native props inside pseudo blocks fail loud (no junk shadow CSS)
{
  // 0.14.0: animate: on a pseudo-element is allowed now (it becomes a CSS animation); what is still engine-only fails loud
  const a = compile('motion pop { from { opacity: 0 } to { opacity: 1 } }\n.card::before { content: "x"; animate: pop 400 }');
  check('14 animate on a pseudo-element is allowed since 0.14 and becomes a CSS animation', a.errors.length === 0 && a.doc.shadowRules.length === 1 && a.doc.shadowRules[0].decls.some((d) => d.prop === '@animate'), JSON.stringify(a.errors));
  check('14 an unknown motion there is still LOUD', compile('.card::before { content: "x"; animate: pop 400 }').errors.length === 1);

  const b = compile('.card::before { overlap: deny }');
  check('14 top-level overlap-in-pseudo is LOUD', b.errors.length === 1 && /engine-native/.test(b.errors[0].message), JSON.stringify(b.errors));

  const c = compile('.card { ::after { --x: 1 } }');
  check('14 custom props still pass in pseudo blocks', c.errors.length === 0 && c.doc.shadowRules.length === 1, JSON.stringify(c.errors));
}

console.log(`${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);