Skip to content

HabInspectorStack ​

Panels stacked sideways next to a canvas, one per level of the current selection. Standing furniture: it does not overlay the canvas and it does not dismiss on an outside click.

Canvas. The stack never overlays this — it is standing furniture.
Claim intake

What the workspace is about. Always mounted, never closable.

Policy
d_8c21ab04e3f1
Root

The panel you just opened sits next to the thing it describes.

All changes saved
The base holds the window edge; detail lands between it and the canvas. Flip, and the whole stack mirrors.
vue
<HabInspectorStack v-model:side="side" flip-label="Move the panels to the other side">
	<template #base>
		<HabInspectorPanel title="Claim intake" :width="260">…</HabInspectorPanel>
	</template>

	<HabInspectorPanel
		v-if="selected"
		title="Policy"
		subtitle="d_8c21ab04e3f1"
		closable
		close-label="Close the properties panel"
		@close="selected = null"
	>
		…
	</HabInspectorPanel>
</HabInspectorStack>

The rules it enforces ​

The stack owns ordering, not the panels. The base slot is always mounted and always outermost, flush against the anchored edge — it is what the workspace itself is about, so an empty canvas still has an edge that means something and the base never moves as the selection changes. Detail panels grow inward, away from that edge, so the panel you just opened sits next to the thing it describes.

Nothing counts depth. Order comes from the list, which is why a fifth panel behaves like the second. A depth number measured against a maximum is silently wrong at the layer past it.

Flipping mirrors the stack, base included. It is one flex-direction swap plus the stack's own order, so the whole thing re-docks to the other side of its host row rather than sliding along it. Sliding strands the base in the middle.

Every panel is the same shell — header on one fixed 56px metric, scrolling body, optional footer. A stack of panels with different chrome reads as separate applications docked side by side.

Hosting it ​

Give the stack a definite height and put it in a flex row beside the canvas:

vue
<div class="flex h-full">
	<div class="min-w-0 flex-1 overflow-hidden"><!-- canvas --></div>
	<HabInspectorStack>…</HabInspectorStack>
</div>

The canvas needs all three of flex-1, min-width: 0 and overflow: hidden: without min-width: 0 a flex item refuses to shrink below its content and the canvas pushes the stack off screen. The stack is flex: none and sets its own order, so it needs nothing from you but the row.

Width, and who remembers it ​

Canvas
Overview

Fixed at 220.

Properties

Resizable, declared at 35% , min 25% . Now 35% .

Terminology

Deliberately not resizable — a term is a term, there is no more or less of it to see.

Drag the handle, or focus it and use the arrow keys. A percentage stays a percentage; a fixed panel need not be resizable at all.

A width is a number of pixels or a percentage string. A percentage is resolved against the flex row the stack shares with the canvas — not against the stack, which is sized by its own children, and a percentage against a shrink-to-fit container resolves to auto.

It stays a percentage. Resize the window and a 30% panel is still 30%, where a stored pixel count would freeze at whatever 30% was the day it was dragged. A panel declared in pixels likewise keeps reporting pixels, so a fixed 400 panel does not quietly become a proportional one after its first drag.

resizable is per panel. A panel whose content has no more or less of itself to show should not offer a width its content cannot use.

Habitus never persists a width. v-model:width reports it and the host decides:

vue
<HabInspectorPanel
	:width="width"
	min-width="25%"
	resizable
	resize-label="Resize the properties panel"
	@update:width="(w) => (width = w) && localStorage.setItem('properties.width', String(w))"
/>

A kit that reached for localStorage itself could not be server-rendered, could not be tested in isolation, and would own a key it never named.

Body padding, and when to take it off ​

Canvas
Activities

A list-shaped body wants the panel's edges. Toggle flush and watch where the row hover bands stop.

The body is padded 16px by default, and dense tightens it to 12. Neither is right for a body that is a list of rows — an accordion, a tree, a table. Padding insets the whole list, so rows cannot reach the panel's edge, every hover band and selection fill stops short at both sides, and a nested set's indent is measured from the wrong place.

flush takes the padding off entirely and hands the inset to the rows:

vue
<HabInspectorPanel title="Activities" flush>
	<HabAccordion multiple flush>…</HabAccordion>
</HabInspectorPanel>

This is the design source's own shape. Its boards specify a full-bleed body and expect rows to carry the panel's own inset themselves — the column the header's left padding and the footer's already sit on — so a flush body lines up rather than going its own way. That column is 16px here where the boards draw 20; see design-source.md. HabAccordion has the same lever for the same reason, and the two are meant to be used together: a flush accordion inside a padded panel is inset twice, 16px from the panel plus 16 of its own, with the fills still stopping short.

Set it on the stack and every panel takes it; set it on a panel to override. It is independent of dense — that one makes the padding tighter, this one removes it — and it wins where both are set, there being no padding left for a density to apply to.

