Guidelines

Single-Prop-Render Guideline

**Version: v1** **Applies to: any consumer template or component composing a canon primitive (`@gyrum-labs/svelte/*` or anything under `src/lib/components/canon/`).**

v1

Single-Prop-Render Guideline

Version: v1 Applies to: any consumer template or component composing a canon primitive (@gyrum-labs/svelte/* or anything under src/lib/components/canon/).

This guideline encodes a load-bearing rule the upcoming structural single-render gate will enforce. It is a sub-rule of the css-is-canon-tier-work principle. PRs touching files in scope cite Guideline: single-prop-render@v1 in the PR body so persona reviewers (ADR-115) verify against this exact version.

Rule

A canon component renders each prop exactly once per breakpoint. The same data value MUST NOT appear in two DOM positions at the same viewport width.

Responsive collapse between layouts is expressed via CSS @media queries (or :has() visibility swaps) that hide one variant and show the other — NEVER by rendering the same prop twice and hoping flex-wrap collapses one branch. A flex-wrap-based collapse is layout-conditional and resolves both branches at any width that fits both. That width exists at production resolutions for a non-trivial fraction of operators.

Concretely, in a single .svelte file:

  • If {prop.tagline} appears in two <span>/<p>/<div> elements that are both within the visible-by-default flow, the second appearance is a violation.
  • If two layout variants need different markup (e.g. inline at small viewports, block at large), wrap each in a @media-gated rule that toggles display: none for the non-matching variant.

Why

Today's empirical evidence: ai-frontend/src/lib/components/canon/ProjectCard.svelte shipped {project.tagline} twice in production. Line 124 rendered it as <span class="row-tagline">{project.tagline}</span> inside the head-line stack; line 138 rendered it as <p class="row-desc">{project.tagline}</p> inside the block-stack region. The consumer's intent was that the head-line variant inline-collapses at narrow widths via flex-wrap while the block variant only renders at wider widths via :has() selector logic. The :has() selector matched at the operator's actual viewport width and BOTH variants resolved. Duplicate text shipped to the production catalog.

Three properties make this failure mode particularly load-bearing:

  1. Console-clean. The duplicate didn't throw any error. There's no runtime signal for "you rendered the same string twice" — the smoke-spec pageerror listener can't catch it. The failure is only visible to an operator looking at the rendered page.
  2. Indistinguishable in code review. The bespoke CSS made the two <span> / <p> positions look intentionally responsive. A reviewer reading the diff sees "responsive variants, fine" — not "duplicate render at any width fitting both".
  3. Tier-specific. The canon primitive (GyListRow) has one slot per content region — it CANNOT render tagline twice. The failure mode is unique to consumer-tier composition that grew a bespoke layout grammar around the canon primitive.

The fix: pick ONE variant that survives at every supported breakpoint and delete the other. If two truly distinct markup shapes are needed, gate one in a @media rule (or :has() visibility swap) that sets display: none for the non-matching branch. The browser then renders one and only one.

Examples — passes

Single render, no responsive variant:

<GyListRow>
    {#snippet name()}
        <span class="row-name">{project.name}</span>
    {/snippet}
    {#snippet description()}
        <p class="row-desc">{project.tagline}</p>
    {/snippet}
</GyListRow>

Responsive variant via @media-gated display: none — one variant visible per breakpoint:

<GyListRow>
    {#snippet description()}
        <span class="row-tagline-inline">{project.tagline}</span>
        <p class="row-tagline-block">{project.tagline}</p>
    {/snippet}
</GyListRow>

<style>
    /* Below the medium breakpoint: show inline, hide block */
    .row-tagline-inline {
        display: inline;
    }
    .row-tagline-block {
        display: none;
    }

    /* At or above medium: show block, hide inline */
    @media (min-width: 720px) {
        .row-tagline-inline {
            display: none;
        }
        .row-tagline-block {
            display: block;
        }
    }
</style>

(Note: the above EXAMPLE-PASS still requires canon-extension marker per the canon-extension-discipline guideline because it carries layout CSS — @media-gated display toggles are layout. The two guidelines compose: this guideline says the responsive shape is correct; canon-extension-discipline says any layout work needs a marker or it belongs in canon.)

Examples — fails

Same prop, two DOM positions, no visibility swap:

<!-- FAILS — {project.tagline} rendered twice, both reachable at the same viewport -->
<GyListRow>
    {#snippet name()}
        <span class="row-name">{project.name}</span>
        <span class="row-tagline">{project.tagline}</span>
    {/snippet}
    {#snippet description()}
        <p class="row-desc">{project.tagline}</p>
    {/snippet}
</GyListRow>

flex-wrap-based collapse (the operator-facing failure mode):

<!-- FAILS — :has() collapse + flex-wrap collapse both resolve at the operator's
     viewport width; tagline renders twice in production -->
<GyListRow>
    {#snippet name()}
        <div class="row-head" style="display: flex; flex-wrap: wrap;">
            <span class="row-name">{project.name}</span>
            <span class="row-tagline">{project.tagline}</span>
        </div>
    {/snippet}
    {#snippet description()}
        <p class="row-desc">{project.tagline}</p>
    {/snippet}
</GyListRow>

Visibility swap toggling opacity not display (still rendered, still in DOM, accessibility tools read both):

/* FAILS — opacity: 0 hides visually but element still in the accessibility
   tree; screen-reader users hear tagline twice */
@media (min-width: 720px) {
    .row-tagline-inline {
        opacity: 0;
    }
}

Path forward when canon doesn't have what you need

Path A — in-PR (warp#3100)

If the canon primitive doesn't offer a slot shape that fits your responsive design, the responsive variant work is canon-tier work. Add the slot to the canon primitive in the SAME PR. Example: today's GyListRow exposes name / description / meta slots — if your design needs a tagline-inline-vs-block toggle, propose a tagline slot with responsive: "inline-md-block" prop on the canon primitive, ship the canon-side change and the consumer-side adoption in the same PR per warp#3100.

Path B — sister ticket

If the missing primitive is a molecule or atom that no existing canon-consumer expresses, file a sister ticket for the responsive primitive (e.g. GyResponsiveText with inline / block variants). Tag the current PR blocked-by:warp#<sister> and add /* canon-extension: warp#NNN */ markers per the canon-extension-discipline guideline. The closed-loop sweeper auto-files retirement when the sister ships.

Audited override

gyrum-review-pr --skip-single-prop-render "<reason ≥ 30 chars>" — logged to ~/.gyrum/admin-overrides.log. Use ONLY when the duplicate render is INTENTIONAL and accessibility-safe (e.g. a print-only DOM that's hidden via @media print from the screen view, paired with aria-hidden="true"). The reason MUST name (a) why both renders are intentional, (b) which mechanism guarantees the user sees only one, and (c) what the accessibility tree shows.

PR body citation

Required line in the PR body when changing consumer files:

Guideline: single-prop-render@v1

Revision process

This document is versioned. Updates ride gyrum-revise-guideline single-prop-render (separate devtools wrapper, separate ticket).

References