Layout

Structural components for organising content on a page. Cards surface discrete pieces of content in a scannable grid; section transitions create intentional visual breaks between full-width areas; and the footer provides a reusable site-wide shell.

Cards

Entry points that surface content and invite action. Background, border, and border-radius are driven by --card-* Tier-3 tokens — retheme in one place without touching the semantic layer.

Default

Background fill, no visible border. Use for most card contexts.

Outlined

Adds a --color-border edge for same-level surfaces.

Highlight

High-contrast background — adapts per theme.

Anatomy

When anatomy children are present the wrapper's padding is removed automatically via:has() — each section carries its own var(--card-padding)..card-media must be the first child so its top corners align with the card's border-radius.

Fern Valley Trail

3.2 km · Moderate

A shaded loop through old-growth forest with seasonal wildflower meadows along the ridge.

Full reference — Cards

Variants

VariantClassUse
Default.cardArticle lists, feature grids, project indexes. Background: --card-bg--color-bg-panel.
Outlined.card-outlinedAdds var(--color-border) edge when the surrounding surface matches the card's background.
Highlight.card-highlightFeatured or promoted content. Background: Deepwater (--fo3-deepwater) in light, Forest (--fo2-forest) in dark — Birch Mist text in both modes (8.6:1 on deepwater, 6.7:1 on forest).
Interactive.card-interactiveAny card that is a navigation target or action trigger — adds hover, focus, and active states.

Anatomy

<div class="card">
  <div class="card-media"><img src="trail.jpg" alt="…"></div>
  <div class="card-header"><h3>Title</h3></div>
  <div class="card-body"><p>Description</p></div>
  <div class="card-footer">
    <button class="btn btn-p btn-sm">Action</button>
  </div>
</div>
ClassPurposeNotes
.card-mediaTop image / visual areaMust be first child. overflow:hidden clips to card radius.
.card-headerTitle and metadata rowUse semantic heading elements (h2h4).
.card-bodyMain content areaflex: 1 — expands to fill in equal-height grids.
.card-footerActions rowmargin-top: auto pins to bottom. Divider appears when preceded by .card-body.

Interactive cards

Add .card-interactive to any card that is a navigation target or action trigger. Works on both <div> (with tabindex="0") and <a>.

<!-- div card -->
<div class="card card-interactive" tabindex="0"> … </div>

<!-- anchor card (whole card is the link) -->
<a href="/trail/fern-valley" class="card card-interactive"> … </a>

Card grid

.card-grid creates a responsive auto-fit grid. Minimum column width: 280px. Cards collapse to a single column on narrow viewports.

<div class="card-grid">
  <div class="card card-interactive"> … </div>
  <div class="card card-interactive"> … </div>
  <div class="card card-interactive"> … </div>
</div>

Token reference

TokenDefaultPurpose
--card-bgvar(--color-bg-panel)Default card background
--card-hover-bgvar(--color-bg-inset)Background on hover (interactive cards)
--card-bordertransparentBorder colour — .card-outlined overrides to --color-border
--card-radiusvar(--radius-lg)Border radius for card and media
--card-paddingvar(--space-md)Padding for bare cards and all anatomy sections
--card-highlight-bg--fo3-deepwater (light) · --fo2-forest (dark)Background for .card-highlight
--card-highlight-bordertransparentBorder for .card-highlight
--card-highlight-textvar(--color-card-highlight-text)Primary text on highlight card — adapts per theme
--card-highlight-text-secondaryvar(--color-card-highlight-text-secondary)Secondary text on highlight card
--card-highlight-text-tertiaryvar(--color-card-highlight-text-tertiary)Tertiary text on highlight card

Overriding tokens

/* Tighter padding for a dense list layout */
.article-list {
  --card-padding: var(--space-sm);
}

/* Pill cards in a marketing section */
.feature-grid {
  --card-radius: var(--radius-xl);
}

CSS reference

Shipped in dist/farn-components.css. Load alongside farn.css or farn-tokens.css.

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: var(--space-md);
}

.card {
  display: flex;
  flex-direction: column;
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: var(--card-radius);
  padding: var(--card-padding);
}
.card:has(> .card-header, > .card-body, > .card-footer, > .card-media) {
  padding: 0;
}

