Skip to content

Docs & Tooltips System

show.fm treats documentation and in-app help as one system with mechanical drift protection. Five pieces, one contract: if a page, tooltip, or ledger row disagrees with the others, a build or test fails.

The five pieces

PieceLocationWhat it is
Public help centerdocs-public/src/User-facing VitePress site (docs.podcasterplus.com)
Internal docsdocs-internal/src/This site, for the dev team
Tooltip registrysrc/lib/tooltips/registry.tsTyped map of concept-tooltip copy, each entry optionally linking to a help-center page
Coverage ledgerdocs/planning/docs-ledger.mdOne row per capability: surfaces, plan gating, target pages, tooltip keys, status
Lockstep CIsrc/lib/tooltips/__tests__/registry.test.tsTests that fail when the pieces disagree

What the CI enforces (and when it runs)

The lockstep tests run with the normal unit suite (pnpm test, or scoped: pnpm test src/lib/tooltips). They fail when:

  1. Any docs path the app links to has no matching file under docs-public/src (the cleanUrls mapping: /booking/availabilitydocs-public/src/booking/availability.md). That means registry learnMore paths and the onboarding checklist's (CHECKLIST_DOCS_PATHS in src/lib/onboarding/checklist.ts), collected in the test's APP_DOCS_PATHS. A new source of docs links must be added there, or it is unguarded: checking only the registry is what let an IA move break two checklist links silently.
  2. A docs-public or docs-internal page the ledger claims does not exist on disk
  3. The ledger's tooltip-key column and the registry disagree in either direction: a key no row claims, or a row claiming a key the registry no longer has
  4. A key rendered by <HelpTooltip> carries a learnMore (unreachable link)
  5. Registry copy breaks the standards (em-dashes, missing final period, stray whitespace)

