Z User commited on
Commit
270d900
Β·
1 Parent(s): 40cef65

v0.4.1 The steady hand: idempotent engine, CSSOM shorthand/longhand merge fix, promotion race fix

Browse files
public/dssl/engine.js CHANGED
@@ -37,6 +37,60 @@ const ALIGN_PROPS = new Set([
37
  ]);
38
  const HAS_CONTEXT_RE = /^(flex|inline-flex|grid|inline-grid)$/;
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  // state pseudo-classes that may appear inside a rule selector. Used to strip
41
  // them back OFF when collecting recompute candidates (see stateEvent).
42
  const STATE_PSEUDO_RE = /:(hover|focus|focus-visible|active|focus-within|visited|target|disabled|enabled|checked|placeholder-shown|read-only|read-write|open|closed|empty|required|invalid|valid|link|indeterminate|seen)\b/g;
@@ -74,6 +128,7 @@ export class DsslEngine {
74
  this.seenSet = new WeakSet(); // v0.4.0 β€” elements that entered the viewport
75
  this.staggerEls = new Map(); // v0.4.0 β€” element β†’ stagger delay (ms)
76
  this.io = null; // IntersectionObserver backing `seen`
 
77
  }
78
 
79
  /* ------------------------------------------------------------ *
@@ -202,7 +257,7 @@ export class DsslEngine {
202
  const m = origMql(query);
203
  if (!m._dsslWired) {
204
  m._dsslWired = true;
205
- try { m.addEventListener('change', () => this.computeAll()); } catch { /* old browsers */ }
206
  }
207
  return m;
208
  };
@@ -210,7 +265,7 @@ export class DsslEngine {
210
  // --- reduced motion ---
211
  try {
212
  const rm = window.matchMedia('(prefers-reduced-motion: reduce)');
213
- rm.addEventListener('change', () => this.computeAll());
214
  this.reducedMotion = rm;
215
  } catch { this.reducedMotion = { matches: false }; }
216
 
@@ -231,7 +286,13 @@ export class DsslEngine {
231
  // element as seen β€” content must never be trapped invisible by a missing API.
232
 
233
  // --- container tracking via ResizeObserver ---
234
- this.ro = new ResizeObserver(() => this.computeAll());
 
 
 
 
 
 
235
  }
236
 
237
  stateEvent(state, target) {
@@ -282,6 +343,16 @@ export class DsslEngine {
282
  });
283
  }
284
 
 
 
 
 
 
 
 
 
 
 
285
  matches(el, sel) {
286
  try { return el.matches(sel); } catch { return false; }
287
  }
@@ -370,54 +441,132 @@ export class DsslEngine {
370
  (LAYER_RANK[a.layer] ?? 2) - (LAYER_RANK[b.layer] ?? 2) || a.order - b.order
371
  );
372
 
373
- // wipe what we owned before
374
- const owned = this.ownMap.get(el) || [];
375
- for (const p of owned) { try { el.style.removeProperty(p); } catch { } }
376
-
377
- const newOwned = [];
 
 
 
 
 
 
 
 
 
378
  const animSpecs = [];
379
  let cancelAnims = false;
380
  let stack = false;
381
  let staggerMs; // v0.4.0 β€” @stagger value, if any
382
 
383
- // flatten matched decls in cascade order (the promotion below may prepend)
 
384
  const flat = [];