.card-outlined  { border-color: var(--color-border); }
.card-highlight { background: var(--card-highlight-bg); border-color: var(--card-highlight-border); }

.card-interactive {
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease-out);
}
.card-interactive:hover         { background: var(--card-hover-bg); }
.card-interactive:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 3px; }
.card-interactive:active        { transform: scale(0.99); }
a.card-interactive              { color: inherit; text-decoration: none; }

Preview card pattern

A card with a fixed-height live-demo strip at the top — useful whenever a card should preview the content or context it links to, giving the viewer a visual taste before they navigate. Composes the existing card anatomy: .card-media.card-preview for the strip,.card-body for the label and description.

Full reference — Preview card

Markup

<a class="card card-interactive card-outlined" href="/section">
  <div class="card-media card-preview" aria-hidden="true">
    <!-- live mini-demo content -->
  </div>
  <div class="card-body">
    <p>Section title</p>
    <p>One-sentence description.</p>
  </div>
</a>
  • .card-media must be the first child for correct corner clipping.
  • .card-preview sets height: 9rem, flex-centres content, and clips overflow.
  • a.card-interactive suppresses default link colour and underline — child text renders in --color-text.
  • Add aria-hidden="true" to the preview strip — the link label comes from the card body, not the demo content.
  • Add tabindex="-1" to any interactive elements inside the preview strip so they are not keyboard-reachable.

Token reference

TokenDefaultPurpose
--card-preview-height9remHeight of the preview strip. Override on a parent element to resize without touching the class.

All other --card-* tokens apply — background and border are inherited from the card context.

CSS reference

.card-preview {
  height: 9rem;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  padding: var(--space-sm);
}

a.card-interactive {
  color: inherit;
  text-decoration: none;
}

Section Transitions

Intentional visual breaks between full-width page sections — signalling a shift in content, depth, or energy. All patterns consume the semantic token layer so transitions stay coherent as the palette evolves. Use one primary pattern per page.

Layered overlap

The only pure-CSS pattern — no SVG. The next section or a card bleeds upward into the preceding one using a negative margin-top and rounded top corners.Never add overflow:hidden to the preceding section — it clips the overlap entirely.

Preceding section

Dark area — never add overflow:hidden here.

Overlapping section

Rounded top corners float over the section above.

Sine wave

An SVG divider using layered cubic bezier curves — two offset paths create the illusion of a receding tide. Sits absolutely at the bottom of the parent section; fill matches the next section's background.

Preceding section

Wave at the bottom — fill matches the section below.

Next section

Override --color-section-next to match this background.

Convex arc

A single quadratic bezier spanning the full width. Calmer than the sine wave — one smooth dome suited to centred hero layouts. Animates via animation-timeline: view()in supporting browsers; static fallback otherwise. The preview below shows the static state.

Preceding section

Arc at the bottom — fill matches the section below.

Next section

Override --color-section-next on the parent to match this background.

Organic blob

A freeform SVG boundary — no mathematical regularity. Two offset paths soften the edge. Fade-only animation keeps the irregular shape from looking chaotic in motion. Use at most once per page for a high-emphasis brand moment. Draw paths in Figma or Inkscape — do not hand-author blob paths.

Preceding section

Blob at the bottom — replace the example paths with exported Figma paths for production.

Next section

Diagonal cut

A skewed ::before pseudo-element paints the section background at an angle — content stays horizontal and box-shadow still works. In supporting browsers the angle flattens via animation-timeline: view().

Preceding section

Background skewed via ::before — content stays horizontal.

Next section

Set --color-section on the element to match the preceding section's background.

Full reference — Section Transitions

Pattern guide

PatternClassesCharacterBest for
Layered overlap.overlap-preceding / .overlap-section / .overlap-cardDimensional, modernDark hero → light content; featured CTA panels
Sine wave.section-waveOrganic, rhythmicRepeated section breaks; any natural flowing transition
Convex arc.section-arcRefined, quietCentred hero layouts; calm lens-like elegance
Organic blob.section-blobExpressive, freeformHigh-emphasis brand moments; one-off expressive breaks
Diagonal cut.section-skewSharp, directionalCTA sections; forward-momentum transitions

