Guidelines

Canon Page Shape Guideline

**Version: v1** **Applies to: every `+page.svelte` in any consumer SvelteKit app that imports a canon primitive (`@gyrum-labs/svelte/*` or `@gyrum-labs/components/*`).**

v1

Canon Page Shape Guideline

Version: v1 Applies to: every +page.svelte in any consumer SvelteKit app that imports a canon primitive (@gyrum-labs/svelte/* or @gyrum-labs/components/*).

This guideline is the operational rule body that the structural canon-page-shape gate (warp#3707) enforces. It closes the gap left after the tier-5 <gy-page> primitive shipped (warp#2631, gyrum-ui PR#71) — the primitive exists but no structural rule kept consumer pages from drifting back into bespoke layout chrome. PRs touching files in scope cite Guideline: canon-page-shape@v1 in the PR body so persona reviewers (ADR-115) verify against this exact version.

Rule

Every +page.svelte in a consumer SvelteKit app roots in the tier-5 <GyPage> primitive (or the underlying <gy-page> Lit web-component), exactly once, at the top level. Bespoke wrapping chrome around the primitive is refused.

The rule decomposes into three failure shapes the gate detects:

  1. bare — no <GyPage> anywhere in the page body. The page has no canon page-geometry contract; whatever layout chrome it ships is unilateral and unreviewed against the primitive's slot shape.
  2. multiple — more than one top-level <GyPage> element. A page is ONE canonical page; rendering two side-by-side makes page-geometry (--page-max, --page-gutter, sticky-header offset, sidebar reservation) meaningless because the primitive's own assumptions about "I own the viewport" are violated.
  3. wrapped<GyPage> lives inside another element. The first non-meta top-level element of the page body is something other than <GyPage> (e.g. <div class="page-wrap">, <main>, <section class="layout">). Wrapping chrome silently narrows the viewport and double-applies gutters; the primitive cannot enforce its geometry contract if it does not own the page root.

Allowed siblings of <GyPage> at the top level (NOT wrappers):

  • <script> blocks (component logic).
  • <style> blocks (page-scoped CSS).
  • <svelte:head> (document head injection).
  • <svelte:window> (window-level event handlers).
  • <svelte:body> (body-level event handlers).
  • HTML comments.
  • Blank lines.

The rule is enforced via grep-shape AST detection on the page body — the page's top-level shape is regular enough that the gate does not need a full Svelte parser. Same trade-off as canon-tier-gate's import-scan rule (warp#2399, warp#3098): false positives on bizarrely-formatted files (e.g. <GyPage> on the same line as a closing tag) are acceptable when the cost is rejecting a malformed page that should be reformatted anyway.

Why

Today's empirical evidence: ai-frontend's +page.svelte files have, over multiple iterations, accreted bespoke layout chrome (page-wrap divs, layout containers, sidebar-reservation hacks) page-by-page. The tier-5 <GyPage> primitive shipped at gyrum-ui PR#71 (warp#2631) specifically so consumer pages stop reimplementing page-geometry. Without a structural gate, consumer pages drift back into the pre-primitive shape one PR at a time.

Three properties make this failure mode load-bearing:

  1. Silent geometry drift. A bespoke <div class="page-wrap"> around <GyPage> doesn't throw an error — it just narrows the viewport and double-applies gutters. The page renders; the operator sees "the layout looks slightly off". No smoke spec catches it because the page does not crash; no design-canon assertion catches it because the rendered DOM is "valid HTML rendering some text".
  2. Multiplies per-page. Once one page ships a bespoke wrapper, the next reviewer reads "well, other pages do this" and the pattern spreads. The gate exists precisely because documentary discipline ("don't wrap GyPage") loses to the silent precedent of an existing wrapped page.
  3. Tier-specific. This is consumer-tier work; the canon-tier primitive cannot fix it because the primitive is the thing being wrapped. The only place this rule can be enforced is at PR-review-time against consumer pages.

The fix: every page roots in <GyPage>. Bespoke chrome lifts into the primitive's default slot. When the primitive genuinely lacks the slot shape needed, extend the primitive in the same PR per canon-tier extension discipline (warp#3100) — do not work around it with a wrapper.

Examples — passes

Bare-minimum page — <GyPage> is the root, content goes in the default slot:

<script lang="ts">
  import { GyPage } from '@gyrum-labs/svelte';
  let { data } = $props();
</script>

<svelte:head>
  <title>Projects — Gyrum</title>
</svelte:head>

<GyPage>
  <h1>Projects</h1>
  <ul>
    {#each data.projects as project}
      <li>{project.name}</li>
    {/each}
  </ul>
</GyPage>

The first non-meta top-level element is <GyPage>; <script> and <svelte:head> are allowed siblings; no wrapping element appears.

Page using the Lit web-component form (Svelte autoresolves to the tier-5 primitive):

<script lang="ts">
  import '@gyrum-labs/components/pages/page';
</script>

<gy-page>
  <article>Page content here.</article>
</gy-page>

Lowercase <gy-page> is the Lit-adapter form of the same tier-5 primitive — the gate accepts both shapes.

Page with multiple top-level meta blocks (still ok — meta blocks are siblings, not wrappers):

<script lang="ts">
  import { GyPage } from '@gyrum-labs/svelte';
</script>

<svelte:head>
  <title>Settings</title>
</svelte:head>

<svelte:window on:resize={handleResize} />

<style>
  :global(body) { background: var(--surface-canvas); }
</style>

<!-- Settings page: roots in GyPage per canon-page-shape@v1 -->
<GyPage>
  <h1>Settings</h1>
</GyPage>

Three meta blocks (<script>, <svelte:head>, <svelte:window>, <style>) plus a comment, all siblings of <GyPage>. The page root is <GyPage>; the gate clears.

Examples — fails

bare — no <GyPage> anywhere:

<!-- FAILS — no <GyPage>; bespoke layout chrome with no canon contract -->
<script lang="ts">
  let { data } = $props();
</script>

<div class="page-wrap">
  <main class="container">
    <h1>Projects</h1>
    <ul>
      {#each data.projects as project}
        <li>{project.name}</li>
      {/each}
    </ul>
  </main>
</div>

The page ships its own page-wrap / container chrome with no relationship to the tier-5 primitive's geometry contract.

multiple — two top-level <GyPage> instances:

<!-- FAILS — two <GyPage> instances; a page is ONE canonical page -->
<script lang="ts">
  import { GyPage } from '@gyrum-labs/svelte';
</script>

<GyPage>
  <h1>Left half</h1>
</GyPage>

<GyPage>
  <h1>Right half</h1>
</GyPage>

The page-geometry primitive assumes it owns the viewport; rendering two violates that assumption silently.

wrapped<GyPage> inside a bespoke wrapper:

<!-- FAILS — <GyPage> is wrapped in <div class="page-wrap"> -->
<script lang="ts">
  import { GyPage } from '@gyrum-labs/svelte';
</script>

<div class="page-wrap">
  <GyPage>
    <h1>Projects</h1>
  </GyPage>
</div>

The first non-meta top-level element is <div>, not <GyPage>. The wrapper silently narrows the viewport, double-applies gutters, and breaks the primitive's geometry contract.

wrapped (subtle)<GyPage> inside <main> looks semantic-HTML-correct, still fails:

<!-- FAILS — <main> wrapping <GyPage> is still wrapping -->
<script lang="ts">
  import { GyPage } from '@gyrum-labs/svelte';
</script>

<main>
  <GyPage>
    <h1>Projects</h1>
  </GyPage>
</main>

<GyPage> already renders the appropriate landmark element internally; adding <main> outside it duplicates the landmark and breaks the primitive's geometry contract. Lift <main>'s role into GyPage's slot or extend the primitive (Path A) if a different landmark is genuinely needed.

Path forward when GyPage doesn't yet fit

Path A — extend the primitive in the same PR (default)

When the page needs a slot shape <GyPage> doesn't currently expose (e.g. a new geometry hook, a different landmark element, a configurable sidebar), extend the canon-tier primitive in the same PR per canon-tier extension discipline (warp#3100):

  1. Author the primitive extension at gyrum-ui/packages/components/src/pages/page/page.ts (and the Svelte adapter at gyrum-ui/packages/svelte/src/GyPage.svelte).
  2. Add the slot / prop to the primitive's story + visual regression suite.
  3. Consume the new shape from your +page.svelte.
  4. Ship canon-side + consumer-side in the same PR so reviewer sees the contract end-to-end.

This is the dominant path; the primitive is meant to be extended through real consumer pressure, not speculation.

Path B — sister-ticket on canon-tier (when extension is non-trivial)

When the primitive extension is non-trivial (e.g. needs design review, breaks visual regression, requires a tier-4 template inversion), file a sister ticket on gyrum-ui and tag your current ticket blocked-by:warp#<sister>. The sister PR ships the primitive extension with its own review; your consumer PR waits and consumes the extension once it merges.

This is sister-shape to the canon-extension-discipline path (warp#3100). Inverting tier-4 templates so they delegate page-geometry to <GyPage> rather than re-deriving it is tracked at warp#3708 — that ticket is the prereq for the gate's promotion from v1 advisory to enforce.

Audited override

gyrum-review-pr --skip-canon-page-shape "<reason ≥ 30 chars>" — logged to ~/.gyrum/admin-overrides.log and ~/.gyrum/findings/findings.jsonl per the audit-on-override convention. Use ONLY when the page genuinely cannot root in <GyPage>:

  • Iframe-host pages that intentionally render no chrome (the entire viewport belongs to the embedded iframe; no page-geometry applies).
  • Error / 404 pages that need a deliberately-minimal shape disjoint from the page-geometry primitive (e.g. +error.svelte is typically allowed; some apps want their 404 page to skip GyPage too).
  • Print-only routes rendered for PDF export where page-geometry actively conflicts with print-media CSS.

The reason text MUST name (a) why this page cannot root in <GyPage>, (b) why Path A (extending the primitive) doesn't fit, and (c) what makes the wrapper / bare shape intentional rather than oversight. Generic reasons ("temporary", "will refactor later", "not in scope", "doesn't matter") are rejected by the operator audit.

v1 advisory mode

This gate ships in v1 advisory mode — findings surface as a reviewer-visible row in gyrum-review-pr's output but rc=0 keeps the gate from blocking merge. Promotion to fail-closed (rc=1 on any finding) is blocked by warp#3708 (gyrum-ui — invert tier-4 templates to delegate page-geometry to <GyPage>). The tier-4 inversion eliminates the duplication that would otherwise force every template-using page through a wrapped shape; once the inversion lands, the baseline of off-shape pages drops to zero and the gate flips to enforce.

The advisory-mode-then-enforce pattern is the same shape canon-url-resolves-gate (warp#3722) uses — surface the signal first, give consumer pages time to migrate (Path A), then promote to fail-closed once the baseline reaches zero.

PR body citation

Required line in the PR body when changing files in scope (any +page.svelte in a consumer SvelteKit app):

Guideline: canon-page-shape@v1

The persona reviewer (ADR-115) fetches this exact version and verifies the implementation against this text. Bumping the version is a separate PR through gyrum-revise-guideline canon-page-shape that includes a regression-corpus re-run.

Revision process

This document is versioned. Updates ride gyrum-revise-guideline canon-page-shape (separate devtools wrapper, separate ticket). Hand-edits without that wrapper skip the regression suite. The regression corpus at regression-corpus/guidelines/canon-page-shape/ includes one fixture per pass / fail example above plus an end-to-end fixture that runs the gate against a synthetic PR diff containing 10 +page.svelte files of mixed ok / bare / multiple / wrapped / wrapped-subtle shapes.

References

  • warp#3707 — canon-page-shape gate implementation (devtools PR#796; the structural enforcement this doc accompanies).
  • warp#3708 — gyrum-ui tier-4 template inversion (prereq for promotion to fail-closed; blocks the v1 → enforce flip).
  • warp#2631 — gyrum-ui PR#71 that shipped the tier-5 <gy-page> primitive (the canon primitive this guideline cites).
  • warp#2399 — canon-tier-gate (sister structural gate; "page imports exactly one template" — this guideline is the structural follow-on enforcing "and that template/primitive lives at the root").
  • warp#3098 — canon-tier-gate page-scan extension (mechanical enforcement of "page imports exactly one template").
  • warp#3100 — canon-extension discipline (Path A in-PR vs Path B sister-ticket).
  • warp#3717 — canon-doc frontmatter extension (T-A; the triggers / gate / prompt_snippet shape this doc adopts).
  • warp#3773 — this guideline's live ticket (companion meta-doc to warp#3707 gate).
  • ADR-083 — defense-in-depth rule promotion (the advisory-then-enforce pattern this guideline rides).
  • ADR-115 — principle-aware reviewers (cite this guideline's version).
  • ADR-117 — module guidelines (the substrate this doc rides).
  • canon site: https://canon.gyrum.ai/
  • canon guidelines registry: https://canon.gyrum.ai/data/guidelines-manifest.json