# Macros

Recipes / patterns from `core/macros.css`. Macros answer
**"what does this element do / look like?"** — distinct from layout
primitives, which answer **"where do my children go?"**.

Layer: `slashed.macros` (between `slashed.layout` and
`slashed.utilities`). Macros may compose with primitives and components,
but a single-property utility still wins on the same selector.

All tokens listed below live in `core/tokens.macros.css` and ship
in the optimal bundle.

---

## `.sf-prose`

Long-form text column with automatic vertical rhythm.

```html
<div class="sf-prose">
  <h2>Heading</h2>
  <p>Paragraph one.</p>
  <p>Paragraph two — automatically spaced.</p>
  <ul><li>Bullet</li><li>List</li></ul>
</div>
```

Styles direct children with `margin-block-start: var(--sf-prose-paragraph)`,
restores native list bullets, lays out `figure`, `figcaption`, `table`,
`video`. Inside a `.sf-prose`, drop `.sf-not-prose` on a region to opt out.

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-prose-paragraph` | `var(--sf-content-gap)` | gap between block children |

## `.sf-not-prose`

Resets `.sf-prose` styling inside the marked subtree (block margins,
list styling, figure margins, image rounding). Useful for embedded
widgets in long-form text.

```html
<div class="sf-prose">
  <p>Article body.</p>
  <div class="sf-not-prose">
    <!-- a card or widget that should not inherit prose rules -->
  </div>
  <p>More body.</p>
</div>
```

---

## `.sf-flow`

Heydon Pickering's "lobotomized owl". Every flow child after the first
gets `margin-block-start: var(--sf-flow-space)`.

```html
<div class="sf-flow">
  <p>One</p>
  <p>Two</p>     <!-- has top margin -->
  <p>Three</p>   <!-- has top margin -->
</div>
```

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-flow-space` | `var(--sf-content-gap)` | distance between consecutive children |

Override per element: `style="--sf-flow-space: 2rem"`.

---

## `.sf-truncate`

Single-line ellipsis. The element must have a finite inline-size for
the overflow to actually clip.

```html
<div class="sf-truncate" style="max-inline-size: 20rem">
  This very long sentence will be ellipsised after one line.
</div>
```

The ellipsis is supplied by `text-overflow: ellipsis`; no token is needed.

---

## `.sf-line-clamp-2`, `.sf-line-clamp-3`, `.sf-line-clamp-N`

Multi-line clamp with ellipsis. The fixed-count variants hardcode the
line count; `-N` reads `--sf-line-clamp`.

```html
<p class="sf-line-clamp-2">Two-line clamp.</p>
<p class="sf-line-clamp-3">Three-line clamp.</p>
<p class="sf-line-clamp-N" style="--sf-line-clamp: 5">N-line clamp.</p>
```

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-line-clamp` | `3` | line count for `.sf-line-clamp-N` |

`-webkit-line-clamp` is a de-facto standard despite the prefix — every
modern engine implements it. The unprefixed `line-clamp` from CSS
Overflow 4 is set alongside for forward compatibility.

---

## `.sf-equal-height`

Forces flex children to share the tallest child's height.

```html
<div class="sf-equal-height">
  <div>Short</div>
  <div>Two<br>lines</div>      <!-- becomes 3-line tall -->
  <div>Three<br>lines<br>here</div>
</div>
```

Pairs naturally with grid layouts where rows already stretch.
Use this when working in flex contexts.

---

## `.sf-scroll-shadow`

Top + bottom mask gradient that fades content near the edges of a
vertically scrolling container. Pure CSS — no scroll listener.

```html
<div class="sf-scroll-shadow" style="block-size: 12rem">
  <p>Lots of content…</p>
</div>
```

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-scroll-shadow-size` | `2rem` | fade depth on both edges |

Pairs nicely with overflow-y'd lists, code blocks, or tall card bodies.

---

## `.sf-scroll-snap`