Use one primary type per page. A second type is acceptable for a single high-emphasis moment (e.g., sine waves throughout with one layered overlap at a CTA). More than two distinct types creates visual noise.

Layered overlap — details

<section class="overlap-preceding">
  <!-- Hero — never overflow:hidden -->
</section>

<section class="overlap-section" style="background: var(--color-bg-panel);">
  <!-- Slides up over the hero -->
</section>
Class / attributeElementPurpose
.overlap-precedingAny blockSets overflow: visible explicitly. Prevents accidental overflow:hidden clipping the overlap.
.overlap-section<section>Full section that slides up. Sets z-index, negative margin, rounded top corners. Consumer sets background.
.overlap-card<div>Card variant — floats upward with Tier-1 entry animation (opacity + translateY + shadow grow).
data-scroll-reveal.overlap-cardOpts into the shared IntersectionObserver. Adds .is-visible on first entry.
TokenDefaultPurpose
--overlap-section-radiusvar(--radius-xl)Top corners on .overlap-section
--overlap-section-offset60pxHow far .overlap-section bleeds upward
--overlap-card-radiusvar(--card-radius)Border radius on .overlap-card
--overlap-card-offset80pxHow far .overlap-card bleeds upward
--overlap-card-duration0.7sEntry animation duration

Sine wave — details

<section style="position: relative; padding-bottom: var(--wave-height);
             --color-section-next: var(--color-bg-panel);">
  <div class="section-wave" aria-hidden="true">
    <svg viewBox="0 0 1200 80" preserveAspectRatio="none">
      <!-- depth layer first (SVG paints in document order) -->
      <path d="M0,40 C300,80 900,0 1200,40 L1200,80 L0,80 Z"
            fill="var(--color-section-next)" opacity="0.4"/>
      <path d="M0,45 C300,85 900,5 1200,45 L1200,80 L0,80 Z"
            fill="var(--color-section-next)"/>
    </svg>
  </div>
</section>
TokenDefaultPurpose
--wave-height80pxSVG rendered height and required parent padding-bottom
--color-section-nextvar(--color-bg-panel)Fill colour — set on the parent to match the destination background

Change amplitude by adjusting control-point Y values. Equal and opposite offsets (C300,80 900,0) produce a symmetric wave. To reverse direction, place .section-wave at the top of the next section with transform: scaleY(-1).

Convex arc — details

<!-- Convex (dome) -->
<section style="position: relative; padding-bottom: var(--arc-height);
             --color-section-next: var(--color-bg-panel);">
  <div class="section-arc" aria-hidden="true">
    <svg viewBox="0 0 1200 80" preserveAspectRatio="none">
      <path d="M0,80 Q600,0 1200,80 Z" fill="var(--color-section-next)"/>
    </svg>
  </div>
</section>

<!-- Concave (bowl) — redrawn path, always static -->
<div class="section-arc section-arc--concave" aria-hidden="true">
  <svg viewBox="0 0 1200 80" preserveAspectRatio="none">
    <path d="M0,0 Q600,80 1200,0 L1200,80 L0,80 Z" fill="var(--color-section-next)"/>
  </svg>
</div>
TokenDefaultPurpose
--arc-height80pxSVG rendered height and required parent padding-bottom
--color-section-nextvar(--color-bg-panel)Fill colour — set on the parent to match the destination background

Organic blob — details

<section style="position: relative; padding-bottom: var(--blob-height);
             --color-section-next: var(--color-bg-panel);">
  <div class="section-blob" aria-hidden="true">
    <svg viewBox="0 0 1200 100" preserveAspectRatio="none">
      <!-- depth layer first (SVG paints in document order) -->
      <path d="M0,60 C150,25 200,80 400,55 C600,28 700,75 900,50 C1050,28 1150,65 1200,45 L1200,100 L0,100 Z"
            fill="var(--color-section-next)" opacity="0.45"/>
      <path d="M0,65 C150,30 200,85 400,60 C600,33 700,80 900,55 C1050,33 1150,70 1200,50 L1200,100 L0,100 Z"
            fill="var(--color-section-next)"/>
    </svg>
  </div>
</section>
TokenDefaultPurpose
--blob-height100pxSVG rendered height and required parent padding-bottom
--color-section-nextvar(--color-bg-panel)Fill colour — set on the parent to match the destination background