Leave it off for a form-shaped body. A stack of fields wants the panel's padding, and nothing in it has a fill that needs to reach an edge.

Saying what the panel is about ​

Canvas
Elements
Remind the reviewer
Emit Notification · Interactables
The tile spans both header lines, so the subtitle stays under the title

The leading slot sits left of the title and subtitle and spans both, usually with a HabIconTile in the entity's colour: a panel about one node of a graph can show what kind of node it is before you read its name. It is its own column rather than part of the title, so the subtitle stays aligned under the title, and the icon is not part of the panel's accessible name. The collapsed rail leaves it out.

The header surface ​

Canvas
Claim intake
Policy
d_8c21ab04e3f1
All changes saved
Terminology
Three headers is a lot of header. On the raised surface they read as one band across the stack; flat, each panel states its own top edge.

By default the header is flat and a rule alone separates it from the body, which is what the design source specifies. raisedHeader puts it on --hab-surface-2 — the surface the footer already uses — so the panel's chrome reads as one band top and bottom, and three headers side by side read as one band across the stack.

Set it on the stack and every panel takes it; set it on a panel to override.

It is surface-2 rather than a neutral wash on purpose. --hab-neutral-ghost is the hover token, and the header's own icon buttons hover with it — a standing background painted the same colour is the "one token, two jobs" mistake this design system has already had to unpick twice. If the band reads too faint, the lever is that token's value, not a different token here.

Saving ​

Policy
d_8c21ab04e3f1
Saving…
A write against the PANEL's own subject. A write against one row reports itself on the row — covering the panel for one checkbox reads as though the panel had gone away.

saving is for a write against the panel's own subject — renaming the thing the panel is about, saving the form it holds. blocking decides whether it also swallows input, and it does by default.

A write against one row reports itself on the row. Covering the whole panel for one checkbox reads as though the panel had gone away, and the row you just touched is exactly what you still need to see.

The wave is a HabSavingFrame round the header, body and footer. The resize handle sits outside it, so it stays reachable through a blocking save: resizing is not a write, and being unable to widen the panel you are waiting on is the opposite of helpful.

Collapsing to a rail ​

Canvas — this is what the rail buys back.
Properties

Still here, still the reason the rail is.

Terminology

Deepest, so nearest the canvas.

When the arithmetic would starve the canvas, the base pays — it still holds the window edge, so the rule that the base never moves survives intact.

Three panels and a rail eventually leave the canvas with nothing. collapsed spends the base panel to buy it back: 56px, one control to restore it, and the rail slot for whatever the panel can still usefully say. The base still holds the window edge, so the rule that it never moves survives.

The base rather than a detail panel, because the base is the layer you are not currently working in — its job at that moment is to say what else exists. The detail panels are the reason the third panel is open at all.

The panel never collapses itself. How wide the canvas is, and what its floor should be, is arithmetic only the host can do — so collapsed is bound, and the host decides when to spend it and when to give it back.

Accessibility ​

The panel is a section, named by its title when it has one, so it is exposed as a region. With no title it is deliberately not a landmark — a region with no accessible name is worse than none.

The resize handle is a window splitter: role="separator", focusable, with aria-valuenow in pixels. Arrow keys move it 16px, Shift+Arrow 64px, Home and End go to the bounds. A handle only a mouse can reach is not a handle.

Every control it renders is icon-only, so each takes a label — closeLabel, expandLabel, resizeLabel on the panel and flipLabel on the stack. Habitus warns once in development when one is missing, because an icon is not an accessible name.

A saving panel is aria-busy, and a blocking one is inert — the attribute, not a pointer-events trick, so the keyboard is stopped too.

What you are still responsible for: the stack does not manage focus when a panel closes. Move it somewhere deliberate, or it lands on <body>.

Not verified here. DOM order is always base → shallowest → deepest, and docked right the row is reversed, so visual order runs the other way. Both orders are coherent — the DOM follows the selection hierarchy — but which one a keyboard user should get is a judgement the browser test layer has to make, and this repo does not have one yet. The same goes for pointer drags, for a percentage re-resolving on a window resize, and for the wave.

API ​

Props ​

PropTypeDefaultDescription
defaultSide"left" | "right""right"The edge to start on when side is not bound.
densebooleanfalseTighter body padding for every panel that does not set its own dense.
flipControlbooleantrueRenders the dock-side flip control in the base panel's header. The stack owns flipping, so it owns the control. Turn it off when the host already offers one somewhere else — an editor toolbar, a keyboard shortcut — and drive side yourself; two affordances for one piece of state is the thing to avoid, not the toolbar placement.
flipLabelstringundefinedAccessible name for the flip control. Required when flipControl is on.
flushbooleanfalseFull-bleed bodies for every panel that does not set its own flush.
raisedHeaderbooleanfalseRaised headers for every panel that does not set its own raisedHeader.
side"left" | "right"undefinedThe edge the whole stack docks to. Bind with v-model:side to control it, or leave it unbound and let the stack own it from defaultSide.

