Template `jay-content` Marker
DL#202 — Template-side jay-content marker (content edits without an override)
Written for AI agents. See Log Methodology Note below for details.
Status: DESIGN — ready for review; implementation following. Promoted from DL#201 Item 1. The user chose a template-side mechanism over the three options originally sketched in DL#201.
Related: #196 (flatten / drift / sync; the "unmarked ⇒ sync overwrites" invariant), #200 (prefer
design-system elements; CONTENT_TAGS, REGION-OVERRIDE-NON-CONTENT), #201 (Item 1).
Decisions for the Implementer (TL;DR)
- New marker
jay-content, authored on a node in the design-system template.jay-html. It declares: "this node's children are a content slot — a consumer editing them is expected, not drift." It lives on the template/source side (unlike page-sideoverride=), which is what makes it resilient to future template changes: as long as the marker stays on the template, content edits stay silent; remove it and the region falls back to the normal override model. - Name is
jay-content, notcontent. A barecontentattribute is a real HTML attribute (<meta content>); prefixing matchesjay-scope/jay-coordinate-baseand avoids collision. - Effect on
validate(drift): indiffChildren, if the source (template) node carriesjay-content, do not emit thechildrenfacet — nooverride="children"needed. (Today suppression is read only from the region node viaparseOverride; this adds a source-side check.) - Effect on
validate(DL#200 non-content rule): ajay-contentnode'schildrenfacet is treated as content, soREGION-OVERRIDE-NON-CONTENTnever fires for it either — even for net-new structural DOM. The template author has explicitly opted this subtree into "owned by the consumer," which is broader than theCONTENT_TAGSallowlist, and intentionally so. - Effect on
sync(mergeOverrides/mergeElement): when the template nodetecarriesjay-content, keep the page node's children (ee.innerHTML) instead of re-flattening from the template — the same behavior the page-sideoverride="children"branch already has, but triggered from the template side and without requiring the page node to carry any marker. This is the crux: it inverts the "unmarked ⇒ overwrite" invariant only under a template-marked node. - Compiler hygiene: register
jay-contentinDIRECTIVE_ATTRIBUTES(jay-html-compiler-shared.ts:120) so the template's own compilation doesn't render it to the DOM, and inMETA_ATTRS(override.ts:17) so it's never itself diffed as a content attribute. - Drift message:
formatRegionDrift's message for achildrenfacet gains a second remediation path — "…or mark the nodejay-contentin the template if these children are a content slot." - Scope of the exemption = the marked node's whole
childrenfacet (text and structure). Do not place a nested<jay:X>region under ajay-contentnode (the subtree becomes page-owned); flag that combination invalidateas a follow-up note.
Background
checkRegionDrift (DL#196) reports every unmarked facet that differs from the source template,
including a children facet that is only a text edit. To silence it the author marks
override="children". Separately, DL#200's REGION-OVERRIDE-NON-CONTENT already says "editing text /
enriching with inline content is expected and fine." So the two rules disagree on the same edit:
DL#200 blesses content edits while DL#196 drift still nags for a marker.
DL#201 Item 1 listed three fixes (keep-as-is / suppress-in-differ-and-teach-sync / auto-mark-on-sync). The user chose a fourth, cleaner mechanism: mark the content slot on the template once, and let both rules honor it.
Problem
An unmarked content edit produces noise (validate drift) that contradicts DL#200, and the only way
to silence it (override="children") is per-page, per-instance and fragile: it is a page-side
assertion that must be repeated on every consumer and re-evaluated whenever the template changes. We
want a single, source-side declaration that a given node is a content slot.
Prior Art / Adjacent Mechanisms
| Mechanism | Where | Solves / Constrains |
|---|---|---|
parseOverride / Suppression (all/attributes/styleProps/children) |
compiler-inline-composition/lib/override.ts:42-91 |
The exact suppression model to mirror — but it reads the region node. jay-content adds a source-side read. |
META_ATTRS (jc,override,page-scope) |
override.ts:17 |
Attributes never diffed as content. jay-content joins this set. |
diffChildren children-facet emit + rSup.children skip |
compiler-inline-composition/lib/diff-markup.ts:54-91 |
The one spot to also consult the source parent. |
mergeElement sup.children branch (te.set_content(ee.innerHTML) keep-page) |
materialise.ts:305-307 |
The exact "keep page children" behavior to trigger from the template side. |
CONTENT_TAGS / isNonContentOverride |
validate.ts:1043-1134 |
DL#200's content classifier; jay-content short-circuits it (whole subtree = content). |
DIRECTIVE_ATTRIBUTES / isDirectiveAttribute |
jay-html-compiler-shared.ts:120-141 |
Where to register jay-content so the template doesn't leak it to the DOM. |
formatRegionDrift / overrideSpecFor |
validate.ts:819-844; facet.ts:78-91 |
Where the drift message gains the "mark jay-content" hint. |
Null hypothesis (why not reuse override=): override="children" already silences the content
facet — but it is page-side, per-instance, and must be re-stated on every consumer and re-checked on
every template change. The whole value of jay-content is being source-side and once, so no
existing primitive suffices. It is a small, single-attribute addition that removes per-page markers.
Questions and Answers
Q: Why template-side, not a validator that auto-classifies content (DL#201 option b)?
A: Resilience and intent. A template author knows which nodes are content slots ({body}, a CTA
label) vs. which are fixed chrome. Encoding that once on the template is more precise than a global
tag-allowlist guess, and it survives template edits. Remove the marker and you're back to the
override model — a clean fallback.
Q: Does jay-content exempt structural (non-CONTENT_TAGS) additions too?
A: Yes, for the marked node's children. The marker is a stronger, explicit opt-in than the
CONTENT_TAGS heuristic: "the consumer owns this subtree." That is exactly a content slot. (Elsewhere
— unmarked nodes — CONTENT_TAGS still governs REGION-OVERRIDE-NON-CONTENT.)
Q: What happens on sync to the author's content under a jay-content node?
A: It is preserved (not overwritten). mergeElement keeps the page's children when the template
node is jay-content, the same way it keeps them today under page-side override="children" — but
without the page needing any marker. This is the one place the DL#196 "unmarked ⇒ overwrite"
invariant is deliberately inverted, and only under a template marker.
Q: Nested regions under a content slot?
A: Out of scope / discouraged. A jay-content node makes its whole subtree page-owned, which would
swallow a nested <jay:X>'s re-flatten. Add a validate note (follow-up) that flags a <jay:X>
inside a jay-content subtree.
Q: Does the marker travel into the flattened page copy?
A: No. It is a template-side directive; registered in DIRECTIVE_ATTRIBUTES it is not emitted when
the template compiles, and the drift/sync code reads it from the template body it loads, not from
the page. The page region stays clean (no jay-content, no override).
Design
Read helper (override.ts)
export const CONTENT_MARKER = 'jay-content';
// add CONTENT_MARKER to META_ATTRS
export function isContentSlot(el: HTMLElement): boolean {
return readAttr(el, CONTENT_MARKER) !== undefined; // bare or valued
}
validate drift skip (diff-markup.ts diffChildren)
The differ receives (sourceParent, regionParent). Before emitting the children facet:
if (isContentSlot(sParent)) return; // template marked this node a content slot → not drift
placed alongside the existing if (rSup.all || rSup.children) return; (diff-markup.ts:63). Because
diffChildren already returns without descending, a content slot's entire subtree is exempt.
DL#200 classifier (validate.ts)
checkRegionOverrideNonContent iterates the same diffBodies entries. Since diffBodies no longer
emits a children entry for a content-slot node, the non-content rule never sees it — no extra code
needed beyond the differ change. (Verify in the trace walk, below.)
sync preserve (materialise.ts mergeElement)
// te = template node, ee = existing page node
if (isContentSlot(te)) {
te.set_content(ee.innerHTML); // keep the consumer's content; do not re-flatten this subtree
// carryOwnedAttributes/style still run for attribute/style facets on the node itself
} else if (sup.children) {
te.set_content(ee.innerHTML);
} else {
mergeChildren(te, ee);
}
The jay-content attribute is on te (the template) and re-emitted into the merged output as part of
the template node — but since first-fill/set_content writes template body verbatim and the attribute
is a directive, it is not rendered to the runtime DOM. The page body itself never carries it.
Compiler hygiene
jay-html-compiler-shared.ts:120— add'jay-content'toDIRECTIVE_ATTRIBUTES.override.ts:17— add'jay-content'toMETA_ATTRS.
Drift message (validate.ts formatRegionDrift, children facet only)
Append to the suggestion: "…or, if these children are a content slot, mark the node jay-content in
the template <path> so consumer edits are expected."
Runtime trace (one content edit, end to end)
Template card.jay-html: <p class="ds-card__body" jay-content>{body}</p>. A page flattens it and a
consumer edits the body to Everything you need <strong>today</strong>.
- validate —
checkRegionDrift(validate.ts:892) callsdiffBodies(templateBody, region).diffChildrenreaches the<p>pair;isContentSlot(sParent==<p template>)is true → returns before pushing thechildrenfacet (diff-markup.ts, new guard). No drift warning. ✓ - DL#200 —
checkRegionOverrideNonContentiteratesdiffBodiesentries; the<p>childrenentry is absent, soisNonContentOverridenever runs for it. NoREGION-OVERRIDE-NON-CONTENT. ✓ - sync —
run-sync.ts:66→materialise(preserveOverrides:true)→mergeOverrides→mergeElement(te=<p jay-content>, ee=<p> edited). New guardisContentSlot(te)→te.set_content(ee.innerHTML)keepsEverything you need <strong>today</strong>.. The template's{body}is not re-imposed. ✓ Page<p>stays marker-free. ✓ - Remove the marker from the template → step 1's guard is false → the
childrenfacet is emitted again (drift), andmergeElementfalls tomergeChildren(template wins). Clean fallback. ✓
Implementation Plan
override.ts—CONTENT_MARKER, add toMETA_ATTRS, exportisContentSlot.diff-markup.ts— source-side guard indiffChildren.materialise.ts— template-side guard inmergeElement.jay-html-compiler-shared.ts— registerjay-contentinDIRECTIVE_ATTRIBUTES.validate.ts— extend the children-facet drift suggestion text.- Tests (fixture-based,
toEqual; nevertoContain):diff-markupunit: content-slot source node ⇒ nochildrenentry; without the marker ⇒ entry present.materialise/mergeOverridesunit: content under ajay-contentnode is preserved on sync; without the marker it is overwritten by the template.- stack-cli
validate: a page flattening a content-slot template with an edited body ⇒ zero drift and zeroREGION-OVERRIDE-NON-CONTENT; removing the marker ⇒ drift reappears. - Extend the
design-system-demo(optional): mark the card bodyjay-content, show an edited body on a page producing no warning.
Trade-offs
- Broader than
CONTENT_TAGS. Ajay-contentnode exempts structural additions too. That is the point (explicit opt-in), but it means a template author can turn off structure-drift detection for a subtree. Acceptable: it is a deliberate, visible, source-side choice. - One inverted invariant.
syncnow preserves unmarked page content under a template marker. The invariant becomes "unmarked ⇒ overwrite, unless the template node is a content slot." Documented and traced; the fallback (remove marker) restores the strict form.
Verification criteria
- A content edit under a
jay-contenttemplate node yields zerovalidatewarnings (no drift, noREGION-OVERRIDE-NON-CONTENT). syncpreserves that edit (page.jay-htmlunchanged bysync).- Removing
jay-contentfrom the template makes the same edit report drift again andsyncoverwrite it — the override model is the clean fallback. - The
jay-contentattribute never appears in compiled DOM output or in the flattened page body.
Implementation Results
Implemented across the five planned sites; all tests green.
compiler-inline-composition/lib/override.ts—CONTENT_MARKER = 'jay-content', added toMETA_ATTRS, exportedisContentSlot.compiler-inline-composition/lib/diff-markup.ts— source-side guard indiffChildren(if (isElement(sParent) && isContentSlot(sParent)) return;).compiler-inline-composition/lib/materialise.ts—mergeElementchildren branch nowif (sup.children || isContentSlot(te)).compiler-jay-html/.../jay-html-compiler-shared.ts—'jay-content'added toDIRECTIVE_ATTRIBUTES.stack-cli/lib/validate.ts—formatRegionDriftappends the content-slot hint forchildrenfacets.
Tests: compiler-inline-composition 86/86 (was 78; +5 diff-markup, +3 mergeOverrides);
stack-cli 118/118 (updated one expected-suggestion assertion for the children drift).
Deviations from the design
- The marker rides into the flattened page copy; it is not stripped. The TL;DR said "the page
region stays clean." In reality, first-fill copies the template body verbatim
(
materialise.tsfirst-fill /mergeOverridesoutput), so the page region's node carriesjay-contenttoo. This is inert and correct: the differ keys the exemption off the source template node (sParent/te), the page copy is never consulted for it, andDIRECTIVE_ATTRIBUTESstops it rendering. The resilience property is unchanged — remove the marker from the template and drift resumes regardless of a stale page copy, because authority is the template. Not stripping it is the smaller change (no new strip pass) and keeps the page copy a faithful mirror of the template. - Pre-existing
override=/page-scopeleakage noted, not fixed. While wiringDIRECTIVE_ATTRIBUTESI confirmedoverride=currently renders into compiled output (it is neither a directive nor stripped — visible indesign-system-demo's builtroute.server-element.js).jay-contentis correctly made a directive here; aligningoverride/page-scopeis out of DL#202 scope and left as a separate follow-up.
Refinement (post-review) — jay-content is a facet list, like override=
The original design made jay-content a boolean (children-only slot). The reviewer asked: what about a
consumer owning a specific attribute — e.g. an <img>'s src/alt as the content, not its
children? Rather than special-case images, jay-content was generalized into a facet list with the
exact grammar of override=:
- Tokens:
children,style.<prop>, or a bare attribute name (e.g.src,alt,href), comma- or space-separated. Barejay-content(no value) =children(the DL#202 default).jay-content="*"= the whole node (all facets). - Read from the source (template) side, mirroring
override='s page-side read. A facet is treated as consumer-owned — skipped by the differ, kept bysync— if either side owns it: the page'soverride=union the template'sjay-content. - Null-hypothesis check: no new grammar was invented — the existing
override=token parser was factored into a sharedparseFacetTokens, andjay-contentreuses it. The only genuinely new surface is one attribute name and one union step.
Examples:
<!-- template: the consumer supplies the image; everything else is design-system -->
<img jay-content="src alt" src="placeholder.png" alt="" class="ds-hero__img" />
<!-- template: the consumer owns the link target and the label text -->
<a jay-content="href children" class="ds-cta" href="#">Learn more</a>
Implementation Results
Status: IMPLEMENTED. Tests green.
What shipped
override.ts— sharedemptySuppression()+parseFacetTokens()(comma/space split;children,style.<prop>, bare attribute).parseOverriderefactored onto them. NewparseContent(el)readsjay-contentwith the same grammar (bare ⇒children,*⇒ whole node). NewunionSuppression(a, b)ORs twoSuppressions.diff-markup.ts—diffChildrenanddiffElementnow suppress a facet whenunionSuppression(parseOverride(regionNode), parseContent(sourceNode))owns it.materialise.ts—mergeElementkeeps the page's children when the union ownschildren(template-sidejay-contenttriggers the keep without any page-side marker).index.ts— exportsparseContent,unionSuppression,isContentSlot,CONTENT_MARKER.
Tests
override.test.ts— facet-list slot: bare ⇒ children,*⇒ whole node, named-attribute slot owns only that attribute (not children), combinedchildren src.diff-markup.test.ts—jay-content="src alt"on an<img>is not drift;jay-content="src"exempts onlysrc(aclasschange still drifts); comma-separatedsrc, alt.materialise.test.ts— attribute-slotjay-contentpreservessrc/alton sync while still re-flattening children; children-slot keeps page children.- Downstream: the demo's card-heading children drift suggestion now offers the
jay-contenttemplate-side alternative (asserted indesign-system-demosmoke).
Refinement (post-review) — strip jay-content from the flattened page
Q (review): is jay-content's grammar identical to override=? Almost. * ⇒ whole node and every
named facet (children, style.<prop>, attributes) parse identically — they share parseFacetTokens.
The only difference is the bare form: bare override ⇒ whole node, bare jay-content ⇒ children.
This asymmetry is deliberate: a page author writing bare override is taking over the whole node, while
a template author marking <p jay-content> wants the consumer to own the text/markup while the template
keeps governing the node's own class/structure — the content-slot default is children, not the whole node.
* remains the explicit "whole node is the consumer's" escape hatch on both sides.
Q (review): should the flattened page retain jay-content? No. jay-content is a template-side
marker — materialise (parseContent(te)) and the differ (parseContent(sParent)/parseContent(se))
always read it from the component source, never from the page. A copy stamped onto the flattened page is
therefore never consulted; it is dead weight and misleading (it reads as if the page were declaring the
slot). This is the exact inverse of override=/page-scope, which are page-owned provenance that
carryMarkers deliberately persists so the hole survives the next sync.
Change. materialise.ts gained stripContentMarkers(root), called at the end of both materialise
(covers first-fill, where filled = template.body copies the marker verbatim, and transitive nested
regions) and mergeOverrides (covers the sync path and the exported-API unit tests). It removes every
jay-content attribute from the flattened output. Idempotent and safe: because suppression is re-derived
from the source each sync, dropping the page copy changes no behaviour — only the emitted page is cleaner.
materialise.test.ts content-slot expectations updated to assert the marker is absent from the output.
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.