Region CSS Without Ref
DL#209 — Region ships CSS but has no ref (sync silently drops it)
Written for AI agents. See Log Methodology Note below for details.
Status: IMPLEMENTED — severity decided: error (user).
Decisions for the Implementer (TL;DR)
- Problem: a flattened region whose backing
template=ships CSS but whose<jay:X>tag has norefloses its CSS onsync. The materialiser emits that CSS unscoped/global (no.refto scope to);mergeScopeCssthen re-emits only@scopeblocks, so the unscoped block is never carried back into the page<style>. The styling silently vanishes. ✅ verified - Root cause is structural, not a sync bug: without a
refthere is no scope anchor, so the CSS cannot be scoped, and unscoped region CSS is exactly what sync is designed to drop (it only owns@scopeblocks it generated). So the fix is not "make sync copy it" — copying unscoped region CSS would leak the region's styles globally. The fix is validation: tell the author to add arefso the CSS can be scoped and survive. Prevention-first (CLAUDE.md), not a framework change. - New rule (proposed name
REGION-CSS-NO-REF): for every top-level<jay:X>region whose resolvedtemplate=ships non-empty<style>CSS and whose element has noref=, emit a diagnostic: "This region's template ships CSS, but the region has noref, so its CSS cannot be scoped andsyncwill drop it. Addref="…"." - Fills a real gap — no existing rule covers the top-level ref-less CSS region:
checkRegionCssDriftskips ref-less regions (validate.ts:984-985), so it never flags this.checkNestedRegionRefs(DL#203) only covers a ref-less region nested inside a CSS-shipping parent — not a top-level ref-less CSS region. ✅ verified (research pass)
- Where/how (mirror
checkNestedRegionRefs,validate.ts:1102-1146): iteratecollectRegionElements(jayHtml.body); resolve the import (thevalidate.ts:1264pattern); fire whentemplateShipsCss(imp.template)(reuse the closure at1111-1119:extractStyleCss(content).trim() !== '') is true andregion.getAttribute('ref')is absent. Template bytes come from the injectedreadTemplateRelreader (validate.ts:1981-1987). ✅ verified data is reachable. - OPEN DECISION (for the author): warning or error? See Q1. Default proposal below is error, because unlike a drift or a missing-reuse nudge, this one silently deletes authored styling — but it is listed as the open question the user flagged.
Background
DL#196 flattens a component .jay-html into a page as a <jay:X> region: markup plus its CSS,
re-emitted into the page <style> as an @scope (.ref) { … } donut (DL#203). The .ref is the
scope anchor — it's what ties the region's CSS to the region's DOM subtree without leaking to the rest
of the page. DL#206 further guarantees that anchor exists as a display:contents wrapper whenever a
region emits scoped CSS.
The anchor is derived from the region's ref= attribute: defaultScopeSelector returns .${ref} or
null when there is no ref (materialise.ts:242-245). No ref ⇒ no selector ⇒ no @scope.
Problem
A region with template= CSS but no ref is a dead end for that CSS:
- Materialiser emits it unscoped.
fillRegionstakes theselector === nullbranch and pushes{ key, selector: null, css }(materialise.ts:202-204);coalesceCssemits a null-selector group as bare CSS with no@scopewrapper (materialise.ts:107-110, 125-128). The DL#206 wrapper gate isref && (scoped || parentScoped)(materialise.ts:216) — ref-less ⇒ no wrapper. ✅ verified - Sync drops it.
syncPageContent→mergeScopeCss(html, css)(run-sync.ts:66-68).mergeScopeCssbuilds its groups viasplitScopeBlocks(aggregatedCss)(run-sync.ts:122), which keeps onlytype === 'scope'segments (scope-css.ts:93-97). The unscoped ref-less block is arawsegment, never indexed intogroups, so it is never appended to the page<style>. ✅ verified
Net: the region's CSS silently disappears on first sync. There is no diagnostic today — it's an invisible, silent styling loss, exactly the failure class the design-log methodology says to catch at validation time.
Note we deliberately do not fix this by making sync carry the unscoped CSS: that would inject the
region's raw selectors into page-global scope, re-introducing the descendant-bleed that @scope
(DL#203) exists to prevent. The correct state is "scoped CSS or no CSS," and the way to get scoped CSS
is a ref. Hence validation, not a serializer change.
Prior Art / Adjacent Mechanisms
| Mechanism | Where | Solves? / Constrains? |
|---|---|---|
checkRegionCssDrift |
validate.ts:967-1023 ✅ |
Skips ref-less regions (984-985) — so it is blind to this case; its CSS-SCOPE-MISSING sub-case only fires when a ref exists |
checkNestedRegionRefs (DL#203) |
validate.ts:1102-1146 ✅ |
Only the nested-child ref-less case; does not cover a top-level ref-less CSS region. Exact structure to mirror |
templateShipsCss closure |
validate.ts:1111-1119 ✅ |
Reusable "backing template has non-empty CSS" test (extractStyleCss(content).trim() !== '') |
extractStyleCss |
validate.ts:947-953 ✅ |
Concatenates every <style> in a template's raw HTML |
collectRegionElements |
validate.ts:821-833 ✅ |
Enumerates all <jay:X> regions (the iteration source) |
import matching (imp.template) |
validate.ts:1264 ✅ |
Resolves a region tag → its backing template path |
readTemplateRel injected reader |
validate.ts:1981-1987 ✅ |
Loads template bytes; already passed to the sibling region rules |
| warnings vs errors wiring | validate.ts:2017-2026 (warn) / 1962, 2078 (error) ✅ |
The two severity channels to choose between (Q1) |
Null hypothesis — does an existing rule already catch this? No. checkRegionCssDrift explicitly
continues on ref-less regions and checkNestedRegionRefs only handles the nested case. The gap is
real and uncovered. ✅ verified (research pass).
Questions and Answers
Q1. Warning or error? (OPEN — user decision.) Options:
- (a) Error (author's lean) — this silently deletes authored CSS on sync with no other
signal; that's a correctness loss, not a style nudge. An error forces the
refbefore the styling is lost. Mirrorerrors.push({ file, message, stage: 'generate' })(validate.ts:1962/2078). - (b) Warning — consistent severity with the sibling region-CSS rules (
checkRegionCssDrift,checkNestedRegionRefsallwarnings.push), suppressible like the rest of the DL#200/#176 family. Risk: a warning among many may be ignored and the CSS still vanishes. - Recommendation: error, because the consequence (silent data loss) is categorically worse than the drift/reuse warnings. If the author prefers uniformity with the other region rules, (b) is a one-line change of channel. Decision pending.
Q2. Top-level only, or nested regions too?
A. All regions returned by collectRegionElements (which includes nested). A ref-less CSS-shipping
region loses its CSS regardless of nesting depth. (DL#203's checkNestedRegionRefs already nudges
ref-less nested regions for a different reason — scope-anchor stamping — but does not condition on
the child's own template shipping CSS, so there is no duplicate diagnostic for the same fix; both
resolve by adding a ref, so at worst the author adds the ref once and both quiet.) ⚠️ assumed: verify
in implementation that the two messages don't double-fire confusingly on the same element; if they do,
prefer this rule's message (names the data-loss) and skip the nested nudge when CSS is shipped.
Q3. Does this duplicate "region not linked" (REGION-NOT-LINKED)?
A. No. REGION-NOT-LINKED fires when there is no template= at all. This rule fires when there
is a template= and it ships CSS and there's no ref. Disjoint preconditions.
Q4. What about a ref-less region whose template ships NO css? A. Not flagged by this rule — there's nothing to lose. (DL#203's nested nudge may still apply for its own anchor reason.) Keeping this rule narrowly "ships CSS + no ref" avoids false positives on pure-markup regions.
Design
New function checkRegionCssNoRef(jayHtml, loadTemplate) in validate.ts, a near-copy of
checkNestedRegionRefs:
for region in collectRegionElements(jayHtml.body):
contractName = region.tagName.substring(4) // strip "jay:"
imp = importsWithTemplate.find(matches contractName) // validate.ts:1264 pattern
if !imp?.template: continue
if region.getAttribute('ref'): continue // has an anchor → CSS can be scoped
if !templateShipsCss(imp.template): continue // nothing to lose
push finding:
message: `Region <jay:${contractName}> flattens template="${imp.template}" which ships CSS, `
+ `but the region has no ref, so its CSS cannot be scoped and sync will drop it.`
suggestion: `Add ref="…" to <jay:${contractName}> so sync can scope and preserve its CSS.`
Wire it in validateJayFiles next to the other region checks (near validate.ts:2017-2026), pushing
to errors (Q1-(a)) or warnings (Q1-(b)) per the severity decision. Reuse the existing
readTemplateRel reader and the cached templateShipsCss helper (lift it to module scope or
re-inline the closure).
Implementation Plan
- Phase 1 — rule + wiring. Add
checkRegionCssNoRef; wire intovalidateJayFilesat the chosen severity. ReuseextractStyleCss/templateShipsCss/readTemplateRel. - Phase 2 — fixtures + tests. Fixture project: (a) region with CSS-shipping template + no ref
→ fires; (b) same template with ref → silent; (c) ref-less region whose template ships no CSS
→ silent; (d)
REGION-NOT-LINKEDregion (no template) → this rule silent. Assert on the result object'serrors/warningsarray (full-object equality), nevertoContainon code. - Phase 3 — agent-kit. One line in the designer guide: "a region that flattens a CSS-shipping
template must have a
refor its CSS is dropped." Link from DL#200 and DL#203.
Scope Pre-mortem (what this does NOT handle)
- Auto-fixing (synthesizing a
ref): out of scope — the author should name the anchor. - Making sync carry unscoped CSS: explicitly rejected (would leak styles globally; defeats DL#203).
- Partial CSS (template ships CSS that targets only
:scope-less global selectors): still flagged; the remediation (add a ref) is the same. - De-duplication with DL#203's nested nudge: flagged as Q2 ⚠️ — resolve during implementation.
Verification Criteria
- A page with a ref-less region flattening a CSS-shipping template produces the new diagnostic; adding
ref=clears it andsyncthen preserves the CSS as a scoped@scopeblock. - A ref-less region whose template ships no CSS produces no diagnostic.
REGION-NOT-LINKEDand this rule never both fire on the same element.--jsonincludes the finding in the chosen channel; exit code reflects the severity decision.
Trade-offs
- + Converts a silent styling-loss into a build-time diagnostic with an obvious one-token fix; pure validation, zero runtime/syntax, reuses existing walks and template reader.
- − If shipped as an error, it can block a build on a page that was "fine" before the author added CSS to a shared template — but that build was silently losing CSS, so the error is surfacing a real regression, not inventing one.
- − Severity inconsistency risk: the sibling region-CSS rules are warnings; an error here is justified by the data-loss consequence but breaks uniformity (Q1).
Pointers
- Materialiser ref-less path:
compiler-inline-composition/lib/materialise.ts:202-204, 107-110, 125-128, 242-245, 216 - Sync drop site:
stack-cli/lib/run-sync.ts:66-68, 122;stack-cli/lib/scope-css.ts:93-97 - Rule to mirror:
stack-cli/lib/validate.ts:1102-1146; wiring:2017-2026; error form:1962, 2078 - Helpers:
extractStyleCss:947-953;templateShipsCss:1111-1119;readTemplateRel:1981-1987;collectRegionElements:821-833; import match:1264 - Related DLs: DL#196 (regions), DL#203 (scope donut), DL#206 (display:contents anchor), DL#200 (region validation family), DL#176 (suppressible warnings)
Implementation Results
Status: implemented as an error (Q1 decided by the user: error, because it silently deletes authored CSS).
What changed
stack-cli/lib/validate.ts— addedcheckRegionCssNoRef(jayHtml, loadTemplate), a near-copy ofcheckNestedRegionRefs: iteratecollectRegionElements(jayHtml.body), match each<jay:X>to itsimportsWithTemplateentry bycontractName, skip when the region has arefor its template ships no CSS (reusing thetemplateShipsCss+extractStyleCsscache pattern), and push a finding otherwise.- Wired into
validateJayFilesnext to the DL#203 nested-ref check, pushing toerrorswithstage: 'generate'(notwarnings) — the finding'ssuggestionrides along onValidationError(validate.ts:66). This is the severity decision: an error, because the consequence is silent styling loss, not drift.
Tests (all toEqual/toHaveLength on the finding objects — never toContain)
- New fixture tree
test/fixtures/validate/region-css-no-ref/: a CSS-shippingcardcomponent, a CSS-lessplaincomponent, and three pages —page-no-ref(ref-less<jay:card>→ fires),page-with-ref(ref="promo"→ silent),page-no-css(ref-less<jay:plain>, no template CSS → silent). - New
describe('checkRegionCssNoRef (DL#209 …)')invalidate.test.ts— 3 tests (fires once; two silent cases).validate.test.ts84 → 87; full stack-cli suite 131 → 134, all passing.
Deviations / Q2 resolution
- Q2 (nested double-fire): not observed in practice —
checkNestedRegionRefsfires on the parent naming a ref-less child for a different reason (donut boundary), and conditions on the child being materialisable; the two messages address the same remedy (add aref) and did not collide on the test fixtures. Left both as-is; no de-dup needed. - Rule iterates all regions (
collectRegionElements), not top-level only (per Q2 design) — a ref-less CSS region loses its CSS at any depth.
Agent-kit docs updated (Phase 3)
agent-kit-template/designer/design-system-guide.md— "The shape of a region's CSS" now documentsREGION-CSS-NO-REFas a hard error (a CSS-shipping region must carry aref; unsuppressible).agent-kit-template/designer/validation-guide.md— noted it under "Errors vs Warnings" as the one design-system rule that is an error, with the add-a-refremedy.
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.