Events ​

EventPayloadDescription
update:sidevalue: HabInspectorSide—

Slots ​

SlotBindingsDescription
baseanyThe panel that is always mounted, always outermost, and never gets a close control: what the workspace itself is about.
defaultanySelection-driven HabInspectorPanels, shallowest first.

Props ​

PropTypeDefaultDescription
blockingbooleantrueWhether a saving wave also swallows input.
closablebooleanfalseRenders a close control that emits close. Ignored on the base panel, which is always mounted and has nothing to close back to.
closeLabelstringundefinedAccessible name for the close control. Required when closable is on.
collapsedbooleanfalseCollapses the panel to a 56px rail: the header keeps one control to expand it again, and the body is replaced by the rail slot. Bind with v-model:collapsed. The panel never collapses itself — when to spend a panel to keep the canvas usable is arithmetic only the host can do, since it alone knows how wide the canvas is and what its floor should be.
densebooleanundefinedTighter body padding, for list-shaped contents.
expandLabelstringundefinedAccessible name for the expand control shown while collapsed.
flushbooleanundefinedFull-bleed body: the panel's own padding comes off, and what is inside carries its own inset instead. Reach for it whenever the body is a list of rows — an accordion, a tree, a table. Padding on the body insets the whole list, so rows cannot reach the panel's edge, hover bands and selection fills stop short at both sides, and a nested set's indent is measured from the wrong place. The design source specifies the body full-bleed and expects rows to carry the panel's own inset themselves — 16px, the column the header and footer already sit on, so a flush body lines up rather than going its own way. Independent of dense: that one makes the padding tighter, this one takes it away. flush wins where both are set, there being no padding left for a density to apply to.
maxWidthHabInspectorWidth"70%"Upper bound for a drag. Pixels or a percentage, independent of width.
minWidthHabInspectorWidth200Lower bound for a drag. Pixels or a percentage, independent of width. The default is pixels on purpose. A percentage default is a trap for a panel declared in pixels: :width="300" against a 20% floor is already under its own minimum on a 1600px row, so the first drag jumps it WIDER than it started. 200 sits below every panel width the design uses, so it binds only when someone really is dragging a panel down to nothing.
raisedHeaderbooleanundefinedPuts the header on --hab-surface-2 — the raised surface the footer already uses — so the panel's chrome reads as one band top and bottom. Off by default, because the design source paints the header flat and separates it with a rule alone. It is opt-in rather than a divergence: a stack of three panels is a lot of header, and a product that wants those headers to read as a continuous band across the stack should be able to say so without restyling every panel that ships.
resizablebooleanfalseGives the panel a drag handle on the edge facing the canvas. Per panel, not per stack: a panel whose content has no more or less of itself to show — a single term, a fixed form — should not offer a width its content cannot use.
resizeLabelstringundefinedAccessible name for the drag handle. Required when resizable is on.
savingbooleanfalseA write against this panel's own subject is in flight: a wave sweeps the panel and it is marked busy. Not for a write against one row inside it. A row reports itself on the row; covering the whole panel for one checkbox reads as though the panel had gone away, and the thing you were looking at is exactly what you need to still see.
subtitlestringundefinedSecond header line — the code name, path or type. Set in mono, because in this product a generated identifier is always mono and a label never is.
titlestringundefinedHeader title. Use the title slot when it needs markup.
widthHabInspectorWidth320Column width. A number is pixels; a percentage string is resolved against the flex row the stack shares with the canvas, and stays a percentage when the window changes size. Bind with v-model:width to follow a drag. Habitus never persists it — the panel reports the new width and the host decides whether to remember it, because a UI kit that reaches for localStorage cannot be server-rendered, cannot be tested in isolation, and quietly owns a key it did not name.

Events ​

EventPayloadDescription
close——
update:collapsedvalue: boolean—
update:widthvalue: HabInspectorWidth—

Slots ​

SlotBindingsDescription
actionsanyIcon buttons in the header, before the flip and close controls.
badgeanyRight of the title — usually a HabBadge or HabChip saying what kind of thing is selected.
defaultanyThe panel body. Scrolls itself.
footeranyPersistent bar at the bottom — save state, counts, one action.
leadinganyLeft of the title and subtitle, spanning both lines — usually a HabIconTile saying what kind of thing the panel is about. Not shown on the collapsed rail.
railanyWhat the 56px rail shows while collapsed.
subtitleanySecond header line. Set in mono by subtitle; this slot sets nothing.
titleanyHeader title, when title is not enough.

Proprietary — internal use only.