Relationships
Related 8
Branches 3
Summary
A drawer is refrakt's only disclosure surface: a modal panel for reading. Several things want a lighter one, shown beside what opened it and dismissed by clicking elsewhere: a ref that previews its target, a pin on an image, a glossary term, a tooltip-style annotation. SPEC-078 reserved preview="popover" for this and said refrakt has no popover primitive yet.
This spec adds {% popover %}. It has the drawer's shape: a region with an id and no trigger of its own, opened by any reference to it. It is built on the platform's popover attribute and CSS anchor positioning, so its core behaviour needs no JavaScript. Pins on images are its next consumer, specified separately.
What exists, measured
The drawer (SPEC-060):
{% drawer id %}renders<section id="drawer-{id}">and registers a page-scopeddrawerentity (drawer-pipeline.ts:58-115).- Triggers are any
a[data-target-type="drawer"]pointing at#drawer-{id}. The behaviour turns the section into a<dialog>and opens it withshowModal()(behaviors/drawer.ts:180-288). - Opening one closes any other. Without JS the section is visible in the page, and the link scrolls to it.
preview exists on ref and file-ref only, with one value, drawer. A ref with preview="drawer" emits a sentinel, and a hoist builder appends a drawer at the end of the page, holding the expand of the entity. An authored drawer with the same id wins (drawer-pipeline.ts:317-456).
No short rendering of an entity exists. A ref is either a link, or the entity's full content through expand. Page entities carry data.description from frontmatter (runes/src/config.ts:2542). EntityRegistration has no summary field.
Tooltip-like things today:
annotate variant="tooltip"reveals its note on:hoveronly, with no keyboard or touch path (skeleton/styles/runes/annotate.css:39-50).sidenote variant="tooltip"is a bordered box, not a tooltip.map's pin popup is built by concatenating HTML strings without escaping (behaviors/src/elements/map.ts:229-235).
Nothing in packages/ or plugins/ uses the popover attribute or anchor positioning.
The platform. The Popover API is Baseline. CSS anchor positioning works in all three engines since Firefox 147 (January 2026). Opening on hover (interestfor, popover="hint") appears to be Chromium-only.
Decisions
D1 — {% popover %} is a region with an id, like a drawer
The walls are {% ref "ultramarine" preview="popover" /%}. {% popover id="ultramarine" title="Ultramarine" %} {% color value="#2563EB" /%} A deep, saturated blue. [See the colour](/colours/ultramarine) {% /popover %}
- It renders
<section id="popover-{id}" popover="auto" data-rune="popover">, with adata-name="title"label whentitleis given, and a close button (popovertarget="popover-{id}" popovertargetaction="hide", labelled fromcore.popover.close). - It registers a page-scoped
popoverentity, as a drawer does, so a ref resolves to it anddata-target-type="popover"marks the trigger. - It stays where it was authored in the source; the
popoverattribute hides it until opened, and an open popover renders in the top layer, so its position in the page does not matter visually. - Attributes:
id(required),title. A popover without atitlereportspopover-unnamed, because the surface needs an accessible name and its triggers can be many.
D2 — the trigger is a button, and the browser does the rest
A reference resolved to a popover renders <button type="button" popovertarget="popover-{id}" data-target-type="popover">, with the reference's label as its text. Natively, with no script:
- a click, tap, Enter or Space opens it, and the button exposes its expanded state;
- Esc, a click outside, or opening another popover closes it (
popover="auto"); - a popover opened from inside another stays open with it, as nested disclosures should.
A button and not a link: the trigger opens something on this page and has no destination of its own. The destination, if there is one, is a link inside the popover. The same was already true of preview="drawer", whose trigger points at #drawer-{id}.
D3 — preview picks the surface
preview | Surface | For |
|---|---|---|
| absent | a link | going there |
popover | small, non-modal, beside the trigger | a glance |
drawer | large, modal | reading |
details, sidenote | still reserved |
ref and file-ref gain popover. Anything later that is a reference (a pin, a glossary term) takes the same vocabulary, so the trigger never decides the surface. A pin that opens a drawer is preview="drawer" and needs nothing new.
D4 — a generated popover holds the entity's summary
A ref with preview="popover" and no authored popover of that id gets one generated and hoisted, as drawers are today. Its body is a summary of the entity, which is new: a summary-pending placeholder, resolved after the registry is complete, as expand-pending is.
The summary is generic for now:
- the title, from
data.titleordata.name, as the popover's label; - the description, from
data.description, when there is one; - a link to the entity's URL, labelled from
core.popover.open, unless the entity is on the same page.
An entity with no description gets a title and a link, and reports popover-summary-thin as info, suggesting an authored popover or preview="drawer". An entity type supplying its own summary, for example a colour showing its swatch, is a stated later extension, most likely as a composition. It is not designed here.
file-ref with preview="popover" holds its snippet, capped at a maximum height and scrolled.
D5 — placement is CSS
- Beside the trigger. The popover is anchored to the button that opened it, below it by default, with
position-try-fallbacksflipping it above or to the side when there is no room. - One popover, many triggers. A popover referenced from three places is anchored to the one that opened it. That cannot be fixed at build time, so a small behaviour sets
position-anchoron open. Without it, the popover opens in the browser's default position, centred, which is usable. - A sheet on narrow screens. Below the
smbreakpoint the same element is drawn as a bottom sheet, through CSS only. It is not a third surface. - Never clipped. The top layer is outside every
overflow: hiddenancestor, so a popover opened from a card's media zone or a carousel slide is not cut off.
D6 — click to open; no hover
Hover fails on touch screens and for keyboard users, and the platform's hover path is not cross-browser yet. Hover is reserved, for when interestfor ships in all three engines, and would be an addition to click, never a replacement.
D7 — a popover is short by contract
A popover holds a glance: paragraphs, lists, images, value elements, an {% actions %} row. Two reports keep it from becoming a small drawer:
- a heading in a popover body reports
popover-too-largeas a warning, and points topreview="drawer"; - more than five top-level blocks reports the same as info. The threshold is a starting point, to be tuned against real content.
A drawer or another {% popover %} declared inside a popover body is an error (popover-nested-region). A reference to one inside it is fine.
D8 — what degrades, and how
- No JavaScript. Everything in D2 still works. Only D5's anchoring to the opening trigger is lost; the popover opens centred.
- No Popover API (a browser from before 2024). The
popoverattribute is ignored, so the popover renders as ordinary content where it is in the page (at the end, when generated), and the trigger does nothing. This matches how a drawer degrades without JS. - Print. An open popover is not printed and a closed one is hidden. Authored popover content is therefore missing from print; see the open questions.
D9 — the later consumers are recorded, not done
| Consumer | What it gains |
|---|---|
| pins on images | a positioned reference with the D3 vocabulary; a legend list as the fallback |
annotate variant="tooltip" | keyboard and touch access, by moving onto popover |
| a glossary | auto-linked terms opening a popover |
map's pin popup | the same surface, and the unescaped HTML removed |
Each is its own change. None is required for this spec to ship.
Open questions
- Print. Should authored popover bodies print as endnotes after the section, numbered at their triggers? That is useful for references, and noise for UI-like popovers.
- A summary per entity type. D4 leaves this open. The likely shape is a composition the entity's plugin registers as its summary template.
aria-haspopupand roles. The platform exposes the button's expanded state. Whether a popover holding links should also carryrole="dialog"(non-modal) is to be checked with a screen reader during implementation, not decided here.
Acceptance Criteria
{% popover id title %}renders apopover="auto"section with a label and a close button, and registers a page-scopedpopoverentity; a missingtitlereportspopover-unnamed- A reference resolved to a popover renders a
<button popovertarget>withdata-target-type="popover", and opening, closing, Esc and outside clicks work with JavaScript disabled refandfile-refacceptpreview="popover"; an authored popover of the same id wins over a generated one- A generated popover holds the generic summary (title, description, link), resolved after the registry; an entity without a description reports
popover-summary-thin - The popover is anchored to the trigger that opened it, flips when there is no room, and renders as a bottom sheet below
sm - A popover opened from inside an
overflow: hiddencontainer is not clipped - A heading in a popover body reports
popover-too-large; a drawer or popover declared inside a popover body reportspopover-nested-region - The close and open labels come from
core.popover.*keys - The
popoverrune is documented besidedrawer, with the D3 table
References
- SPEC-060: the drawer, whose shape this follows.
- SPEC-078: the
previewattribute and its reserved values. - SPEC-173: media zones and carousels, where pins will sit.
- SPEC-090: interaction posture; a trigger in a linked card is a lifted control.
- SPEC-166: value elements, which a popover may hold.