Separately, both VitePress builds fail on broken internal links (ignoreDeadLinks: false in docs-public; never re-enable it, and never add app-facing patterns to docs-internal's allowlist). Run them with pnpm build inside each docs directory.

Tooltips: two renderers, one registry

Both read the same TOOLTIP_REGISTRY entry. The difference is interaction and whether the docs link renders:

RendererTriggerShowsUse for
HelpTooltipHover, on a passive <span>Copy only (plus sr-only copy for assistive tech)Badges, status chips (e.g. the Starter badge)
HelpPopoverClick/tap on a visible info iconCopy + Learn more linkForm fields, meters; anything a touch user needs

HelpTooltip keys are typed TooltipOnlyKey, which excludes every entry that has a learnMore. A hover tooltip cannot host a clickable link, so pairing the two produces a docs path that CI keeps alive for a link no user can reach. Adding a learnMore to a key a tooltip renders is a compile error; move the call sites to HelpPopover first.

HelpTooltip renders a passive trigger, not bits-ui's default <button>, so these badges are not focusable and the tooltip copy is duplicated as sr-only text. That is deliberate: the badges sit inside rows that are themselves clickable, where a real button both swallows the row's click and nests an interactive element inside a role="button". See "Passive tooltip triggers".

Rules (the full rulebook is the header comment in registry.ts):

  • The registry holds concept copy only: things a first-time user would not know. Copy is one to two sentences, defines the concept, never restates the visible label, no dynamic values, no em-dashes.
  • Plain icon-button labels ("Edit", "Back to episodes") use the ui/tooltip primitive directly with the label inline; they do not get registry keys.
  • Never use a native title= attribute for a tooltip. The only acceptable title= uses are full-value reveals on truncated text and native validation hints.
  • A tooltip is never the sole carrier of required information; icon-only controls keep an aria-label or sr-only name independent of the tooltip.
  • Coverage is pragmatic: a tooltip earns its place only where a concept genuinely needs explaining at the point of use. If the surface already explains itself, no tooltip.

Adding a concept tooltip

  1. Add an entry to TOOLTIP_REGISTRY in src/lib/tooltips/registry.ts (key format: domain.kebab-case, e.g. billing.storage-cap). Add learnMore: '/section/page' only if you are rendering it with HelpPopover: tooltip-rendered keys must not have one.
  2. Wire it at the surface: <HelpPopover key="your.key" label="Visible field name" /> next to a label, or wrap a badge in <HelpTooltip key="your.key">...</HelpTooltip>. Import from $lib/tooltips.
  3. Add the key to the matching capability row in docs/planning/docs-ledger.md (create the row if the capability is new).
  4. pnpm test src/lib/tooltips fails until the learnMore page exists and the ledger agrees. Component tests that render a tooltip need the provider: renderWithTooltipProvider from $test/render (bits-ui throws "Context not found" without it, since the real provider lives in the root layout).

Passive tooltip triggers

bits-ui's Tooltip.Trigger assumes a real control. The props it hands the child snippet always carry button semantics (type="button", tabindex="0", disabled, data-state), and mergeProps applies them after the call site's own, so they cannot be overridden inline.

Spread those onto a non-interactive element and you get: a tab stop per element (one per workflow step, per presence avatar), an invalid type on a <div>/<img>, and a data-state that makes row click delegation testing closest('[data-state]') swallow the click.

So: spreading trigger props onto anything that is not a button or link goes through passiveTriggerProps (src/lib/tooltips/passive.ts), which strips exactly those four and keeps the handlers, id, aria-describedby and ref attachment:

svelte
<Tooltip.Trigger>
	{#snippet child({ props })}
		<span {...passiveTriggerProps(props)}>…</span>
	{/snippet}
</Tooltip.Trigger>

A passive trigger is not focusable, so the call site must carry the information itself as visible text, an alt, or an sr-only span. When the tooltip is the only way to get the information, use a real <button> and no helper (see SavePill.svelte, where the pill is a button and bits-ui's focusable trigger is correct).

Writing or updating docs

The binding content standards live where the content lives, and are non-negotiable:

  • Public: docs-public/CLAUDE.md §4. Reader has never seen the product; friendly, never condescending; short paragraphs; numbered steps with exact bold UI labels (Settings → Calendar); no screenshots; no em-dashes; never invent a detail, verify every label/flow/limit against source; existing pages are untrusted and get corrected.
  • Internal: docs-internal/CLAUDE.md §5. Technical and reference-oriented; cite real source paths and migration IDs; Mermaid for diagrams; frontmatter required; sidebar registration in .vitepress/config.ts is manual.

The three commands

The authoring workflows are the /docs-* commands in .claude/commands/. Pick by the shape of the change:

CommandUse whenCore discipline
/docs-feature <name>A feature shipped or changed and its docs must follow (new or existing). Covers all five artifacts in one pass: public pages, internal pages, tooltip keys, cross-links, ledger rowFact sheet before prose: harvest verified facts with file:line citations from code first; every written claim must trace to one
/docs-audit [area|all] [--fix]You suspect drift, or want pre-release confidence. Verifies each page's claims against sourceCURRENT / STALE / WRONG verdict per page with evidence; report-only unless --fix
/docs-sweep <change>One change touches many pages: a rename, a plan-limit change, an IA or URL moveEnumerate every touchpoint across all artifact classes BEFORE editing; found / edited / justified counts must reconcile

Rules shared by all three: standards are read from this page's references (never duplicated into the commands), unverifiable details are left out rather than guessed, ignoreDeadLinks is never touched, and nothing is committed or deployed by the command.

Whatever route you take, the definition of done per change is the same:

  1. Page(s) written or corrected, claims verified against source (grep quoted labels back)
  2. Tooltip keys added/updated if the change introduces a concept
  3. The ledger row updated (pages, keys, dated status)
  4. Gates green: both docs builds, pnpm test src/lib/tooltips, em-dash grep clean on touched files

The ledger contract

docs/planning/docs-ledger.md is the single source of coverage truth and the map of how capabilities interconnect (surfaces ↔ gating ↔ public pages ↔ internal pages ↔ tooltip keys). Do not maintain coverage status anywhere else; status tables in other files rot. When you touch a feature, read its ledger row first: it tells you every artifact your change must keep in sync. The "Flags for Dan" section at the bottom collects product issues found during doc verification; add to it rather than silently fixing or ignoring.

History

Built in the 2026-07-11 first pass. Program plan: docs/planning/plans/2026-07-11-docs-tooltips-program.md; verified fact sheets from the sweep live in docs/planning/research/docs-program-sweeps/.

Internal documentation - Not for public distribution