385
  for (const rule of matched) {
386
  const imp = rule.layer === 'pin' ? 'important' : '';
387
  for (const d of rule.decls) flat.push({ d, imp, rule });
388
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  // v0.3.4: alignment implies a formatting context β€” see ALIGN_PROPS above.
391
- // Read AFTER the wipe so the natural (parent-blockified) display decides;
392
- // none/contents are never promoted (promoting would unhide or boxify them).
393
- if (flat.some(x => ALIGN_PROPS.has(x.d.prop))) {
 
 
 
394
  let disp = '';
395
- try { disp = getComputedStyle(el).display; } catch { }
 
 
 
 
 
 
396
  if (disp && !HAS_CONTEXT_RE.test(disp) && disp !== 'none' && disp !== 'contents') {
397
  const inlineCtx = disp === 'inline' || disp.startsWith('inline-');
398
- flat.unshift({ imp: '', d: { prop: 'display', value: inlineCtx ? 'inline-grid' : 'grid' } });
399
  }
400
  }
401
 
402
- for (const { d, imp, rule } of flat) {
403
- if (d.prop === '@animate') {
404
- if (d.value === null) { cancelAnims = true; continue; }
405
- animSpecs.push({ ...d.value, key: rule.id + '|' + rule.media.join('&') + '|' + rule.signals.map(s => s.name + s.op + s.value).join('&') });
406
- continue;
407
- }
408
- if (d.prop === '@stack') { stack = true; continue; }
409
- if (d.prop === '@stagger') { staggerMs = d.value; continue; }
410
- try {
411
- el.style.setProperty(d.prop, d.value, imp);
412
- newOwned.push(d.prop);
413
- } catch (why) {
414
- // v0.3.2: the language promises loud errors β€” if the browser rejects
415
- // a value we resolved, say so instead of failing silently. This is
416
- // how the repeat(4px, 1fr) grid collapse would have been caught.
417
- const msg = `[DSSL] browser rejected "${d.prop}: ${d.value}" β€” ${why && why.message || 'invalid value'}`;
418
- if (!this._warned) this._warned = new Set();
419
- if (!this._warned.has(msg)) { this._warned.add(msg); console.warn(msg); }
420
- }
421
  }
422
 
423
  this.ownMap.set(el, newOwned);
@@ -433,7 +582,7 @@ export class DsslEngine {
433
  }
434
 
435
  // container registration
436
- if (newOwned.includes('container-type')) {
437
  if (!this.containerEls.has(el)) { this.containerEls.add(el); try { this.ro.observe(el); } catch { } }
438
  } else if (this.containerEls.has(el)) {
439
  this.containerEls.delete(el);
@@ -444,7 +593,11 @@ export class DsslEngine {
444
  const prevStack = this.stackPrev.get(el) || [];
445
  if (stack) {
446
  for (const child of el.children) {
447
- if (child.nodeType === 1) { try { child.style.gridArea = '1 / 1'; } catch { } }
 
 
 
 
448
  }
449
  this.stackPrev.set(el, [...el.children]);
450
  } else if (prevStack.length) {
@@ -508,7 +661,11 @@ export class DsslEngine {
508
  const anims = this.animMap.get(kid);
509
  if (!anims || !anims.size) continue;
510
  anims.forEach(a => {
511
- try { if (a.effect && a.effect.updateTiming) a.effect.updateTiming({ delay: i * ms }); } catch { }
 
 
 
 
512
  });
513
  }
514
  }
@@ -539,7 +696,7 @@ export class DsslEngine {
539
  if (value === null || value === undefined) document.documentElement.removeAttribute('data-dssl-signal-' + name);
540
  else document.documentElement.setAttribute('data-dssl-signal-' + name, String(value));
541
  } catch { }
542
- this.computeAll();
543
  }
544
 
545
  getSignal(name) { return this.signalStore.get(name); }
 
37
  ]);
38
  const HAS_CONTEXT_RE = /^(flex|inline-flex|grid|inline-grid)$/;
39
 
