Skip to content

Component roadmap ​

This page covered what came after the eight core primitives: what was in scope, what was not, in what order, and what had to be decided first. All of it has landed — see the mapping table in design-source.md.

It is a plan, not a promise. Amend it when the answer changes, and move a component's name into design-source.md's mapping table as it lands.

All five waves are done. What follows is kept as the record of what was decided and why — the deviations especially, since each is a place where a later reader would otherwise wonder whether the port simply missed something.

Beyond the design source ​

HabLogo is the first component that came from outside the design project, so none of the wave planning below accounts for it and the mapping table in design-source.md has no row for it — there is no design-source component to map.

It exists because the brand marks are library assets in the same sense the tokens are: every app needs them, at the same weights, in both themes. It ships the four marks as path data plus a small interpolation engine, and a habRenderLogoSvg escape hatch for the places a Vue component cannot reach — a favicon, an e-mail template, a PNG export.

Two things about it are open rather than settled, both in README's TO DISCUSS: the stroke normalization applied to alternis and nova, and the size ladder, which comes from no token because none of --hab-icon-* or --hab-control-* describes a brand mark.

Scope is bigger than the file count ​

The design project has 24 component directories left, but twelve of them export more than one component. It is closer to 36 public components:

One directoryExports
SkeletonSkeleton, Spinner, ProgressBar, EmptyState
DataTableDataTable, DataCell, RowAction
NavItemNavRow, NavItem, NavSection
ListList, ListItem
ModalModal, FormActions
TabsTabs, SubNav
StatusStripStatusStrip, StatusNote
TimelineTimeline, TimelineItem
AccordionAccordion, AccordionItem
ColorPickerColorPicker, ColorGrid, Palette (data)
PermissionMatrixPermissionMatrix, MatrixCell
InspectorStackInspectorStack, InspectorPanel

Each one needed its own directory, spec, docs page and class-check matrix, which is why the plan counted exports rather than directories. The final figure came in higher again — 46 public components across 33 directories — as single specs turned out to owe more than one export.

Decisions ​

All three blockers are settled. Each creates token work before its components can be built — see The second token pass.

1. The raw palette — ship all 65 ​

Decided: port the full ramp. Nine hues × seven steps, plus light and dark. ColorPicker gets the control as designed, and Avatar can hash a name to a hue so the same person is the same color everywhere with nothing stored.

This reverses TOKENS.md's advice, which was written for the eight primitives — none of which reads a raw ramp. Worth naming what it costs, because that advice existed for a reason: the package now carries two color vocabularies, one semantic and one literal. The rule that keeps them apart:

A color that carries meaning the system defines — status, tone, accent — is a semantic token and not a choice. The ramp is only for color that is genuinely the user's or the data's: entity kind, tag, category, avatar.

A component reaching for --hab-blue-darken-2 where --hab-info exists is a bug.

Unblocks: ColorPicker, ColorGrid, Palette, Avatar.

2. Navigation tokens — add them now ​

Decided: port the --hab-nav surface family and build the rail. The dark-rail sub-theme joins the token set.

Unblocks: NavDrawer, NavRow, NavItem, NavSection.

3. Overlay layering — three layers and a scrim ​

Decided: a three-step scale plus a scrim color.

LayerTokenHolds
Modal--hab-z-modalModal, and the scrim beneath it
Popover--hab-z-popoverHabSelect, HabMenu, Popover, Tooltip
Toast--hab-z-toastToastHost

Values come from the design source rather than being invented — Modal exists there, so a scrim color and a stacking order already do too.

The behavioural rules were deferred to implementation. Wave 3 is built, so here are the answers.

reka-ui gives every overlay a place on one dismissable-layer stack, and that single mechanism settles all three questions:

  • Esc dismisses the topmost layer only. A popover open inside a modal closes the popover and leaves the modal standing, which is what a user expects and what a hand-rolled document.addEventListener('keydown') per component cannot do — the design source's Modal adds exactly that listener, so its Esc would have closed the dialog out from under an open popover.
  • A popover inside a modal works, including its outside-click dismissal, because each layer tracks pointer-events for itself rather than the modal claiming them all. Its stacking did not, and that is the correction below: the popover layer now ranks ABOVE the modal, because every overlay portals to <body> and a select opened inside a dialog is a sibling of that dialog's sheet, not a child of it. Shipped under the popover layer and was painted behind the dialog that opened it, in every dialog with a select in it.
  • Two modals can coexist technically — they nest as layers. Do not use it. A second dialog means the first asked a question the user could not answer without leaving it, which is a flow problem, not a layering one.

