Calm-First CTA Guideline
Version: v1
Applies to: any consumer template or page declaring a page-hero CTA via GyPageHero or any canon page-header primitive.
This guideline extends the calm-first principle (warp#2206) with a CTA-specific rule. PRs touching page-hero CTAs cite Guideline: calm-first-cta@v1 in the PR body so persona reviewers (ADR-115) verify against this exact version.
Rule
At rest — i.e. on a steady-state page that the operator visits to look at content, not to recover from an error — page-hero CTAs render with variant="secondary". The variant="primary" fill is reserved for moments of attention: calm-first empty-slot CTAs ("you have no projects yet, create one"), error-recovery actions ("retry"), and decision prompts ("confirm deploy").
The rule applies to:
- The
variantprop onGyButton(or equivalent canon button primitive) when used as the first/only CTA in aGyPageHeroslot. - Any
<button>styled withclass="btn--primary"orclass="cta-primary"within a page-hero region. - Any element in the page-hero CTA position whose visual weight matches "primary fill" — solid dark/brand fill, high-contrast text, hero-prominent padding.
The rule does NOT apply to:
- Empty-state slots (
emptyslot on calm-first templates likeDashboardTemplatefrom warp#3616) — primary fill is correct there; the slot's whole job IS calling for action. - Error-state surfaces (404, deploy-failed pages) — primary fill on the recovery action is correct; the surface IS a moment of attention.
- Modal-confirm dialogs and decision-prompt overlays — primary fill is correct there; the user is being asked to make a decision now.
Why
Today's empirical evidence: today's /projects page hero (ai-frontend) ships a dark "New project" variant="primary" button as the loudest visual element on a steady-state catalog page. Operators visiting /projects to browse the list see the primary CTA's dark fill before they see any of the actual project rows. The CTA's weight trains operators to ignore visual weight as a signal — when something genuinely urgent appears later (a calm-first empty slot prompt, an error-state retry button), the operator's eye has nowhere distinctive to land.
Sister evidence: V96 today (warp#3616) shipped DashboardTemplate with a calm-first empty slot that takes over the body region when the dashboard has nothing to show. The empty-slot CTA there ("create your first dashboard") is correctly variant="primary" — that's a moment-of-attention surface. The operator is on a dashboard page expecting to see data and finds none; the primary CTA is the right next-action.
When EVERY page hero ships primary CTAs, the primary/secondary distinction collapses into "the only style we use". The calm-first principle (warp#2206) requires the distinction to be meaningful — primary fill earns attention precisely because most surfaces don't use it. Three failure modes follow when the distinction collapses:
- Visual hierarchy noise. The primary fill on a steady-state hero competes with content for attention. Operators slow down to filter the CTA out before reading the content.
- Empty-state under-emphasis. When the calm-first
emptyslot finally fires (a dashboard with no data, a project list with no projects), its primary CTA blends into the visual baseline of "every page has a primary CTA" and reads as cosmetic rather than the urgent next-action it's meant to be. - Error-recovery under-emphasis. Same shape as (2) — the error-page "retry" button no longer stands out because operators have learned to ignore primary fills.
The fix: default page-hero CTAs to variant="secondary" (token-driven outlined or low-fill style). Reserve variant="primary" for the three surfaces named above. The visual hierarchy then carries information: a primary fill means "something needs your attention here", not "this page has a header".
Examples — passes
Steady-state catalog page hero — secondary CTA:
<GyPageHero title="Projects" subtitle="Your active and archived projects.">
{#snippet actions()}
<GyButton variant="secondary" href="/projects/new">
New project
</GyButton>
{/snippet}
</GyPageHero>
Calm-first empty slot — primary CTA correct here:
<DashboardTemplate {state}>
{#snippet empty()}
<GyEmptyState heading="No dashboards yet" body="Create one to start.">
<GyButton variant="primary" href="/dashboards/new">
Create your first dashboard
</GyButton>
</GyEmptyState>
{/snippet}
</DashboardTemplate>
Error-recovery surface — primary CTA correct here:
<!-- src/routes/+error.svelte -->
<GyPageHero title="Something went wrong" subtitle={$page.error?.message}>
{#snippet actions()}
<GyButton variant="primary" onclick={() => location.reload()}>
Retry
</GyButton>
{/snippet}
</GyPageHero>
Multi-CTA hero — primary reserved for the rare urgent action, secondary for the steady-state one:
<!-- Only correct when the page genuinely has both a steady-state action
and an attention-requiring one (e.g. unread-count drives 'review now') -->
<GyPageHero title="Inbox">
{#snippet actions()}
{#if unreadCount > 0}
<GyButton variant="primary" href="/inbox/review">
Review {unreadCount} new
</GyButton>
{/if}
<GyButton variant="secondary" href="/inbox/settings">
Settings
</GyButton>
{/snippet}
</GyPageHero>
Examples — fails
Steady-state hero shipping primary CTA (today's lived failure):
<!-- FAILS — /projects is a steady-state catalog; primary CTA at rest
trains operators to ignore visual weight -->
<GyPageHero title="Projects" subtitle="Your active and archived projects.">
{#snippet actions()}
<GyButton variant="primary" href="/projects/new">
New project
</GyButton>
{/snippet}
</GyPageHero>
Custom-class CTA matching primary visual weight:
<!-- FAILS — bypasses the variant prop but ships the same visual weight -->
<GyPageHero title="Settings">
{#snippet actions()}
<button class="cta-loud" onclick={save}>Save</button>
{/snippet}
</GyPageHero>
<style>
.cta-loud {
background: var(--text);
color: var(--bg);
padding: var(--space-3) var(--space-5);
}
</style>
Multi-primary CTA hero (no priority order):
<!-- FAILS — two primaries cancel each other out; operator has no signal -->
<GyPageHero title="Project">
{#snippet actions()}
<GyButton variant="primary" href="/deploy">Deploy</GyButton>
<GyButton variant="primary" href="/settings">Settings</GyButton>
{/snippet}
</GyPageHero>
Path forward when canon doesn't have what you need
Path A — in-PR (warp#3100)
If the canon GyButton primitive doesn't expose a variant="secondary" that fits your design at the page-hero scale, that's canon-tier work. Author the variant on the canon primitive in the same PR — most likely as a new value in the variant enum with token-driven fill / border / text-color rules. Update the canon documentation page for GyButton with the new variant's visual contract. Register the variant in canon-manifest.yaml if appropriate.
Path B — sister ticket
If the page-hero genuinely needs a distinct CTA primitive that GyButton shouldn't carry (e.g. a hero-only "stamp" affordance with a different shape grammar), file a sister ticket for the new primitive. Tag the current PR blocked-by:warp#<sister> and ship the current hero with the closest existing variant="secondary" in the meantime, with a /* canon-extension: warp#NNN */ marker if any layout CSS sits in the consumer file (per canon-extension-discipline).
Audited override
gyrum-review-pr --skip-calm-first-cta "<reason ≥ 30 chars>" — logged to ~/.gyrum/admin-overrides.log. Use ONLY when the surface is genuinely attention-requiring (onboarding flows where the page IS the call-to-action; one-off promotional landing pages; security-prompt pages). The reason MUST name (a) why this surface is attention-requiring rather than steady-state, (b) why the calm-first separation costs more than it earns here, and (c) the audit-log line referring to the operator's acknowledgement.
PR body citation
Required line in the PR body when changing page-hero CTAs:
Guideline: calm-first-cta@v1
Revision process
This document is versioned. Updates ride gyrum-revise-guideline calm-first-cta (separate devtools wrapper, separate ticket).
References
- css-is-canon-tier-work principle — parent principle (visual contract IS canon-tier)
- warp#2206 — calm-first parent principle (the design pin this guideline extends)
- warp#3672 — this guideline's live ticket
- warp#3616 — V96 / DashboardTemplate — canonical empty-slot CTA shape
- warp#3100 — canon-extension Path-A / Path-B
- warp#974 — smoke-spec gate
- ADR-115 — principle-aware reviewers
- ADR-117 — module guidelines
- canon site: https://canon.gyrum.ai/components/button
- canon site: https://canon.gyrum.ai/components/page-hero