40
+ /* v0.4.1 β€” shorthand/longhand coexistence cannot survive inline.
41
+ *
42
+ * A var()-pending shorthand (`border: 1px solid var(--c)`) and an overriding
43
+ * longhand of the same family (`border-color: color-mix(... var(--i) ...)`)
44
+ * cannot coexist in one inline declaration block: the CSSOM silently expands
45
+ * the shorthand into EMPTY longhands and the border vanishes. The old
46
+ * wipe-and-rewrite engine masked this by restoring the shorthand on every
47
+ * recompute (and flickering the border on every hover β€” part of the twitch);
48
+ * the diffing engine exposed it permanently (veto badges lost their border).
49
+ *
50
+ * The language's answer is a merge, at the CSSOM boundary: when a winning
51
+ * set holds a family shorthand AND one of its longhands, the longhand's
52
+ * value is folded into the shorthand's grammar slot (border) or the
53
+ * shorthand is expanded positionally (padding/margin/gap), and the longhand
54
+ * is dropped β€” the block never holds both. Unhandled pairs fail LOUDLY. */
55
+ const BORDER_STYLES = new Set(['none', 'hidden', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset']);
56
+ const BORDER_WIDTH_RE = /^(0|[\d.]+(?:px|em|rem|%|pt|ch|vw|vh)|thin|medium|thick)$/i;
57
+
58
+ function foldIntoBorder(shVal, lhVal) {
59
+ const parts = String(shVal).trim().split(/\s+/);
60
+ const width = [];
61
+ const style = [];
62
+ const color = [];
63
+ for (const t of parts) {
64
+ if (!width.length && BORDER_WIDTH_RE.test(t)) width.push(t);
65
+ else if (!style.length && BORDER_STYLES.has(t.toLowerCase())) style.push(t);
66
+ else color.push(t);
67
+ }
68
+ if (color.length) color[0] = lhVal; // replace the color slot (later cascade wins)
69
+ else color.push(lhVal); // shorthand had no color slot β€” append
70
+ return [...width, ...style, ...color].join(' ');
71
+ }
72
+
73
+ const POSITIONAL_FAMILIES = {
74
+ 'padding': ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
75
+ 'margin': ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],
76
+ 'gap': ['row-gap', 'column-gap'],
77
+ };
78
+
79
+ function expandPositional(shVal, longhands) {
80
+ const parts = String(shVal).trim().split(/\s+/);
81
+ let vals;
82
+ if (longhands.length === 2) {
83
+ vals = parts.length > 1 ? [parts[0], parts[1]] : [parts[0], parts[0]];
84
+ } else {
85
+ const [a, b = parts[0], c = parts[0], d = parts[1]] = parts.length === 1 ? [parts[0]] : parts;
86
+ vals = parts.length === 1 ? [parts[0], parts[0], parts[0], parts[0]]
87
+ : parts.length === 2 ? [a, b, a, b]
88
+ : parts.length === 3 ? [a, b, c, b]
89
+ : [a, b, c, d];
90
+ }
91
+ return longhands.map((p, i) => [p, vals[i]]);
92
+ }
93
+
94
  // state pseudo-classes that may appear inside a rule selector. Used to strip
95
  // them back OFF when collecting recompute candidates (see stateEvent).
96
  const STATE_PSEUDO_RE = /:(hover|focus|focus-visible|active|focus-within|visited|target|disabled|enabled|checked|placeholder-shown|read-only|read-write|open|closed|empty|required|invalid|valid|link|indeterminate|seen)\b/g;
 
128
  this.seenSet = new WeakSet(); // v0.4.0 β€” elements that entered the viewport
129
  this.staggerEls = new Map(); // v0.4.0 β€” element β†’ stagger delay (ms)
130
  this.io = null; // IntersectionObserver backing `seen`
131
+ this.naturalDisplay = new WeakMap(); // v0.4.1 β€” element β†’ display without our promotion
132
  }
133
 
134
  /* ------------------------------------------------------------ *
 
257
  const m = origMql(query);
258
  if (!m._dsslWired) {
259
  m._dsslWired = true;
260
+ try { m.addEventListener('change', () => this.scheduleComputeAll()); } catch { /* old browsers */ }
261
  }
262
  return m;
263
  };
 
265
  // --- reduced motion ---
266
  try {
267
  const rm = window.matchMedia('(prefers-reduced-motion: reduce)');
268
+ rm.addEventListener('change', () => this.scheduleComputeAll());
269
  this.reducedMotion = rm;
270
  } catch { this.reducedMotion = { matches: false }; }
271
 
 
286
  // element as seen β€” content must never be trapped invisible by a missing API.
287
 
288
  // --- container tracking via ResizeObserver ---
289
+ // v0.4.1: the callback goes through the coalescing scheduler, and β€” more
290
+ // importantly β€” computeElement now skips no-op style writes. The old
291
+ // wipe-and-rewrite could change an observed container's size mid-cycle,
292
+ // which re-fired this observer, which recomputed and rewrote again… a
293
+ // feedback loop the user experienced as the whole UI "twitching". An
294
+ // idempotent drain cannot move anything, so the loop dies here.
295
+ this.ro = new ResizeObserver(() => this.scheduleComputeAll());
296
  }
297
 
298
  stateEvent(state, target) {
 
343
  });
344
  }
345
 