Vertical scroll-snap container. Each direct child snaps to start.
For horizontal snap, use the `.sf-reel` layout primitive.

```html
<div class="sf-scroll-snap" style="block-size: 100dvh">
  <section style="block-size: 100dvh">A</section>
  <section style="block-size: 100dvh">B</section>
  <section style="block-size: 100dvh">C</section>
</div>
```

---

## `.sf-overflow-fade`

Gradient mask fade for overflowing content. Pure alpha mask — respects
the element's actual background. Reads `--sf-mask-scrim-start` /
`--sf-mask-scrim-end` for fade depth.

All directions are **physical** (not logical): `--right` always fades
the physical right edge regardless of writing direction. For logical
inline-end fading in RTL layouts, add a `:dir(rtl)` override that
swaps the gradient direction. Modifier classes work standalone and
target a specific edge or axis:

| Class | Fades |
|---|---|
| `.sf-overflow-fade` | right edge (default) |
| `.sf-overflow-fade--right` | right edge (explicit) |
| `.sf-overflow-fade--left` | left edge |
| `.sf-overflow-fade--top` | top edge |
| `.sf-overflow-fade--bottom` | bottom edge |
| `.sf-overflow-fade--block` | top **and** bottom edges |
| `.sf-overflow-fade--inline` | left **and** right edges |

```html
<!-- Single edge -->
<div class="sf-overflow-fade" style="white-space: nowrap">
  <span class="tag">…</span>
  <span class="tag">…</span>
</div>

<!-- Both inline edges (left + right) -->
<div class="sf-overflow-fade--inline" style="white-space: nowrap">…</div>

<!-- Bottom-only (e.g. truncated prose preview) -->
<div class="sf-overflow-fade--bottom" style="max-height: 6rem">…</div>
```

---

## `.sf-no-tap-highlight`

Suppresses the WebKit/Android grey tap-highlight overlay on
interactive elements where it conflicts with the framework's own
`:active` / hover treatment.

```html
<a class="sf-no-tap-highlight" href="…">…</a>
```

Just sets `-webkit-tap-highlight-color: transparent`. No tokens.

---

## `.sf-render-lazy`

Skips rendering (layout + paint) for offscreen content until it scrolls
near the viewport — a large initial-render win on long pages (product
grids, long articles).

```html
<section class="sf-render-lazy">…repeated long-page section…</section>

<!-- Override the reserved placeholder size -->
<section class="sf-render-lazy" style="--sf-content-intrinsic-size: 800px">…</section>
```

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-content-intrinsic-size` | `500px` | placeholder block size fed to `contain-intrinsic-size` |

Sets `content-visibility: auto` plus `contain-intrinsic-size: auto
var(--sf-content-intrinsic-size)`. The `auto` keyword caches each section's
last-rendered size; the token reserves space before first render so the scrollbar
and scroll position stay stable. Unsupported engines (Safari < 18) ignore both
declarations and render normally.

> Deliberately not paired with `will-change` — it pre-creates compositing layers
> and usually hurts performance when applied broadly; set it from JS only while an
> element is actively animating.

---

## `.sf-tabular-nums`

Fixed-width digits so numbers align in vertical columns (price lists,
totals, invoices, dashboards).

```html
<table class="sf-tabular-nums">…numeric columns…</table>
```

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-font-numeric` | `tabular-nums` | figure style (`core/tokens.css`) |

Sets `font-variant-numeric: var(--sf-font-numeric, tabular-nums)`. The
same token is applied to `<input type="number">` in `optional/forms.css`.
Universal browser support.

---

## `.sf-drop-shadow-xs` / `.sf-drop-shadow-s` / `.sf-drop-shadow-m` / `.sf-drop-shadow-l` / `.sf-drop-shadow-xl`

Applies `filter: drop-shadow(...)` — unlike `box-shadow`, this follows the
actual alpha shape of the element (PNG cutouts, SVG icons, transparent
logos) instead of hugging the bounding box.