--hab-z-scrim and --hab-z-modal stay separate tokens because the primitive portals the overlay and the sheet as siblings, not parent and child. A single shared value would leave their order to DOM sequence.

Verified against the primitive's model and the specs, not in a browser. The nesting cases need the browser test layer, and Esc ordering in particular is not something happy-dom can answer — it has no real focus.

That caveat collected: the behavioral answers held, the stacking one did not, and nothing but a browser was ever going to say so. A select inside a modal renders its list behind the sheet whatever the dismissable-layer stack does, and the specs assert the class name — z-(--hab-z-popover) — which stayed correct while the value behind it was wrong.

Out of scope ​

InspectorStack / InspectorPanel. Panels docked beside a canvas, one per level of the current selection, with the stack flipping edges. That is one product's workspace furniture, not a shared primitive — it fails the playbook's first test. If a second app grows a canvas inspector, revisit.

Folded into another component ​

PermissionMatrix / MatrixCell become a DataTable variant. Not a separate component: permissions down and roles across is a table with grouped rows and boolean cells, and giving it its own name made it look like a different kind of thing than it is.

Done. This departs from the design source's structure rather than its design. DataTable grew the two features it needed:

  • Row grouping. The matrix groups rows by area, with a labelled, icon-marked group header spanning the table. DataTable takes a flat rows array today.
  • A boolean cell treatment. MatrixCell's filled accent disc for granted and a dash for not, as a cell kind rather than a component consumers assemble.

Both are useful beyond permissions — grouped rows especially — so the cost bought something.

It also turned out to be an accessibility upgrade rather than just less code. The source builds the matrix from CSS grid, where every mark is an unlabelled disc inside an unnamed cell: a screen reader gets nothing at all from it. A real <table> gives each mark a row header and a column header, and the boolean cell spells its state out in text — so the grid is announced as "Delete sessions, Administrator, Granted" instead of as silence. Folding it in was the accessible choice, not merely the tidy one.

Order ​

Dependencies and shared anatomy drive this more than size does.

Wave 1 — small and self-contained · Done ​

SectionHeader · IconTile · MetaList · Meter · Timeline + TimelineItem · Skeleton + Spinner + ProgressBar + EmptyState

No new decisions, no new tokens, real surface area quickly. Spinner and ProgressBar can reuse the hab-spin and hab-indeterminate keyframes already in base.css, and ProgressBar is the same 3px indeterminate bar HabCard's loading already draws — worth sharing rather than reimplementing.

Wave 2 — the notice family · Done ​

Banner · StatusStrip + StatusNote

The design source is explicit that these two and ToastHost share one anatomy — tone border, tone icon, tinted surface — and differ only by placement and elevation. Build them adjacent for the same reason Badge and Chip were: the shared parts should be found once. Both are in-flow, so neither needs the layering decision.

Wave 3 — overlays · Done ​

Popover · Tooltip · Modal + FormActions · ToastHost

Popover first: it is the general case the others specialise, and the design source defines the rest by contrast with it — a tooltip holds nothing interactive, a menu's content is actions, a select's content is options, a modal is the escalation that traps focus and scrims.

Wrapping the primitives paid for itself twice here, in both cases fixing a contract defect rather than just saving code:

  • Tooltip was unreachable by keyboard. The source listens for mouseenter and mouseleave only, and its own documentation says so: "invisible on touch and to keyboard users."
  • Modal's destructive variant is now an alertdialog. tone="error" swaps the primitive family rather than suppressing a dialog's dismissal, so assistive tech announces the consequence on open instead of waiting to be explored.

ToastHost gained three things a bare aria-live container cannot do: F8 reaches the stack from anywhere (the only way a keyboard user can act on a toast at all), timers pause while the pointer or focus is inside it, and background: true marks a toast the user did not set off so it waits its turn.

It also owns no list — toasts arrive as a prop and leave on dismiss. The queue stays in the consuming app rather than in a module-level singleton this package would hide.

Wave 4 — collections · Done ​

ListRow · List + ListItem · Accordion + AccordionItem · Tabs + SubNav · DataTable + DataCell + RowAction

DataTable last as the largest. The genuinely hard piece was List's native HTML5 drag, which does both reorder-within and drag-out to external targets.

happy-dom cannot produce a real drag sequence, so the pointer path does ship unverified and is flagged as such on the docs page. What closed the gap is that native drag has no keyboard equivalent at all — so the contract's reorderable list could not be reordered without a mouse. HabList adds Alt+ArrowUp / Alt+ArrowDown on a focused row, with a live-region announcement per move, and that half is asserted. The half a test can honestly cover turned out to be the half that was missing.