Diagonal cut — details

Three implementation approaches in increasing precision. Only approach 2 ships as a CSS class; approaches 1 and 3 are code patterns to adapt.Note: clip-path (approach 1) clips box-shadowon the same element — use the ::before approach if the section needs a shadow.

Approach 1 — clip-path polygon

Simplest option. GPU-accelerated. Does not work if the section needs box-shadow.

<section style="clip-path: polygon(0 0, 100% 0, 100% 92%, 0 100%);">
  <!-- Section content -->
</section>

Approach 2 — ::before skew (shipped as .section-skew)

The section element itself must have no background — only the ::beforepseudo-element provides the background. In supporting browsers the angle flattens viaanimation-timeline: view(). Static fallback shows var(--diagonal-angle).

<section class="section-skew"
         style="padding: 80px 0; --color-section: var(--color-bg-panel);">
  <!-- Content sits horizontally above the skewed ::before background -->
</section>

Approach 3 — SVG triangle

Precise pixel control. Place the SVG at the bottom of the preceding section.

<section style="position: relative; padding-bottom: 60px;">
  <svg viewBox="0 0 1200 60" preserveAspectRatio="none"
       style="display:block; position:absolute; bottom:0; left:0; width:100%; height:60px;"
       aria-hidden="true">
    <polygon points="0,60 1200,0 1200,60" fill="var(--color-section-next)"/>
  </svg>
</section>
TokenDefaultPurpose
--diagonal-angle-3degStatic display angle (approach 2). Keep between -2deg and -5deg. Scroll-driven animation always starts at -5deg regardless.
--color-sectionvar(--color-bg)Background fill for the ::before (approach 2). Set to match the section's intended background.
--color-section-nextvar(--color-bg-panel)SVG fill for the triangle approach. Set to match the destination section background.

Direction carries meaning: negative skewY (bottom-left to top-right) reads as forward momentum. Be consistent within a page.

Separator

Content-level horizontal rules — distinct from section-transition patterns. Three purpose-named variants cover the full range from tight list spacing to editorial accent moments.

Hairline — between list items


Section — between content groups


Decorative — editorial scene break


Full reference — Separator

Variants

ClassDescription
.hairline1px subtle rule — tight spacing; between list items or dense content
.section-divider2px standard rule — medium spacing; between content groups
.decorative2px accent-color rule, 32px wide — editorial scene break

Anatomy

<!-- Tight rule between list items -->
<hr class="hairline">

<!-- Standard rule between content groups -->
<hr class="section-divider">

<!-- Short accent rule for editorial breaks -->
<hr class="decorative">

<!-- Purely decorative — hide from assistive tech -->
<hr class="decorative" aria-hidden="true">

Token reference

Separator uses semantic tokens directly — no Tier-3 override layer.

TokenDefaultPurpose
--separator-section-weight2pxSection divider border thickness
--color-border-subtlecolor-mix() at 12% / 25%Hairline color
--color-borderLight: --in3-ash · Dark: --in2-slateSection divider color
--color-accentLight: --fo1-fern · Dark: --fo0-gladeDecorative rule color
--space-sm0.75remHairline vertical margin
--space-lg2remSection divider vertical margin
--space-md1.5remDecorative rule vertical margin

CSS reference

Shipped in dist/farn-components.css. Load alongside farn.css or farn-tokens.css.

hr { border: none; }

.hairline        { border-top: 1px solid var(--color-border-subtle); margin: var(--space-sm) 0; }
.section-divider { border-top: var(--separator-section-weight) solid var(--color-border); margin: var(--space-lg) 0; }
.decorative      { border-top: 2px solid var(--color-accent);        width: 32px; margin: var(--space-md) 0; }

Quotes

Three patterns for surfacing cited text, featured statements, and testimonials. All variants consume --quote-* Tier-3 tokens — retheme in one place.

Blockquote

The platform is never finished. The moment you ship it, the next set of needs is already forming at the edges.

— Teresa Torres, Continuous Discovery Habits

Pullquote

Good platform thinking starts with the developer, not the platform.

Jacob Lueg Tiedemann

Testimonial card