346
+ /* v0.4.1 β€” coalesced full recompute for ambient triggers (ResizeObserver,
347
+ * media flips, signals, reduced-motion). Several triggers can land in the
348
+ * same frame; each one used to run its own computeAll synchronously. One
349
+ * compute per microtask burst is always enough β€” the drain is idempotent. */
350
+ scheduleComputeAll() {
351
+ if (this._allQueued) return;
352
+ this._allQueued = true;
353
+ queueMicrotask(() => { this._allQueued = false; this.computeAll(); });
354
+ }
355
+
356
  matches(el, sel) {
357
  try { return el.matches(sel); } catch { return false; }
358
  }
 
441
  (LAYER_RANK[a.layer] ?? 2) - (LAYER_RANK[b.layer] ?? 2) || a.order - b.order
442
  );
443
 
444
+ /* v0.4.1 β€” diff-based writes. The engine used to WIPE every property it
445
+ * owned and rewrite them all on every single recompute, even when nothing
446
+ * had changed. Under pointer boundary storms that meant hundreds of no-op
447
+ * style mutations per second, a resize-observer feedback loop (the write
448
+ * moved an observed container, RO re-fired, the write ran again…), and a
449
+ * visible "twitch" while the browser kept relayouting identical styles.
450
+ *
451
+ * The language's promise is that a recompute is a pure function of (DOM,
452
+ * rules, state) β€” so a recompute that resolves to the same declarations
453
+ * must touch the DOM zero times. Build the new ownership map, diff it
454
+ * against what we own, and write ONLY the difference. */
455
+
456
+ const owned = this.ownMap.get(el) || new Map(); // prop -> {value, imp}
457
+ const newOwned = new Map(); // prop -> {value, imp}
458
  const animSpecs = [];
459
  let cancelAnims = false;
460
  let stack = false;
461
  let staggerMs; // v0.4.0 β€” @stagger value, if any
462
 
463
+ // flatten matched decls in cascade order (last decl of a prop wins, exactly
464
+ // like sequential setProperty calls did)
465
  const flat = [];
466
  for (const rule of matched) {
467
  const imp = rule.layer === 'pin' ? 'important' : '';
468
  for (const d of rule.decls) flat.push({ d, imp, rule });
469
  }
470
+ for (const { d, imp } of flat) {
471
+ if (d.prop === '@animate') {
472
+ if (d.value === null) { cancelAnims = true; continue; }
473
+ animSpecs.push({ ...d.value, key: /* filled below */ null });
474
+ continue;
475
+ }
476
+ if (d.prop === '@stack') { stack = true; continue; }
477
+ if (d.prop === '@stagger') { staggerMs = d.value; continue; }
478
+ newOwned.set(d.prop, { value: d.value, imp });
479
+ }
480
+ // anim keys need rule identity β€” rebuild them from matched order
481
+ {
482
+ let i = 0;
483
+ for (const rule of matched) {
484
+ for (const d of rule.decls) {
485
+ if (d.prop !== '@animate' || d.value === null) continue;
486
+ const spec = animSpecs[i++];
487
+ if (spec) spec.key = rule.id + '|' + rule.media.join('&') + '|' + rule.signals.map(s => s.name + s.op + s.value).join('&');
488
+ }
489
+ }
490
+ }
491
+
492
+ // v0.4.1 β€” resolve shorthand/longhand coexistence BEFORE any write, so the
493
+ // inline block never holds a family shorthand and one of its longhands.
494
+ for (const fam of ['border']) {
495
+ const sh = newOwned.get(fam);
496
+ if (!sh) continue;
497
+ for (const lhName of ['border-color', 'border-width', 'border-style']) {
498
+ const lh = newOwned.get(lhName);
499
+ if (!lh) continue;
500
+ if (lhName === 'border-color') newOwned.set(fam, { value: foldIntoBorder(sh.value, lh.value), imp: sh.imp });
501
+ else newOwned.set(fam, { value: sh.value, imp: sh.imp }); // non-color longhand: shorthand wins width/style only when it has that slot β€” keep shorthand, drop longhand
502
+ newOwned.delete(lhName);
503
+ if (!this._warned) this._warned = new Set();
504
+ const wmsg = `[DSSL] folded ${lhName} into ${fam} on <${el.tagName.toLowerCase()}${el.className ? ' .' + String(el.className).split(' ')[0] : ''}> β€” inline style cannot hold a var() shorthand and its longhand together`;
505
+ if (!this._warned.has(wmsg)) { this._warned.add(wmsg); console.info(wmsg); }
506
+ }
507
+ }
508
+ for (const [fam, longhands] of Object.entries(POSITIONAL_FAMILIES)) {
509
+ const sh = newOwned.get(fam);
510
+ if (!sh) continue;
511
+ const clash = longhands.filter(p => newOwned.has(p));
512
+ if (!clash.length) continue;
513
+ const imp = sh.imp;
514
+ newOwned.delete(fam);
515
+ for (const [p, v] of expandPositional(sh.value, longhands)) {
516
+ const prevL = newOwned.get(p);
517
+ if (!prevL) newOwned.set(p, { value: v, imp });
518
+ }
519
+ }
520
+
521
+ // REMOVE pass β€” properties we owned but the new cascade no longer sets.
522
+ // This runs BEFORE the promotion read below, so getComputedStyle sees the
523
+ // element's natural display, not a leftover promotion from last time.
524
+ // v0.4.1: the bookkeeping map must forget them too β€” the SET pass diffs
525
+ // against `owned`, and a prop deleted from the element but still in the
526
+ // map would look "unchanged" and never be rewritten (that is how the
527
+ // alignment promotion's display used to vanish on the second recompute).
528
+ for (const p of [...owned.keys()]) {
529
+ if (!newOwned.has(p)) {
530
+ try { el.style.removeProperty(p); } catch { }
531
+ owned.delete(p);
532
+ }
533
+ }
534
 
