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.
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
| 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 |
-
//
|
| 392 |
-
//
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
| 394 |
let disp = '';
|
| 395 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
if (disp && !HAS_CONTEXT_RE.test(disp) && disp !== 'none' && disp !== 'contents') {
|
| 397 |
const inlineCtx = disp === 'inline' || disp.startsWith('inline-');
|
| 398 |
-
|
| 399 |
}
|
| 400 |
}
|
| 401 |
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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>
|