```html
<img class="sf-drop-shadow-m" src="logo.svg" alt="">
<svg class="sf-drop-shadow-xl" ...>...</svg>
```

Tokens:

| Token | What it controls |
|---|---|
| `--sf-drop-shadow-xs` / `-s` / `-m` / `-l` / `-xl` | drop-shadow value consumed 1:1 by the matching class (`core/tokens.css`) |

`--sf-text-shadow-xs` / `-s` / `-m` / `-l` / `-xl` mirror the same five-step
scale for `text-shadow` (no dedicated utility class — apply the token
directly via `text-shadow: var(--sf-text-shadow-l)`), matching the
`box-shadow` ramp's `xs`..`2xl` rhythm at the small/large ends.

---

## `.sf-surface` and `.sf-surface--*`

Contextual background + auto-contrast text color. Apply to any element
to give it a filled surface with accessible foreground text.

### Generic surface: any color

`.sf-surface` (no modifier) takes **any** color through `--sf-surface-color`
(default: `--sf-color-base`) — including palette shades — and derives the
background, an auto-contrast foreground (the same lightness-flip used by
`--sf-color-text--on-*`), and the full contextual token set:

```html
<!-- a palette tint surface -->
<section class="sf-surface" style="--sf-surface-color: var(--sf-color-primary-100)">
  Text, headings, links and borders re-derive automatically.
</section>

<!-- any arbitrary color works -->
<aside class="sf-surface" style="--sf-surface-color: oklch(0.35 0.09 200)">…</aside>
```

`--sf-surface-color` inherits: a nested `.sf-surface` picks up the outer
surface's color unless it sets its own. The derivation requires relative
color syntax; outside the `@supports` gate only the background applies.

### Named variants

10 precomputed variants: `primary`, `secondary`, `tertiary`, `action`,
`neutral`, `inverse`, `success`, `warning`, `info`, `danger`.

```html
<div class="sf-surface--primary">White text on primary bg</div>
<div class="sf-surface--danger">White text on danger bg</div>
<div class="sf-surface--neutral">Auto-contrast text on neutral bg</div>
```

Each variant sets `background` to the resolved color token
(`--sf-color-{name}`) and `color` to the matching on-color token
(`--sf-color-text--on-{name}`). No extra tokens needed.

### Author your own surface

Both forms rebind the same contextual token set so descendants adapt with no
extra classes. To make any BEM component a conforming surface, copy the
contract (shown here seeded from a custom foreground/background pair):

```css
@supports (color: oklch(from red l c h)) {
  .my-component {
    /* your component's background */
    --my-bg: var(--sf-color-primary);
    background: var(--my-bg);

    /* pick or derive the foreground for your background */
    --my-fg: var(--sf-color-text--on-primary);
    color: var(--my-fg);

    --sf-color-text:              var(--my-fg);
    --sf-color-heading:           var(--my-fg);
    --sf-color-link:              oklch(from var(--my-fg) l calc(c + 0.08) h);
    --sf-color-link--hover:       var(--my-fg);
    --sf-color-link--underline:   oklch(from var(--my-fg) l c h / 0.5);
    --sf-color-text--subtle:      oklch(from var(--my-fg) l c h / 0.70);
    --sf-color-text--placeholder: oklch(from var(--my-fg) l c h / 0.45);
    --sf-color-text--disabled:    oklch(from var(--my-fg) l c h / 0.30);
    --sf-color-border:            oklch(from var(--my-fg) l c h / 0.20);
    --sf-color-border--subtle:    oklch(from var(--my-fg) l c h / 0.12);
    --sf-color-border--strong:    oklch(from var(--my-fg) l c h / 0.35);
    --sf-shadow-color:            oklch(from var(--my-bg) 0.15 c h);
  }
}
```

In most cases the simpler route is to set `--sf-surface-color` on
`.sf-surface` and let the framework do this for you.