Working this way changed how I think about internal tooling entirely. It stopped being infrastructure and started being product.

Maya RodriguezVP Engineering, Acme
Full reference — Quotes

Variants

VariantElementClassUse
Blockquote<blockquote>.quoteInline prose citation with left accent border. Body text uses Fraunces italic.
Pullquote<figure>.quote-pullCentred featured statement. Accent rule generated via ::before. Caption via <figcaption>.
Testimonial<figure>.quote-testimonialTestimonial card with decorative quote mark, quote text, and an attribution row (avatar + name/role).

Anatomy

<!-- Blockquote -->
<blockquote class="quote">
  <p>Quote body text here.</p>
  <footer>— Author Name, <cite>Source Title</cite></footer>
</blockquote>

<!-- Pullquote -->
<figure class="quote-pull">
  <p>Featured statement goes here.</p>
  <figcaption>Attribution name</figcaption>
</figure>

<!-- Testimonial card — .card provides the shell (bg, border, radius, flex-column) -->
<figure class="card quote-testimonial">
  <p class="quote-testimonial-text">Quote text here.</p>
  <figcaption class="quote-testimonial-attribution">
    <div class="quote-testimonial-avatar" aria-hidden="true">AB</div>
    <div class="quote-testimonial-meta">
      <span class="quote-testimonial-name">Full Name</span>
      <span class="quote-testimonial-role">Title, Company</span>
    </div>
  </figcaption>
</figure>

Token reference

TokenDefaultPurpose
--quote-border-width3pxBlockquote left accent bar width
--quote-border-color--color-accentBlockquote left accent bar color
--quote-text-color--color-text-secondaryBody text color
--quote-footer-color--color-text-tertiaryFooter / cite text color
--quote-padding--space-sm 0 --space-sm --space-lgBlockquote inset
--quote-margin-bottom--space-lgSpacing below all quote variants
--quote-opsz16Fraunces optical size for blockquote body text
--quote-pull-rule-width32pxPullquote decorative rule width
--quote-pull-rule-height2pxPullquote decorative rule height
--quote-pull-rule-color--color-accentPullquote rule color
--quote-testimonial-border--color-border-subtleBorder color override (card default is transparent)
--quote-testimonial-padding--space-lgPadding override (card default is --space-md)
--quote-testimonial-gap--space-smGap between quote mark, text, and attribution row
--quote-testimonial-mark-color--color-accentDecorative opening quote mark color
--quote-testimonial-mark-size3remDecorative opening quote mark font size
--quote-testimonial-avatar-size2.5remAvatar circle diameter
--quote-testimonial-avatar-bgcolor-mix(fern 15%)Avatar circle background tint
--quote-testimonial-attribution-gap--space-smGap between avatar and name/role block
--quote-testimonial-attribution-top--space-xsExtra top margin on attribution row (adds to parent gap)

CSS reference

Shipped in dist/farn-components.css. Load alongside farn.css or farn-tokens.css.

.quote {
  border-left: var(--quote-border-width) solid var(--quote-border-color);
  padding: var(--quote-padding);
  margin-bottom: var(--quote-margin-bottom);
  font-family: var(--font-display);
  font-style: italic;
  font-variation-settings: 'opsz' var(--quote-opsz);
  color: var(--quote-text-color);
}

.quote-pull { text-align: center; padding: 0 var(--space-xl); }
.quote-pull::before { /* accent rule — width/height/color via tokens */ }
.quote-pull p { font-family: var(--font-display); font-variation-settings: 'opsz' 20; }

.quote-testimonial { /* compose with .card — adds border-color, padding, gap overrides */ border-color: var(--quote-testimonial-border); padding: var(--quote-testimonial-padding); gap: var(--quote-testimonial-gap); }
.quote-testimonial::before { content: '\201C'; font-family: var(--font-display); font-variation-settings: 'opsz' 72; font-size: 3rem; line-height: 0.8; color: var(--quote-testimonial-mark-color); }
.quote-testimonial-text { font-family: var(--font-display); font-variation-settings: 'opsz' 20; font-style: italic; font-size: 1rem; }
.quote-testimonial-attribution { display: flex; align-items: center; gap: var(--quote-testimonial-attribution-gap); }
.quote-testimonial-avatar { width: var(--quote-testimonial-avatar-size); border-radius: var(--radius-full); }
.quote-testimonial-name { font-weight: 600; color: var(--color-text); }
.quote-testimonial-role { font-size: 0.75rem; color: var(--color-text-secondary); }