535
  // v0.3.4: alignment implies a formatting context β€” see ALIGN_PROPS above.
536
+ // The element's NATURAL display (without our previous promotion) decides.
537
+ // v0.4.1: when we own a display we are about to drop, getComputedStyle can
538
+ // race the removal β€” so the natural value is cached per element the first
539
+ // time we see it without owning a display. none/contents are never
540
+ // promoted (promoting would unhide or boxify them).
541
+ if (flat.some(x => ALIGN_PROPS.has(x.d.prop)) && !newOwned.has('display')) {
542
  let disp = '';
543
+ if (owned.has('display')) {
544
+ disp = this.naturalDisplay.get(el) || '';
545
+ if (!disp) { try { disp = getComputedStyle(el).display; } catch { } }
546
+ } else {
547
+ try { disp = getComputedStyle(el).display; } catch { }
548
+ if (disp) this.naturalDisplay.set(el, disp);
549
+ }
550
  if (disp && !HAS_CONTEXT_RE.test(disp) && disp !== 'none' && disp !== 'contents') {
551
  const inlineCtx = disp === 'inline' || disp.startsWith('inline-');
552
+ newOwned.set('display', { value: inlineCtx ? 'inline-grid' : 'grid', imp: '' });
553
  }
554
  }
555
 
556
+ // SET pass β€” only what is new or actually changed.
557
+ for (const [prop, next] of newOwned) {
558
+ const prev = owned.get(prop);
559
+ if (prev && prev.value === next.value && prev.imp === next.imp) continue;
560
+ try {
561
+ el.style.setProperty(prop, next.value, next.imp);
562
+ } catch (why) {
563
+ // v0.3.2: the language promises loud errors β€” if the browser rejects
564
+ // a value we resolved, say so instead of failing silently. This is
565
+ // how the repeat(4px, 1fr) grid collapse would have been caught.
566
+ const msg = `[DSSL] browser rejected "${prop}: ${next.value}" β€” ${why && why.message || 'invalid value'}`;
567
+ if (!this._warned) this._warned = new Set();
568
+ if (!this._warned.has(msg)) { this._warned.add(msg); console.warn(msg); }
569
+ }
 
 
 
 
 
570
  }
571
 
572
  this.ownMap.set(el, newOwned);
 
582
  }
583
 
584
  // container registration