---

## `.sf-text-gradient`

Fills text with a gradient (default `--sf-gradient-primary`).

```html
<h2 class="sf-text-gradient">Gradient headline</h2>

<!-- Override per-instance -->
<h2 class="sf-text-gradient" style="background-image: var(--sf-gradient-secondary)">
  Secondary gradient
</h2>
```

`background-clip: text` and `color: transparent` are applied unconditionally (no
`@supports` gate). The unprefixed form is used — it is supported at the framework
floor (Safari 18.0+, Chrome 125+, Firefox 129+). Browsers that don't clip
backgrounds to text render the text invisible — an accepted consequence of the
support floor.

> **Known limitation:** selecting gradient text reveals the clipping boundary
> (text appears to lose colour during selection) in most browsers.

---

## `.sf-link-external`

Adds an external-link indicator glyph after the link text via `::after`,
plus a screen-reader-only accessible name for that glyph using the CSS
alt-text syntax (`content: <value> / <string>`) — assistive tech reads it
appended after the link's own text; sighted users only see the glyph.

```html
<a href="https://example.com" class="sf-link-external">Example</a>
```

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-link-external-marker` | `" \2197"` (arrow with leading space) | glyph appended after link text |
| `--sf-link-external-label` | `"opens in a new window or external site"` | accessible name read by screen readers for the glyph |

Disable globally (both the glyph and its accessible name):

```css
:root {
  --sf-link-external-marker: "";
  --sf-link-external-label: "";
}
```

Localise the announcement by overriding `--sf-link-external-label` inside a
`:lang()` block or a locale-scoped selector.

### Automatic detection by domain

`.sf-link-external` is opt-in — you add the class per link. To apply the
same treatment automatically to every cross-origin link, write your own
rule keyed to your site's own host (CSS selectors can't read a custom
property, so the host has to be a literal string) and exclude links that
wrap an image, since those carry their own accessible name:

```css
a[href^="http"]:not([href*="example.com"]):not(:has(img, svg, picture))::after {
  content: var(--sf-link-external-marker) / var(--sf-link-external-label);
  display: inline-block;
  font-size: 0.85em;
  text-decoration: none;
}
```

Swap `example.com` for your own domain. Page builders and CMS integrations
(e.g. the WordPress plugin) can generate this rule with the site's real
host injected server-side.

---

## `.sf-link--subtle`, `.sf-link--reverse`

Opt-in link underline affordances. They don't change link **colour**
(that stays the auto-contrast `--sf-color-link`); they only toggle the
underline.

```html
<a href="…" class="sf-link--subtle">Underline appears on hover/focus</a>
<a href="…" class="sf-link--reverse">Underlined at rest, clears on hover</a>
```

| Class | Resting state | Hover / focus |
|---|---|---|
| `.sf-link--subtle` | no underline | underline (currentcolor) |
| `.sf-link--reverse` | underline | no underline |

`.sf-link--subtle` suits dense link lists (nav, footers) where a
permanent underline is noisy; the hover underline preserves the
affordance at the moment of interaction.

The base `a:link` underline geometry is tokenised (added for parity with
the colour tokens):

| Token | Default | What it controls |
|---|---|---|
| `--sf-link-underline-offset` | `0.15em` | distance from the text baseline |
| `--sf-link-underline-thickness` | `auto` | underline stroke width (`auto` = font metrics) |

---

## `.sf-scrim`

Darkening overlay for text placed over a background image, so the text
clears contrast **without dimming the whole picture**. Apply to a
positioned wrapper holding the image + text; the scrim paints as a
`::before` gradient between them (the macro sets `position: relative`
and `isolation: isolate` itself).

```html
<div class="sf-scrim sf-scrim--bottom" style="position:relative">
  <img src="hero.jpg" alt="" style="display:block; inline-size:100%">
  <div class="sf-scrim__content" style="position:absolute; inset-block-end:0">
    <h2>Legible headline</h2>
  </div>
