WORK-655
ID:WORK-655Status:done

link wraps blocks, and card and bento-cell href render through it

Only card and bento-cell can make a whole region a link, each through its own href. Both stretched overlays are aria-hidden="true" tabindex="-1", so keyboard and screen-reader users cannot reach them. SPEC-160 D8 extends the existing link tag to wrap blocks, and both href attributes become shorthand that renders through it.

Priority:mediumComplexity:complexMilestone:v0.43.0Source:SPEC-160
claude/v0-43-0-review-ny4nl1 View source

Criteria completion

Criteria completion: 10 of 10 (100%) checked; history from Oct 26 to Oct 260%25%50%75%100%Oct 26Oct 26
Branches 3
History 4
  1. 373a468
    • ☑ `{% link %}` accepts block content and renders a real `<a>` around it when it holds no links, buttons or form controls
    • ☑ The choice between wrap and stretch is made in the identity transform, so links added later (entity auto-links, resolved `{% ref %}`) are seen; a test asserts that a `link` around content that gains an auto-link in `postProcess` renders the stretched form, with no nested `<a>`
    • ☑ The schema emits a documented marker for the engine (an element or attribute carrying `href`, with no `data-rune`), and the marker never reaches the rendered HTML
    • ☑ When the content is interactive, `link` renders a stretched `<a data-name="link">`, focusable and labelled by `aria-label` when given, else by the content's title slot (`aria-labelledby`). When the content is a single rune, the anchor goes inside that rune's root; when it is several blocks or plain prose, the engine wraps them in one positioned `<div>` that holds the anchor. Both cases are tested
    • ☑ `link` emits no `data-rune`, and a test asserts that a schema property inside a `link` still publishes
    • ☑ An empty `href` renders the content unchanged
    • ☑ `card`'s and `bento-cell`'s `href` render through `link` and are not deprecated. **The shorthand always takes the stretched form**, so the rune's root stays the grid item and its `>` child selectors keep matching (`bento-cell`'s `grid-column` / `grid-row` span and `> .rf-bento-cell__content` split rules would break under an outer or inner wrapping `<a>`)
    • ☑ Skeleton gets the shared rules: the stretched anchor's positioning (replacing `.rf-card__link` and `.rf-bento-cell__link` in their rune CSS) and, for the wrap form, a reset so a wrapped block does not inherit link colour and underline. Lumina's `.rf-card:has(.rf-card__link):hover` still matches, or is moved to the shared selector
    • ☑ The changeset says that linked cards and bento cells are now reachable by keyboard and screen readers, one tab stop per card
    • ☑ The CSS coverage test passes, and contracts are regenerated on both copies and the diff reviewed
    by bjornolofandersson
  2. 59a86fd
    Created (ready)by bjornolofandersson
  3. fa4981f
    Content editedby Claude
    plan: ADR-046 section's header grammar, WORK-659, and v0.43.0 refinement
  4. abc833f
    Content editedby Claude
    plan: v0.41.3, v0.42.0 and v0.43.0 milestones

Acceptance Criteria

  • {% link %} accepts block content and renders a real <a> around it when it holds no links, buttons or form controls
  • The choice between wrap and stretch is made in the identity transform, so links added later (entity auto-links, resolved {% ref %}) are seen; a test asserts that a link around content that gains an auto-link in postProcess renders the stretched form, with no nested <a>
  • The schema emits a documented marker for the engine (an element or attribute carrying href, with no data-rune), and the marker never reaches the rendered HTML
  • When the content is interactive, link renders a stretched <a data-name="link">, focusable and labelled by aria-label when given, else by the content's title slot (aria-labelledby). When the content is a single rune, the anchor goes inside that rune's root; when it is several blocks or plain prose, the engine wraps them in one positioned <div> that holds the anchor. Both cases are tested
  • link emits no data-rune, and a test asserts that a schema property inside a link still publishes
  • An empty href renders the content unchanged
  • card's and bento-cell's href render through link and are not deprecated. The shorthand always takes the stretched form, so the rune's root stays the grid item and its > child selectors keep matching (bento-cell's grid-column / grid-row span and > .rf-bento-cell__content split rules would break under an outer or inner wrapping <a>)
  • Skeleton gets the shared rules: the stretched anchor's positioning (replacing .rf-card__link and .rf-bento-cell__link in their rune CSS) and, for the wrap form, a reset so a wrapped block does not inherit link colour and underline. Lumina's .rf-card:has(.rf-card__link):hover still matches, or is moved to the shared selector
  • The changeset says that linked cards and bento cells are now reachable by keyboard and screen readers, one tab stop per card
  • The CSS coverage test passes, and contracts are regenerated on both copies and the diff reviewed

Approach

  • Today's overlays are built in packages/runes/src/tags/card.ts and plugins/marketing/src/tags/bento.ts, and positioned by packages/skeleton/styles/runes/card.css and bento.css.
  • Wrap and stretch are chosen in the engine because the engine is the first stage that sees links added by postProcess. This is a new kind of engine pass, over an element that is not a rune, which is why the item is rated complex.

References

  • SPEC-160: D8.
  • SPEC-145: D19 (name-resolution boundaries).

Resolution

Completed: 2026-10-26

Branch: claude/v0-43-0-review-ny4nl1

What was done

  • packages/runes/src/nodes.ts: linkTag, now the link entry of the tags map.
    • Inline it delegates to the link node (a plain <a>).
    • Around blocks it returns a <div data-link-href> marker (data-link-label from aria-label). It has no data-rune.
    • An empty href returns the children.
    • The Markdown link node is unchanged.
  • packages/transform/src/link-regions.ts (new): resolveLinkRegions. engine.ts runs it last in createTransform, with the set of runes configured interactive.
    • Wrap: <a data-link="wrap">.
    • Stretch: <a data-link="stretch" data-name="link">, inside a single rune's root with its __link class, or inside a <div data-link="host">.
    • Labelling for every stretched anchor: aria-label, else the host's title / headline / name slot or first heading through aria-labelledby (an id assigned if missing), else the host's text.
  • packages/runes/src/tags/common.ts: stretchedLink(), exported. card.ts and plugins/marketing/src/tags/bento.ts use it instead of the aria-hidden / tabindex="-1" anchor.
  • CSS:
    • packages/skeleton/styles/dimensions/link.css (new): wrap display, stretch positioning, and inner controls raised above.
    • packages/lumina/styles/dimensions/link.css (new): the wrap form's colour and underline reset, and the focus ring.
    • Imported from both index.css files and Lumina's base.css. The overlay rules are removed from card.css and bento.css.
  • Tests: packages/content/test/link-regions.test.ts (12).
  • Docs: site/content/runes/card.md (the focusable overlay, the output contract, "Linking any block").

Notes

  • Focus ring inside the box. The stretched link's ring is drawn inside it (outline-offset: -3px). card clips with overflow: hidden and the overlay fills exactly that box, so an outside ring was invisible. This showed up in a Chromium check: overlay hit-testing, inner link above it, Tab order, ring in light and dark.
  • "Interactive" covers: a, button, form controls, details / summary, iframe / audio / video / embed / object, label, tabindex, contenteditable, and any rune whose config says interactive.
  • Existing hooks unchanged. card and bento-cell keep data-name="link", so rf-card__link, Lumina's linked-card hover and the SPEC-090 guest-posture check are unchanged.
  • Existing content unchanged. Every existing {% link %} in site/content is single-line, which Markdoc parses as inline, so no existing output changes.
  • Attribute change. href is no longer required on the tag, because an empty one is now meaningful.
  • Contracts do not change, the SEO baseline does not move, and the site builds.