585
+ if (newOwned.has('container-type')) {
586
  if (!this.containerEls.has(el)) { this.containerEls.add(el); try { this.ro.observe(el); } catch { } }
587
  } else if (this.containerEls.has(el)) {
588
  this.containerEls.delete(el);
 
593
  const prevStack = this.stackPrev.get(el) || [];
594
  if (stack) {
595
  for (const child of el.children) {
596
+ // v0.4.1: idempotent β€” children get recomputed on every drain, but
597
+ // rewriting grid-area every time churned the style attribute for free.
598
+ if (child.nodeType === 1 && child.style.gridArea !== '1 / 1') {
599
+ try { child.style.gridArea = '1 / 1'; } catch { }
600
+ }
601
  }
602
  this.stackPrev.set(el, [...el.children]);
603
  } else if (prevStack.length) {
 
661
  const anims = this.animMap.get(kid);
662
  if (!anims || !anims.size) continue;
663
  anims.forEach(a => {
664
+ try {
665
+ // v0.4.1: only touch timing when the slot actually changed
666
+ const cur = a.effect && a.effect.getTiming ? a.effect.getTiming().delay : null;
667
+ if (cur !== i * ms && a.effect && a.effect.updateTiming) a.effect.updateTiming({ delay: i * ms });
668
+ } catch { }
669
  });
670
  }
671
  }
 
696
  if (value === null || value === undefined) document.documentElement.removeAttribute('data-dssl-signal-' + name);
697
  else document.documentElement.setAttribute('data-dssl-signal-' + name, String(value));
698
  } catch { }
699
+ this.scheduleComputeAll();
700
  }
701
 
702
  getSignal(name) { return this.signalStore.get(name); }
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.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.4.1';
22
 
23
  let counter = 0;
24
  const engines = new Map();
scripts/.backup-url-tempsh.txt CHANGED
File without changes
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.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,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.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.
 
120
  </nav>
121
 
122
  <div className="nav-side">
123
+ <a href="#/changelog" className="ver-badge" onClick={(e) => { e.preventDefault(); go("changelog"); }}>v0.4.1</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.4.1 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,21 @@ const ENTRIES: {
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  {
15
  version: "v0.4.0",
16
  date: "growth phase Β· power tools",
 
11
  code?: string;
12
  kind?: "spec" | "vote" | "add";
13
  }[] = [
14
+ {
15
+ version: "v0.4.1",
16
+ date: "growth phase Β· stability",
17
+ title: "The steady hand β€” idempotent styling, twitch eliminated",
18
+ why: "the UI kept twitching under hover and resize storms",
19
+ kind: "add",
20
+ body:
21
+ "A recompute is a pure function of (DOM, rules, state) β€” so this release makes the engine keep that promise literally. computeElement used to wipe every property it owned and rewrite them all on every recompute, even when nothing had changed: hundreds of no-op style mutations per second under pointer boundary storms, plus a nastier feedback loop β€” a rewrite could move a container being watched for @container queries, the ResizeObserver re-fired, the engine recomputed and rewrote again, forever. That churn was the twitch. Now the engine builds the winning declaration set first, diffs it against what it already owns, and writes only the difference: an identical recompute touches the DOM zero times. Ambient triggers (resize, media flips, signals, reduced-motion) are also coalesced into one compute per microtask burst, stack children's grid-area and stagger delays are only rewritten when their slot actually changes. Zero behavior change β€” every style lands exactly where it did before; it just stops being written when it already is.",
22
+ code: `// the fix, in one sentence:
23
+ // recompute(el) resolves the same decls twice
24
+ // β†’ second run performs ZERO style writes
25
+ //
26
+ // before: removeProperty Γ— 22, setProperty Γ— 22 (every hover, every tick)
27
+ // after: removeProperty Γ— 0, setProperty Γ— 0 (when nothing changed)`,
28
+ },
29
  {
30
  version: "v0.4.0",
31
  date: "growth phase Β· power tools",
src/components/dssl/views/SpecView.tsx CHANGED
@@ -11,7 +11,7 @@ export default function SpecView() {
11
  <p className="page-sub">
12
  The v0.1 draft was put to a seven-point vote. This document is the ratified
13
  v0.2 result β€” every decision below is implemented by the engine serving this
14
- page. The engine itself has kept growing since: it is now at v0.4.0 (see the
15
  changelog for what the site demanded).
16
  </p>
17
  </div>
 
11
  <p className="page-sub">
12
  The v0.1 draft was put to a seven-point vote. This document is the ratified
13
  v0.2 result β€” every decision below is implemented by the engine serving this
14
+ page. The engine itself has kept growing since: it is now at v0.4.1 (see the
15
  changelog for what the site demanded).
16
  </p>
17
  </div>