</div>
```

Media children (`img`, `picture`, `video`, `svg`, `canvas`) are left in
the background layer so the scrim darkens them; only non-media children
are lifted above the scrim. Position your content over the image with
`position: absolute` (as above) or use a CSS `background-image` on the
wrapper instead of an `<img>` child.

**Media background + overlay + stacked content, with no manual
`z-index`:** compose with the [`.sf-bg-layer`](layout.md) layout primitive
instead of a plain `<img>` — it auto-fills the parent (`position:
absolute; inset: 0`) and already composes under `.sf-scrim` by design,
so `img`/`video` background, gradient, and content stack correctly with
zero extra positioning:

```html
<div class="sf-scrim sf-scrim--bottom">
  <img class="sf-bg-layer" src="hero.jpg" alt="">
  <div class="sf-scrim__content">
    <h2>Media background, scrim, and content — no z-index to manage</h2>
  </div>
</div>
```

This is the framework's answer to "background media + overlay + stacked
content" — no dedicated macro needed on top of `.sf-bg-layer` + `.sf-scrim`.

Variants:

| Class | Effect |
|---|---|
| `.sf-scrim--bottom` | gradient darkest at the bottom (default — text at bottom) |
| `.sf-scrim--top` | gradient darkest at the top |
| `.sf-scrim--full` | even wash over the whole image |

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-scrim-color` | `oklch(0 0 0 / 0.55)` | the dark stop |
| `--sf-scrim-direction` | `to top` | gradient direction |
| `--sf-scrim-gradient` | `linear-gradient(var(--sf-scrim-direction), var(--sf-scrim-color), transparent)` | the whole composed gradient (override for multi-stop / radial) |

The lift selector is `.sf-scrim > :not(img, picture, video, svg, canvas) { z-index: 1 }`.

---

## `.sf-surface-bg`

A reusable, **named background surface** preset. Where `.sf-surface` sets a
solid colour and `.sf-scrim` adds a single gradient overlay, `.sf-surface-bg`
bundles a full background into one class you can name once and reuse: base
colour fallback + image/gradient/pattern + sizing + an optional overlay
layered above the image + an optional animation.

The class itself is inert — it only composes the `--sf-surface-bg-*` tokens.
Define a preset by setting those tokens on a scope, then apply the class:

```css
.hero-surface {
  --sf-surface-bg-image:     url("/hero.avif");
  --sf-surface-bg-overlay:   var(--sf-scrim-gradient);   /* reuse the scrim */
  --sf-surface-bg-animation: sf-pan 40s linear infinite; /* optional */
}
```

```html
<section class="hero-surface sf-surface-bg">…</section>
```

The overlay is the **first** `background-image` layer, so it paints *above*
the image (use it for a scrim/tint over a photo). Builds on the existing
scrim + gradient tokens rather than new infrastructure; for a blurred backdrop
compose `.sf-scrim` or a `filter` on top.

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-surface-bg-color` | `transparent` | base colour fallback (below the image) |
| `--sf-surface-bg-image` | `none` | the image / gradient / pattern layer |
| `--sf-surface-bg-overlay` | `none` | overlay layered above the image (e.g. a scrim) |
| `--sf-surface-bg-size` | `cover` | `background-size` |
| `--sf-surface-bg-position` | `center` | `background-position` |
| `--sf-surface-bg-repeat` | `no-repeat` | `background-repeat` |
| `--sf-surface-bg-attachment` | `scroll` | `background-attachment` |
| `--sf-surface-bg-animation` | `none` | optional `animation` shorthand |

---

## `.sf-text-protect`

Lighter-weight alternative to `.sf-scrim`: protects text legibility over
a busy image **without** a darkening layer, using a soft shadow halo
behind the glyphs. Apply directly to the text element.

```html
<h2 class="sf-text-protect">Readable over a photo</h2>
```

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-scrim-text-shadow` | `0 1px 3px oklch(0 0 0 / 0.6)` | the protective text shadow |

