CSS @scope Donut for Region Isolation
DL#203 — CSS @scope (…) to (…) donut for region isolation
Written for AI agents. See Log Methodology Note below for details.
Status: DESIGN — ready for review; implementation following. Promoted from DL#201 Item 2. The user confirmed full browser support can be assumed.
Related: #196 (flatten / @scope wrapping / CSS coalescing / scope-anchor class), #201 (Item 2).
Decisions for the Implementer (TL;DR)
- Emit the donut form
@scope (.<ref>) to (<direct-child-region-anchors>) { … }so a region's CSS stops at the boundary of each direct nested<jay:X>child region. Full browser support is assumed, so no build-time fallback (:not()guard / hoist) is needed. - The
to (…)list is the scope-anchor classes of the region's direct child regions — exactly whatdirectRegions(region)(materialise.ts:173-184) returns (it already stops at region boundaries). Build it infillRegionsright afterregion.set_content(filled)(line 144), reading each direct child'srefattribute (.+ ref). If a region has no child regions, emit plain@scope (.<ref>) { … }(noto). - Coalescing key must include the
tolist. TodaycoalesceCsskeys only ontemplatePath(materialise.ts:84-107), assuming same-template ⇒ identical block. With donuts, two instances of the same template can have different direct children (one has a<jay:badge>filled in, the other doesn't) → differenttolists → they must not coalesce. New key =templatePath + '\0' + sortedToAnchors.join(','). Add thetolist toCssContribution(materialise.ts:55-59) and pass it toscopeWrap. - Non-materialisable child regions get no
toentry. A<jay:X>without a resolvable template is never flattened, so itsrefis never stamped as a DOM class (stampScopeAnchorruns only in the fill path) — ato (.ref)would match nothing. Omit such children from thetolist (document it); they are rare and already warn elsewhere. - Teach every
@scopeparser about the optionalto (…)segment. The scope-start regexes assume@scope (…)is immediately followed by{. They appear in three files and must tolerate an optionalto (…)before{:compiler-jay-html-adjacentscope-css.ts—tokenizeCssheader regex (:50) andscopeInnerBodystrip (:94).stack-cli/lib/validate.ts—scopeBodystrip (:1327) and the block-extraction used bycheckRegionCssScoping/checkRegionCssDrift.stack-cli/lib/run-sync.ts—narrowScopeBlockscope-start rewrite (:183) must preserve theto (…)tail.
- Validation canonical shape updates.
checkRegionCssScoping(validate.ts:989-1035): theCSS-SCOPE-NOT-COALESCEDkey (template + scopeBody,:1021) must also fold in thetolist, or it will false-positive and tell the author to coalesce two blocks that legitimately differ by boundary.CSS-SCOPE-MIXED-TEMPLATE(:1012) is unaffected (keys on template identity). - CSS body diff is unaffected.
to (…)is header-only;scopeReadyCss/:scoperewrite (materialise.ts:227-246) and body-equality comparisons stay as-is — once the strip regexes ignore theto (…)tail.
Background
The materialiser wraps each region's CSS in @scope (.<ref>) (DL#196, scopeWrap
materialise.ts:195-197). A plain @scope (.ref) matches all descendants — including the roots
and interiors of nested <jay:X> child regions flattened inside it. So a parent region's
descendant selectors bleed into a child region's DOM, breaking "each region owns its styles" and
complicating coalescing.
CSS has a purpose-built tool for exactly this — the scoping limit (donut):
@scope (.parent) to (.child) { … } styles the subtree except the islands rooted at the to
selectors.
Problem
Region isolation currently relies on authors not writing descendant selectors that reach into child
regions. That is a convention, not a guarantee: a parent .ds-card p { … } will style a <p> inside a
nested <jay:badge> region. We want isolation to be structural.
Prior Art / Adjacent Mechanisms
| Mechanism | Where | Solves / Constrains |
|---|---|---|
scopeWrap (string concat of @scope (sel) { css }) |
materialise.ts:195-197 |
The single emit point; append to (…) here. |
directRegions (boundary-stopping child walk) |
materialise.ts:173-184 |
Exactly the direct-child enumeration for the to list — reuse as-is. |
stampScopeAnchor (ref → real class on region roots) |
materialise.ts:253-257 |
Why the to targets exist as DOM classes; also why non-materialisable children have none. |
scopeReadyCss / root→:scope rewrite |
materialise.ts:227-246 |
Body rewrite; unaffected by to. |
coalesceCss (key = templatePath) |
materialise.ts:84-107 |
Must extend key with the to list. |
CssContribution type |
materialise.ts:55-59 |
Add the to list field. |
tokenizeCss / splitScopeBlocks / scopeInnerBody |
scope-css.ts:47-94 |
Header regex must tolerate to (…). |
checkRegionCssScoping / scopeBody |
validate.ts:989-1035, 1325-1331 |
NOT-COALESCED key folds to; strip regex tolerates to. |
mergeScopeCss / narrowScopeBlock |
run-sync.ts:111-183 |
Block identity + scope-start rewrite must preserve to. |
Null hypothesis (why not keep plain @scope + author discipline): The bleed is a real
correctness gap that no existing primitive closes without author vigilance. to (…) is the
standard, declarative fix and we already compute its inputs (directRegions + stamped anchors), so
the new surface is a single header segment, not a new mechanism.
Questions and Answers
Q: Where do the to anchors come from, and are they available at emit time?
A: directRegions(region) after region.set_content(filled) (fillRegions, between :144 and the
CSS wrap at :146-165). The filled body already contains the nested <jay:X ref> elements; read each
ref. The child's own .ref class is stamped later (when that child is itself filled), but within
the same materialise run it always will be, so to (.childRef) resolves. (Per the trace.)
Q: What breaks coalescing?
A: Two instances of the same template with different child nesting produce different to lists; a
single coalesced @scope (.a, .b) to (…) can't be correct for both. Fix: fold the sorted to list
into the coalescing key so they split into separate blocks automatically. Instances with matching
template and matching child-anchor set still coalesce.
Q: Non-materialisable child region as a boundary?
A: It has no stamped class, so to (.ref) would match nothing. Omit it from the to list and
document. (These are uncommon and surfaced by other warnings.)
Q: Does to affect CSS body equality / drift?
A: No. It is header-only. Once the strip/parse regexes ignore the to (…) tail, scopeBody /
scopeInnerBody / normalizeCssBody compare the same bodies as before.
Q: What about checkRegionCssScoping's canonical-shape enforcement?
A: MIXED-TEMPLATE keys on template identity (unaffected). NOT-COALESCED keys on template + body and
must also include the to list, else it demands coalescing of boundary-distinct blocks. MISSING
(in checkRegionCssDrift) is about presence of a block for .ref, unaffected.
Design
CssContribution + emit (materialise.ts)
interface CssContribution {
key: string;
selector: string | null;
css: string;
to?: string[];
}
function scopeWrap(css: string, selector: string, to?: string[]): string {
const limit = to && to.length ? ` to (${to.join(', ')})` : '';
return `@scope (${selector})${limit} {\n${css}\n}`;
}
In fillRegions, after region.set_content(filled) (:144):
const toAnchors = directRegions(region)
.map((c) => readAttr(c, 'ref'))
.filter((r): r is string => !!r && /* materialisable */ true)
.map((r) => `.${r}`);
// push contribution { key: `${templatePath}\0${[...toAnchors].sort().join(',')}`, selector, css, to: toAnchors }
Coalescing (coalesceCss)
Key already comes from contribution.key — just ensure it includes the sorted to list (above).
scopeWrap(g.css, g.selectors.join(', '), g.to) — the to list is identical across a group by
construction (same key), so any member's to is correct for the group.
Parser tolerance (shared pattern)
Replace @scope\s*\(\s*([^)]+?)\s*\)\s*\{ with a form that accepts an optional limit:
@scope\s*\(\s*([^)]+?)\s*\)(?:\s*to\s*\(\s*([^)]+?)\s*\))?\s*\{, capturing the to list as group 2.
Apply in scope-css.ts:50, scopeInnerBody :94, validate.ts:1327, and run-sync.ts:183 (and any
sibling @scope\s*\([^)]*\)\s*\{ strippers). narrowScopeBlock must re-emit the captured to tail.
Validation key (checkRegionCssScoping)
NOT-COALESCED key becomes `${templates[0]} ${scopeBody(block)} ${toListOf(block)}` where
toListOf reads the (now-captured) sorted to members.
Runtime trace (card with a nested badge)
Template card.jay-html flattens a nested <jay:badge ref="badge">. Page has two cards: cardA (with
the badge) and cardB (badge empty/absent).
fillRegionsfillscardA;directRegions(cardA)→[<jay:badge ref=badge>]→to=['.badge']; contribution keycard.jay-html\0.badge.fillRegionsfillscardB; no child region →to=[]; keycard.jay-html\0.coalesceCss: different keys → two blocks:@scope (.cardA) to (.badge) { … }and@scope (.cardB) { … }. Correct —cardA's.ds-card pno longer reaches into the badge;cardBhas nothing to exclude. ✓- If both had the badge: same key
card.jay-html\0.badge→ coalesced@scope (.cardA, .cardB) to (.badge) { … }. ✓ validatere-parses the page<style>; header regex captures.cardA/.cardBand.badgeas thetolist; NOT-COALESCED key includes.badge, so the two single-instance blocks in step 3 are not flagged for coalescing. ✓sync(mergeScopeCss→narrowScopeBlock) re-emits each block preserving itsto (…)tail; body equality unchanged → page<style>stable (validate-clean ⇔ sync-clean). ✓
Implementation Plan
materialise.ts—CssContribution.to;scopeWraplimit; computetoinfillRegions; fold into the coalescing key.scope-css.ts— header regex +scopeInnerBodystrip tolerateto (…); expose thetolist fromtokenizeCss/splitScopeBlockswhere the validator needs it.validate.ts—scopeBodystrip toleratesto; NOT-COALESCED key folds thetolist.run-sync.ts—narrowScopeBlockpreserves thetotail;mergeScopeCssblock identity folds it.- Tests (fixture-based,
toEqual/prettifyHtml; nevertoContain):materialiseunit: nested child region ⇒@scope (.ref) to (.child) { … }; no child ⇒ plain block; two same-template instances with differing children ⇒ two blocks (not coalesced); with matching children ⇒ one coalescedtoblock.scope-cssunit: tokenizer round-trips ato (…)block (selectors +tolist + body).validateunit: canonical donut page is clean; a hand-coalesced block whose instances differ by boundary is flagged NOT-COALESCED correctly;syncleaves a canonical donut page unchanged.
Trade-offs
- Coalescing is slightly less aggressive. Same-template instances with different child nesting no longer share a block. Correct by construction; the extra bytes compress well and are bounded by the number of distinct nesting shapes.
- Regex surface across three files. The
@scopeparsing is duplicated (postcss on the materialiser side, regex/brace on the stack-cli side). All scope-start matchers must learnto (…)or sync/ validate silently corrupt blocks. Mitigated by centralizing the header regex where possible and by the round-trip tokenizer test.
Verification criteria
- A region with a direct child region emits
@scope (.<ref>) to (.<child>) { … }; a leaf region emits plain@scope (.<ref>) { … }. - Same-template instances coalesce iff their direct-child-anchor sets match.
- A parent region's descendant selector no longer styles DOM inside a nested child region (structural isolation).
validateon a canonical donut page is clean, andsyncleaves it byte-identical (validate-clean ⇔ sync-clean holds with thetosegment).
Refinement (post-review) — require a ref on nested regions under scoped CSS
The design above omits a child region from the to list when it has no ref (nothing to anchor the
boundary on). That silently leaves the child inside the parent's scope — the parent's descendant
selectors bleed into it, the exact failure the donut exists to prevent. The reviewer chose full
isolation: a nested region under a CSS-shipping parent must be named, enforced by validation.
Decision — require ref on nested regions (chosen over "best-effort donut").
- Rule
REGION-NESTED-NO-REF(checkNestedRegionRefs,validate.ts): for every region whosetemplate=ships CSS and that has aref(so it emits an@scope (.<ref>)block), every materialisable direct child region (directChildRegions) must carry aref. A ref-less one is flagged — it cannot be stamped with a scope-anchor class, so the parent's donut cannot name it. - Non-materialisable children are out of scope. A
<jay:Y>imported withouttemplate=is never flattened or stamped, so it can never be a donut boundary; the rule skips it (it is nudged elsewhere byREGION-NOT-LINKED). - Consequence for the demo:
.ds-buttonCSS moved fromcard.jay-htmlintobutton.jay-html(region ownership), and every<jay:button>in the card/gallery/section templates gainedref="cta". The card donut is now@scope (.cardStarter, .cardPro) to (.cta), with the button's own styles in a separate@scope (.cta)block.
No new validation for stale to. Adding/removing a nested region changes the parent template's
markup, which is already markup drift caught by checkRegionDrift; sync then recomputes the to
list from the current children. So a to that no longer matches the nesting is never a distinct error
state — it is either drift (reported) or fixed by the next sync. The require-ref rule is the only new
validation this DL adds.
Implementation Results
Status: IMPLEMENTED. All phases complete; tests green.
What shipped (matches the plan)
materialise.ts—CssContribution.to;scopeWrap(css, selector, to?)appendsto (…);fillRegionscomputes the dedupedtolist fromdirectRegionsand folds the sorted list into the coalescing key (`${templatePath}\0${[...to].sort().join(',')}`). Anchor stamping was decoupled from the region's own CSS and gated onscoped || parentScoped, so a child named by a parent's donut gets its.<ref>class even when it ships no CSS of its own.scope-css.ts—ScopeBlock.to;tokenizeCssheader regex captures the optionalto (…)group;scopeInnerBody/splitScopeBlocksexpose it.validate.ts—scopeBodystrip toleratesto; the NOT-COALESCED key folds the sortedtolist (NUL-separated, matching the materialiser); newcheckNestedRegionRefs+directChildRegions, wired into the run loop.run-sync.ts—narrowScopeBlockpreserves theto (…)tail on re-narrowing.
Deviations
- Selector +
tode-duplication (new, not in the plan). Two instances of one template that each nest a child sharing aref(the demo: two cards each with aref="cta"button) coalesced to@scope (.cta, .cta). Fixed by de-duping both the coalesced selector list (coalesceCss) and the per-regiontolist (fillRegions). Found by the demo re-sync, not a unit test — now covered. narrowScopeBlockexported fromrun-sync.tsfor a focused unit test of theto-preserving regex.
Tests
compiler-inline-composition: 100/100 (materialise donut emission, coalescing-by-boundary, dedup).jay-stack-cli: 127/127 — newscope-css.test.ts(tokenizer round-tripsto (…)), newcheckNestedRegionRefsfixtures (fires on a ref-less nested region, passes with a ref), newnarrowScopeBlockto-preservation tests.design-system-demo: 8/8 smoke — the demo now flattens the full donut chain (@scope (.hero) to (.grid)→(.grid) to (.cardStarter, .cardPro)→(.cardStarter, .cardPro) to (.cta)→(.cta)), andvalidateis clean of scoping / require-ref warnings.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.