Two more things settled here:

  • density, one word for the whole family. The source says dense (a boolean) on List and Accordion but density: 'default' | 'compact' on DataTable. Unified on density, the spelling that names the row tokens. It lives in src/types.ts — the first genuinely shared public type, added only once a second component needed it.
  • reka's TabsIndicator is deliberately unused. It measures offsetWidth, so it renders nothing without layout: its classes would never reach check:classes, and a renamed token there would silently drop the underline — the failure mode this repo has shipped three times. The underline is a border on the active trigger whose color changes while its width does not, which is what the contract actually asked for.

Wave 5 — unblocked by the decisions above · Done ​

Avatar · ColorPicker + ColorGrid + Palette · NavDrawer + NavRow + NavItem + NavSection · ChipInput

These need the second token pass first, not a decision: Avatar and ColorPicker want the ramp, the nav family wants the --hab-nav surfaces.

ChipInput was never blocked technically — it builds on HabInput's box, which exists — but it is large, it is the design system's one sanctioned exception to the 34px control grid (the box grows downward as chips wrap), and reka's TagsInput and Combobox both partly fit.

That question is settled: Combobox. The contract needs a filtered menu and chips that Backspace removes, and the two primitives cannot nest — each wants to own the input element and the value. Combobox's half is the hard half: filtering, the portalled listbox, roving focus, the full combobox/listbox ARIA wiring. What TagsInput would have contributed is chips, which are markup, and "Backspace on an empty field removes the last chip", which is four lines.

Three more things settled in this wave:

  • HabNavRow is not shipped. The source has it as a second copy of NavItem differing only in label-as-prop versus label-as-children — having already consolidated a third copy that had drifted inside the drawer. In Vue a label prop with an overriding default slot is one component covering both call shapes. One implementation was the point the source was making; this finishes it.
  • The palette ships as data as well as tokens, so the hex values are duplicated: a picker has to render swatches during SSR and in a test, where getComputedStyle cannot resolve a custom property. tests/palette.spec.ts re-parses theme.css in both directions, so a ramp edit cannot leave the swatches showing the old color while every component using the token shows the new one.
  • --hab-gray-darken-4 is excluded from the pickable set. It exists as a token but is one step past every other ramp because it is the dark UI surface value, not a color anyone should choose for an entity.

The second token pass · Done ​

All three decisions landed in the token layer as one piece of work:

From decisionAdds
Palette65 ramp tokens, both themes, plus the semantic-vs-literal rule in theming.md
Navthe --hab-nav surface family
Layering--hab-z-modal, --hab-z-toast, --hab-scrim

Every value comes from the design source's own styles.css — the token source of truth — rather than being invented here.

Wave 3 added three more, --hab-modal-sm|md|lg (420 / 560 / 760px), mapped into Tailwind's --container-* namespace so they read as max-w-modal-md. They are the design source's own dialog widths; the alternative was three arbitrary values in a lookup table, which is what rule 5 exists to prevent.

reka-ui coverage ​

Wrapping a primitive is the default (rule 7), and the precedent from HabSelect applies: where the design source's hand-rolled keyboard model differs from the primitive's, the model is a decision to make explicitly rather than a detail to port.

ComponentPrimitive
AccordionAccordion (5 parts) — done
ModalDialog / AlertDialog — done, and it uses both
PopoverPopover (7 parts) — done
TooltipTooltip (6 parts) — done
TabsTabs — done, minus TabsIndicator (see above)
ToastHostToast (8 parts) — done
ChipInputCombobox — done; wave 5 says why not TagsInput
ProgressBarhand-rolled — an omitted aria-valuenow is the
indeterminate signal, which the primitive hides
Avatarhand-rolled — no image, so no load or fallback
state, which is all that primitive manages
NavDrawerhand-rolled — a rail of links is not a menu bar
Logohand-rolled — an SVG brand mark has no behaviour
for a primitive to manage

AlertDialog was worth noting for Modal's tone="error", and it is what shipped: the tone swaps the whole primitive family through a component map, rather than rendering a Dialog with its dismissal suppressed. Suppression would have matched the design source's behaviour but not its intent — only the real alertdialog role gets the contents announced on open.

Two loose ends ​

Tabs docks to a PageHeader that lives in packs/notitia/ — a product pack, not core. One of its documented relationships points outside the shared library, so either PageHeader is miscategorised or Tabs needs a different anchor here.

RowAction shipped in wave 4 with DataTable, but the design source's own Select/Menu previews use it as HabMenu's trigger. The menu's canonical trigger does not exist yet; HabButton with icon-only stands in.

Proprietary — internal use only.