---

## `.sf-entrance--*`

Scroll-driven entrance animations. Elements animate into view as they
enter the viewport.

6 variants: `fade`, `fade-up`, `fade-down`, `fade-left`, `fade-right`,
`scale-up`.

```html
<div class="sf-entrance--fade-up">Fades in while sliding up</div>
<div class="sf-entrance--scale-up">Scales from 95% to 100%</div>
```

**How it works:** Uses `animation-timeline: view()` where supported
(Chrome/Edge 115+). In browsers without scroll-driven animation support
(Firefox, which keeps it behind a flag, and Safari), the class falls back
to a one-shot time-driven animation at `--sf-duration-slow`.

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-scroll-timeline-range-start` | `entry 0%` | when the animation begins |
| `--sf-scroll-timeline-range-end` | `cover 30%` | when the animation completes |

All entrance classes are gated by `prefers-reduced-motion: no-preference`;
when the user opts out of motion the animations are inert (no movement).

---

## `.sf-exit--*`

Scroll-driven exit animations — the symmetric counterpart of
`.sf-entrance--*`. Elements animate out as they leave the viewport.

6 variants: `fade`, `fade-up`, `fade-down`, `fade-left`, `fade-right`,
`scale-down`.

```html
<div class="sf-exit--fade-up">Fades out while sliding up as it leaves</div>
<div class="sf-exit--scale-down">Scales from 100% to 92% on exit</div>
```

**How it works:** Uses `animation-timeline: view()` where supported
(Chrome/Edge 115+). Unlike `.sf-entrance--*`, there is **no** time-driven
fallback: `animation-name` only applies inside
`@supports (animation-timeline: view())`. An unconditional one-shot exit
animation would fade the element out on load and leave it hidden forever
in engines without scroll-driven animation support — so those engines
just render the element normally, visible and static.

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-scroll-timeline-range-exit-start` | `cover 70%` | when the exit animation begins |
| `--sf-scroll-timeline-range-exit-end` | `exit 100%` | when the exit animation completes |

All exit classes are gated by `prefers-reduced-motion: no-preference`;
when the user opts out of motion the animations are inert (no movement).

Lives in `core/motion.css`, layer `slashed.motion`.

---

## `.sf-stagger`

Choreography helper: put it on a **parent** and every direct child receives
an incrementing `animation-delay`, so a group of time-based entrance
animations plays in sequence.

```html
<ul class="sf-stagger">
  <li class="sf-fade-in">First</li>
  <li class="sf-fade-in">Second</li>
  <li class="sf-fade-in">Third</li>
</ul>
```

`.sf-stagger` sets **only** the delay — each child still needs its own
time-based entrance animation (the fade / slide-in looping classes in
[motion.md](./motion.md)). A child without one carries an inert delay (a
no-op), so animating only some children needs no opt-out on the rest.

Tokens:

| Token | Default | What it controls |
|---|---|---|
| `--sf-stagger-step` | `75ms` | per-item delay increment |

Each child's delay is `index × --sf-stagger-step × --sf-motion-scale`. Where
`sibling-index()` is supported the index is unbounded; otherwise an 8-step
`:nth-child` ramp (covering a 4-column grid's first two rows) plateaus so
arbitrarily long lists still animate.

**Best paired with the time-based fade / slide-in looping classes** (see
[motion.md](./motion.md)), which stagger consistently everywhere. On the
scroll-driven path (`.sf-entrance--*`/`.sf-exit--*` under
`animation-timeline: view()`) the rhythm is `animation-range`, not
`animation-delay`, so stagger has no effect there — though `.sf-entrance--*`
does stagger in its time-based fallback on engines without `view()`, while
`.sf-exit--*` has no such fallback. Gated by
`prefers-reduced-motion: no-preference`.

Lives in `core/motion.css`, layer `slashed.motion`.