A site-wide footer shell — logo, tagline, navigation slot, copyright bar, and secondary links — styled by --footer-* Tier-3 tokens. Works on any background; pair with data-theme="dark" for the standard inverted treatment.

Live demo: the .footer component is rendered at the bottom of this page. Scroll down to see the logo, tagline, navigation links, and footer bar in action.
Full reference — Footer

Anatomy

<footer class="footer" data-theme="dark">
  <div class="footer-inner">
    <div class="footer-top">
      <div class="footer-logo">
        <svg ...></svg> <!-- your logo icon -->
        Site Name<span>.</span>
      </div>
      <p class="footer-tagline">Short tagline.</p>
      <nav class="footer-nav">
        <a href="/docs">Docs</a>
        <a href="/components">Components</a>
      </nav>
    </div>
    <hr class="footer-hr">
    <div class="footer-bar">
      <p class="footer-copy">&copy; 2026 Your Name</p>
      <div class="footer-links">
        <a href="/about">About</a>
        <a href="/contact">Contact</a>
      </div>
    </div>
  </div>
</footer>
ClassElementPurpose
.footer<footer>Outer shell — top/bottom padding via --footer-padding-top/bottom
.footer-inner<div>Max-width container with horizontal padding
.footer-top<div>Logo + tagline + nav row
.footer-logo<div>Flex row for logo icon and wordmark
.footer-logo-icon<svg>56px tall logo icon — set on the <svg> or <img>
.footer-tagline<p>Short one-liner below logo — --color-text-secondary, max-width 48ch
.footer-nav<nav> or <div>Horizontal link row — --color-text-tertiary with hover/focus states
.footer-hr<hr>Subtle horizontal rule — --color-border-subtle
.footer-bar<div>Copyright + secondary links row — space-between layout
.footer-copy<p>12px copyright line — --color-text-tertiary
.footer-links<div>Flex row for legal/secondary links — 12px, --color-text-tertiary

Theme

Add data-theme="dark" to get the standard dark-footer treatment — all semantic tokens flip automatically without any extra CSS.

<footer class="footer" data-theme="dark"> … </footer>

The footer slot accepts any content via <slot /> (Astro) or direct children — use .footer-nav with plain <a> elements for navigation links.

Token reference

TokenDefaultPurpose
--footer-padding-topvar(--space-3xl)Space above footer content
--footer-padding-bottomvar(--space-xl)Space below footer content

CSS reference

Shipped in dist/farn-components.css. Load alongside farn.css or farn-tokens.css.

.footer {
  padding: var(--footer-padding-top) 0 var(--footer-padding-bottom);
}
.footer-inner {
  max-width: var(--width-content); margin: 0 auto; padding: 0 var(--space-lg);
}
.footer-top { margin-bottom: var(--space-xl); }
.footer-logo {
  display: flex; align-items: center;
  font-size: 1.75rem; font-weight: 700; text-decoration: none; color: var(--color-text);
}
.footer-logo span { color: var(--color-accent); }
.footer-logo-icon { height: 56px; width: auto; margin-right: 0.45em; }
.footer-tagline { font-size: 14px; color: var(--color-text-secondary); max-width: 48ch; }
.footer-nav { display: flex; gap: var(--space-md); flex-wrap: wrap; }
.footer-nav a { font-size: 14px; color: var(--color-text-tertiary); text-decoration: none; }
.footer-nav a:hover        { color: var(--color-text-secondary); }
.footer-nav a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 3px; }
.footer-hr { border: none; border-top: 1px solid var(--color-border-subtle); margin: var(--space-xl) 0; }
.footer-bar {
  display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);
}
.footer-copy { font-size: 12px; color: var(--color-text-tertiary); }
.footer-links { display: flex; gap: var(--space-md); flex-wrap: wrap; }
.footer-links a { font-size: 12px; color: var(--color-text-tertiary); text-decoration: none; }
.footer-links a:hover        { color: var(--color-text-secondary); }
.footer-links a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 3px; }