Overview
How this catalog is organized — and how to build from it.
The Baseout component catalog: the single source of truth for how the UI is built. Pick any entry on the left to see the live component, the rules for using it, its props, and copy-paste markup.
How it’s organized
- Foundations — the tokens everything is built from (color, type, spacing, radius, elevation). These are the knobs: change a token and the whole UI follows.
- Primitives — the standard building blocks (buttons, inputs, badges, tables…). Build new screens from these, not hand-rolled CSS.
- Patterns — product-specific compositions (the Home rail, the backup pipeline, audit tables). These stay bespoke; reuse the primitives inside them.
How to read an entry
- When to use — which variant or size to reach for, and why. The highlighted row is the default.
- Props — every option the component accepts (its API).
- Examples — the live component above its exact copy-paste markup. What you see is what ships.
Building a new screen (person or agent): compose from Primitives, follow each entry’s “When to use” default, and pull color / spacing / radius from Foundations. The non-negotiables: one primary button per surface · md btn is the default size (~90%), sm only for dense clusters · every badge is soft + semantic (status, plus Required = error / Recommended = primary / Managed = success) — never badge-outline, and a standalone status badge gets a leading dot · any user hint is a soft alert with a leading icon, not a bare tinted line · a Clear/reset is a red ghost + × shown only when there’s something to clear · real third-party services use their real logo · a concept uses one icon everywhere · linked-and-healthy connectors are a green line + check (the Home pipeline language) · numbers are font-mono + tabular · 12px is the smallest text. If something isn’t a primitive here, it’s a Pattern — keep it bespoke.
Tags: each component is tagged by provenance — daisyUI (a standard component, used as-is), daisyUI + custom (daisyUI primitives composed into our own layout / logic), Custom (fully ours, no daisyUI core).
Colors
The Arctic Console palette — achromatic chrome, one luminous accent.
--color-* variables defined in styles/themes/baseout.css — the same swatches re-resolve per theme, so toggle light/dark to see both.Do
- Use the primary only for interactive elements — buttons, links, focus, active state.
- Use status colors only to signal state (success / warning / error).
- Reach for tokens (bg-primary, text-base-content) — never raw hex in components.
Don’t
- Don't use the primary as decoration or on a surface where it fails contrast.
- Don't introduce a second brand color — the palette is single-accent on purpose.
Brand & surfaces
Status
Typography
Urbanist for everything human, JetBrains Mono for everything machine.
font-mono) is reserved for machine-readable data. Font tokens live in --font-sans / --font-mono.Choosing a role
Body 16 is the reading default. In dense tables and metadata we drop to 14 or 13 — never below the 12px floor. Display (64) appears at most once per page and mostly on brand or marketing surfaces, rarely in-app.
| Use | When | Why |
|---|---|---|
| Body · 16 / 400 Default | Default reading text — descriptions, paragraphs, form values. | Comfortable for sustained reading; keep line length 65–75ch. |
| Label · 14 / 500 · caps | Category and section headers above a group; table column headers. | Uppercase + tracking says "this labels a group", distinct from content. |
| Subtitle · 22 / 600 | Card, widget and dialog titles. | Enough step above body to anchor a block without competing with the page title. |
| Title · 32 / 600 | Page and major section headings. | The top of the in-app hierarchy — one focal heading per view. |
| Caption · 13–14 / 400 | Timestamps, helper text, secondary metadata. | Supporting info that should recede; still at or above the 12px floor. |
| Mono · 14 / 400 | IDs, hashes, durations, and counts in data columns. | The mono boundary — machine-readable data only, never human text. |
Do
- Drive hierarchy with size + weight, not color.
- Use font-mono for IDs, hashes, durations, timestamps, counts in data columns.
- Keep body line length at 65–75 characters.
Don’t
- Don't use mono for human-written text (names, labels, descriptions).
- Don't go below 12px — that is the floor for the smallest UI text.
Text: color & weight
The reading hierarchy — carried by how dark and how heavy text is, never by hue.
base-content, not different hues) and weight. Keep text neutral; a semantic color is only for words that are a state. Tune these and the whole UI’s legibility and emphasis shift together.Text color — when to use which
Text is base-content stepped down by opacity, not recoloured. Reach for a status color only to signal state, and for the primary (accent) only for interactive text.
| Use | When | Why |
|---|---|---|
| Primary · text-base-content Default | Main content — values, headings, body you must read. | Full contrast; this is where the eye lands. |
| Secondary · text-base-content/70 | Supporting copy — descriptions, the caption next to a value. | Present but clearly subordinate to primary. |
| Muted · text-base-content/55 | Metadata — table headers, timestamps, placeholders, hints. | Recedes to the background; stays ≥ AA at ≥12px. |
| Status · text-success / warning / error | Words that ARE a state — “Failed”, “Backed up”, an error line. | Here colour is meaning, not decoration; pair with a label/icon. |
| Action · text-primary | Inline links and clickable text. | The single chromatic accent reads as “interactive”. |
Font weight — when to use which
Weight reinforces hierarchy alongside size. Body is 400; labels and small UI text step to 500 so they hold up small; headings and the one value a block is about use 600. Avoid 700 for in-app text.
| Use | When | Why |
|---|---|---|
| Regular · 400 Default | Body and reading text. | Comfortable for sustained reading; the baseline. |
| Medium · 500 | Labels, nav, table headers, buttons, small UI text. | Holds legibility at small sizes where 400 looks thin. |
| Semibold · 600 | Headings, card titles, the single key value of a block. | Anchors a block — one focal weight per surface. |
Color ladder
Weight ladder
Spacing
A 4px grid — the rhythm everything snaps to.
--spacing: 0.25rem); every padding, gap and margin is a multiple of 4. Use the scale, never arbitrary pixel values, so vertical rhythm stays consistent across views. The knob is --spacing plus the discipline of using p-* / gap-* / m-* steps.Do
- Snap every gap and pad to the scale (4 / 8 / 12 / 16 / 24 / 32 / 48).
- Use larger steps (24–48) to separate sections, smaller (4–8) to group.
Don’t
- Don't hand-pick 14px / 18px / 30px — those break the grid.
4pxp-1 / gap-18pxp-2 / gap-212pxp-3 / gap-316pxp-4 / gap-424pxp-6 / gap-632pxp-8 / gap-848pxp-12 / gap-12Radius & corners
Three daisyUI knobs, all 6px today — change them once, the whole UI re-rounds.
styles/themes/baseout.css: --radius-field (buttons, inputs, selects), --radius-box (cards, modals, containers) and --radius-selector (checkboxes, toggles, badges). All three are 0.375rem (6px) right now. This is the single lever to make the product softer or sharper — edit the tokens, every component follows. Use rounded-full for true pills (avatars, status dots).Do
- Adjust the three radius tokens together for a consistent feel.
- Use rounded-field / rounded-box / rounded-selector so components track the tokens.
Don’t
- Don't hardcode arbitrary border-radius on individual components — it desyncs from the system.
--radius-field · 6px
--radius-box · 6px
--radius-selector · 6px
rounded-full
Elevation
Border-first depth; shadows only float and modal.
shadow-md / shadow-xl.Do
- Use a border + surface step for resting cards and containers.
- Reserve shadow-md for floating (popovers, dropdowns) and shadow-xl for modals.
Don’t
- Don't drop shadows on flat resting surfaces — it reads as noise on dark.
border only
shadow-md
shadow-xl
Button
daisyUIcomponents/ui/Button.astro daisyUI btn — the standard action primitive across the app.
btn plus a variant. One primary per visible surface; everything else is subordinate. Destructive actions use btn-error and always confirm via a modal.Choosing emphasis
Spend exactly one primary per visible surface — it points at the single thing the user came to do. Everything else steps down: secondary is a neutral gray fill, tertiary is ghost (no fill). Reference: Cloudflare’s blue primary + gray “Edit code” secondary (cf. Linear, Claude). Outline is deprecated — its default border reads heavy; reach for the gray Secondary instead. It survives only as a quieter destructive (btn-outline btn-error).
| Use | When | Why |
|---|---|---|
| Primary · btn-primary | The one main action of the surface — Run backup now, Save, Connect, Continue. | A single cyan fill is the unmistakable focal point. A second primary collapses the hierarchy. |
| Secondary · btn-neutral | A supporting action next to the primary — Edit code, Settings, Cancel, Back. | A neutral gray fill with white text: clearly a button, but it yields to the primary (the Cloudflare / Linear pattern). Tuned dark-first. |
| Tertiary · btn-ghost | Low-stakes or repeated actions — toolbars, table-row actions, dismiss, menu items. | No fill or border, so many can sit together without competing for attention. |
| Destructive · btn-error | Irreversible actions — Delete, Disconnect, Overwrite. | Red signals stop-and-think; always pair it with a confirm modal. |
Choosing a size
daisyUI ships 5 sizes; we standardise on 3 and use daisyUI’s native scale as-is. <strong>Default (md, 40px / 14px) is the size for ~90% of the interface</strong> — every prominent or standalone action: page-header CTAs, empty-state buttons, form submits, modal AND drawer actions, wizard nav. Reach for small ONLY inside genuinely dense clusters (a toolbar, a row of filter chips, table row actions); large is the rare hero. When unsure, use md.
| Use | When | Why |
|---|---|---|
| Default · btn (40px / 14px) Default | The default for ~90% of buttons — page-header CTAs (Run backup now), empty-state actions (Connect Airtable), form submits, modal + drawer footers, wizard Next / Save. Whenever you want a 14px label, this is it. | Its 14px label matches the app’s body text and gives an action the presence it needs. Small reads as a secondary, dense-context control — wrong for a standalone action. |
| Small · btn-sm (32px / 12px) | Dense clusters ONLY — a toolbar, filter chips, table row actions, an icon-only close (×) in a header, tight inline groups. | daisyUI’s native compact size (32px / 12px) for control-packed areas, the Linear / Vercel density default. Don’t reach for it just to make a button smaller — a standalone action wants md. (We do not restyle sm’s font — overriding daisyUI’s .btn-sm doesn’t survive the Tailwind v4 / Lightning CSS build.) |
| Large · btn-lg (48px / 18px) | A hero CTA — empty-state or onboarding, where one action defines the screen. | Reserve the extra weight for the exception; oversized buttons elsewhere read as marketing. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'warning' | 'primary' | Emphasis level — see the table. secondary = neutral gray; outline / tonal are deprecated. |
| size | 'sm' | 'md' | 'lg' | 'md' | md (default) is the workhorse; sm for dense areas, lg for heroes. |
| icon | boolean | false | Square icon-only button (needs an aria-label). |
| loading | boolean | false | Shows a spinner and disables the button. |
| disabled | boolean | false | Non-interactive, reduced opacity. |
| href | string | — | Renders as a link (a) instead of a button. |
Do
- Keep exactly one btn-primary per visible surface.
- Give primary and secondary actions a leading icon that names the action — it makes the button self-explanatory (play = Run, plus = New, settings = Configure).
- Pair an icon with a label; give icon-only buttons an aria-label.
- Show a loading spinner and disable the button during async work.
Don’t
- Don't stack two primary buttons competing for attention.
- Don't ship a destructive button without a confirm step.
Variants
<div class="flex flex-wrap items-center gap-3">
<button class="btn btn-primary">Primary</button>
<button class="btn btn-neutral">Secondary</button>
<button class="btn btn-ghost">Tertiary</button>
<button class="btn btn-error">Danger</button>
</div> Sizes
<div class="flex flex-wrap items-center gap-3">
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary">Default</button>
<button class="btn btn-primary btn-lg">Large</button>
</div> States
<div class="flex flex-wrap items-center gap-3">
<button class="btn btn-primary">
<span class="loading loading-spinner loading-sm"></span>
Saving
</button>
<button class="btn btn-primary" disabled>Disabled</button>
</div> With icon
<div class="flex flex-wrap items-center gap-3">
<button class="btn btn-primary">
<span class="iconify lucide--plus size-4"></span>
New backup
</button>
<button class="btn btn-ghost btn-square" aria-label="Refresh">
<span class="iconify lucide--rotate-cw size-4"></span>
</button>
</div> Badge / Status
daisyUIcomponents/ui/Badge.astro daisyUI badge — soft tint + colored text for state, the app-wide status pill.
badge-soft badge-{state}), optionally with a leading dot (a small bg-current span we compose inside the badge so it inherits the badge’s colour — daisyUI’s standalone dot is the Status component). Solid badges are for counts and emphasis, not status. This is the pattern every view should converge on instead of hand-rolled pills.Choosing a badge
Status is almost always a soft tint plus a semantic color — it reads calm and stays scannable down a long list. Reach for a solid fill only for numeric counts, and for neutral when a state carries no alarm.
| Use | When | Why |
|---|---|---|
| Soft + status · badge-soft badge-{state} Default | The state of a thing — Backed up (success), Degraded (warning), Failed (error), Running (primary). | Tinted background + colored text signals state without shouting; consistent across every list. |
| Neutral · badge-ghost | States that carry no alarm — Paused, Draft, Skipped. | No semantic color means no urgency — it reads as "idle", not "wrong". |
| With dot · + leading dot | When the badge stands alone in a row (a source/destination option, not under a column header). | The dot reinforces the state at a glance where a label alone might be missed. Compose it as a small bg-current span inside the badge. |
| Meta tag · soft, by meaning | Required = badge-soft badge-error (you must), Recommended = badge-soft badge-primary, Managed = badge-soft badge-success. | Sibling meta tags must all be badges (don’t mix a plain-text label with a badge); colour carries the meaning, red flags necessity. |
| Solid · badge-{state} | Counts and emphasis only — a tally, a "12 new", never a passive status. | A solid fill pulls too much attention to be a calm status indicator. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | 'default' | 'primary' | 'secondary' | 'tertiary' | 'success' | 'warning' | 'error' | '*-solid' | 'default' | Soft tint by default; the -solid variants fill. |
| size | 'sm' | 'md' | 'lg' | 'md' | Pill height. |
| outline | boolean | false | Outlined instead of filled / soft. |
| dot | boolean | false | Leading status dot. |
Do
- Use badge-soft + a status color for state (Backed up, Failed, Paused).
- Add a leading dot when the badge stands alone in a row without nearby context.
- Make sibling meta tags all badges by meaning (Required = error, Recommended = primary, Managed = success).
Don’t
- Don't use a solid status-colored fill for state — soft tint + colored text reads calmer.
- Don't use badge-outline — we standardise on the soft style everywhere.
- Don't roll a custom pill, or mix a plain-text label with a badge for sibling tags.
Soft (status)
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-soft badge-success">Backed up</span>
<span class="badge badge-soft badge-warning">Degraded</span>
<span class="badge badge-soft badge-error">Failed</span>
<span class="badge badge-soft badge-primary">Running</span>
<span class="badge badge-ghost">Paused</span>
</div> With status dot
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-soft badge-success">
<span class="size-1.5 rounded-full bg-current"></span>
Healthy
</span>
<span class="badge badge-soft badge-error">
<span class="size-1.5 rounded-full bg-current"></span>
Failed
</span>
</div> Solid — counts & emphasis
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-primary">3 new</span>
<span class="badge badge-success">12</span>
<span class="badge badge-neutral">v2</span>
</div> Sizes (sm · default · lg)
<div class="flex flex-wrap items-center gap-3">
<span class="badge badge-soft badge-primary badge-sm">Small</span>
<span class="badge badge-soft badge-primary">Default</span>
<span class="badge badge-soft badge-primary badge-lg">Large</span>
</div> Tooltip
daisyUIdaisyUI tooltip — an instant, on-brand hint for icon-only controls.
tooltip wraps a control and shows its data-tip text on hover or focus. Use it to name an icon-only button — never the native title attribute, which waits ~1s, renders unstyled, and ignores the theme (we zero the open-delay in global.css so ours appear instantly). Tooltips are CSS pseudo-elements, so any overflow: hidden or scrolling ancestor clips them: inside a scroll area or against a right edge, use tooltip-left. Keep the tip to a few words, and always keep an aria-label on the button — the tip alone is hover-only, unreachable by keyboard, touch, or screen reader.When to use a tooltip
A tooltip names a control that has no visible label. It is a hint, not a home for essential information — anything a user must read belongs on the surface.
| Use | When | Why |
|---|---|---|
| tooltip (top) | Default. An icon button with room above it. | Points at the trigger without covering neighbouring rows. |
| tooltip-left | Inside a scrolling panel or against the right edge (e.g. the Schema field-row actions). | A top tooltip gets clipped by the scroll container; left stays within the row. |
| tooltip-primary / -success / … | When the hint reinforces a semantic action (rare). | Colours the bubble to match; default neutral suits most cases. |
Do
- Wrap the control in a tooltip and set data-tip to a short label.
- Keep an aria-label on the button so the action is announced and touch-reachable.
- Use tooltip-left inside scroll areas or against a right edge so it is not clipped.
Don’t
- Don't use the native title attribute — it lags ~1s and ignores the theme.
- Don't put essential information in a tooltip (hover-only: no keyboard, no touch).
- Don't write a sentence; a tooltip is two to four words.
Placement (forced open for the catalog)
<div class="flex flex-wrap items-center justify-center gap-12" style="padding: 3rem 4.5rem">
<div class="tooltip tooltip-open tooltip-top" data-tip="Top"><button class="btn btn-sm">Top</button></div>
<div class="tooltip tooltip-open tooltip-bottom" data-tip="Bottom"><button class="btn btn-sm">Bottom</button></div>
<div class="tooltip tooltip-open tooltip-left" data-tip="Left"><button class="btn btn-sm">Left</button></div>
<div class="tooltip tooltip-open tooltip-right" data-tip="Right"><button class="btn btn-sm">Right</button></div>
</div> On icon-only buttons (hover to reveal) — the real use
<div class="flex items-center gap-2" style="padding: 2.75rem 1rem">
<div class="tooltip tooltip-left" data-tip="Generate description · 10 credits">
<button class="btn btn-sm btn-ghost btn-square text-primary" aria-label="Generate description"><span class="iconify lucide--sparkles size-4"></span></button>
</div>
<div class="tooltip" data-tip="Edit description">
<button class="btn btn-sm btn-ghost btn-square" aria-label="Edit description"><span class="iconify lucide--pencil size-4"></span></button>
</div>
<div class="tooltip" data-tip="Clear description">
<button class="btn btn-sm btn-ghost btn-square text-error" aria-label="Clear description"><span class="iconify lucide--x size-4"></span></button>
</div>
</div> Colour
<div class="flex flex-wrap items-center justify-center gap-12" style="padding: 3rem 3rem">
<div class="tooltip tooltip-open" data-tip="Neutral"><button class="btn btn-sm">Default</button></div>
<div class="tooltip tooltip-open tooltip-primary" data-tip="Primary"><button class="btn btn-sm btn-primary">Primary</button></div>
<div class="tooltip tooltip-open tooltip-success" data-tip="Success"><button class="btn btn-sm">Success</button></div>
</div> Input
daisyUIcomponents/ui/TextInput.astro daisyUI input — the text field for every form value.
input-bordered needed). Wrap in a fieldset with a fieldset-legend label and a fieldset-label for helper or error text. For a leading icon, make the control a label.input with the input as .grow inside.States & feedback
Always pair an input with a visible label — never placeholder-only. Show a validation error inline below the field, not only at the top of the form.
| Use | When | Why |
|---|---|---|
| Default · input | The resting field for any value. | Bordered and neutral; reads as editable. |
| Error · input-error | A failed validation — put the reason in the fieldset-label below. | Red border + message points straight at the field to fix. |
| Success · input-success | Confirmed values where confirmation genuinely matters (rare). | Use sparingly; most valid fields need no color. |
| Small · input-sm | Dense forms, inline edit, filters. | Matches the btn-sm density default. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Visible field label (always provide one). |
| type | 'text' | 'email' | 'password' | 'search' | 'tel' | 'url' | 'number' | 'text' | Drives validation and the mobile keyboard. |
| icon / iconRight | string (lucide name) | — | Leading / trailing icon inside the field. |
| size | 'sm' | 'md' | 'lg' | 'md' | Field height. |
| error | string | — | Message; switches the field to the error style. |
| required / disabled / readonly | boolean | false | Standard field states. |
Do
- Give every input a visible label.
- Use the right type (email, number, url) so mobile shows the right keyboard.
- Put helper and error text in a fieldset-label below the field.
Don’t
- Don't rely on the placeholder as the label — it vanishes on input.
- Don't surface errors only at the top of the form.
Default + label
<fieldset class="fieldset max-w-xs">
<legend class="fieldset-legend">Space name</legend>
<input type="text" class="input" placeholder="My backup" />
<p class="fieldset-label">Shown across the app.</p>
</fieldset> With leading icon
<label class="input max-w-xs">
<span class="iconify lucide--search size-4 opacity-50"></span>
<input type="search" class="grow" placeholder="Search runs" />
</label> Error
<fieldset class="fieldset max-w-xs">
<legend class="fieldset-legend">API key</legend>
<input type="text" class="input input-error" value="bad-key" />
<p class="fieldset-label text-error">That key was rejected by Airtable.</p>
</fieldset> Sizes
<div class="flex max-w-xs flex-col gap-2">
<input type="text" class="input input-sm" placeholder="Small" />
<input type="text" class="input" placeholder="Default" />
</div> Select
daisyUIcomponents/ui/Select.astro daisyUI select — one choice from a known list.
fieldset wrapper as Input, bordered by default. A select is for exactly one value from a short, fixed list. For many searchable options use a combobox; for 2–4 mutually exclusive ones consider a segmented control.When to use a select
One choice from a short, known list. If the list is long and needs search, or the options are 2–4 and always visible, a select is the wrong tool.
| Use | When | Why |
|---|---|---|
| Default · select | One value from a fixed list — frequency, destination type. | Compact, familiar, native keyboard support. |
| Error · select-error | A required choice left unmade on submit. | Matches the input error treatment. |
| Small · select-sm | Toolbar filters and inline controls. | Density default; pairs with btn-sm. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | Option[] | required | The list of choices. |
| label | string | — | Visible label. |
| placeholder | string | — | Empty first option. |
| icon | string (lucide name) | — | Leading icon. |
| size | 'sm' | 'md' | 'lg' | 'md' | Control height. |
| error | string | — | Message + error style. |
Do
- Pre-select a sensible default when one exists.
- Auto-select the only option when the list has exactly one.
Don’t
- Don't use a select for free text or for many searchable options.
Default
<fieldset class="fieldset max-w-xs">
<legend class="fieldset-legend">Frequency</legend>
<select class="select">
<option>Daily</option>
<option>Weekly</option>
<option>Monthly</option>
</select>
</fieldset> Sizes
<div class="flex items-center gap-2">
<select class="select select-sm"><option>Small</option></select>
<select class="select"><option>Default</option></select>
</div> Checkbox & Toggle
daisyUIcomponents/ui/Checkbox.astro · Toggle.astro Two boolean controls — choose by whether the change is immediate.
checkbox selects items in a set or opts into something that applies on submit. A toggle flips a setting that takes effect the instant you flip it. Both use the primary color when on.Checkbox vs toggle
The deciding question: does the change apply now or on save? Immediate effect → toggle. Part of a form, deferred, or multi-select → checkbox.
| Use | When | Why |
|---|---|---|
| Checkbox · checkbox | Selecting rows (which bases to back up), opting into a form choice. | Reads as "part of a set / applies on submit". |
| Toggle · toggle | A setting that takes effect the instant it flips (enable schedule). | Reads as an on/off switch with immediate effect. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Both — the control’s label. |
| checked | boolean | false | Both — on / off state. |
| disabled | boolean | false | Both — non-interactive. |
| size | 'sm' | 'md' | 'lg' | 'md' | Toggle only — switch size. |
| description | string | — | Toggle only — helper line under the label. |
Do
- Use a toggle only when the effect is immediate.
- Label the state so on/off is unambiguous.
- When an option can’t be chosen (at a selection cap, plan-gated), make the item itself read inactive — reduced opacity (~0.4) + cursor-not-allowed + the control disabled — not just a banner. A long list hides the banner; the disabled item carries the reason in place.
Don’t
- Don't use a toggle for something that only applies after a Save.
Checkbox
<label class="flex items-center gap-2">
<input type="checkbox" class="checkbox checkbox-primary checkbox-sm" checked />
<span class="text-sm">Include attachments</span>
</label> Toggle
<label class="flex items-center gap-2">
<input type="checkbox" class="toggle toggle-primary" checked />
<span class="text-sm">Scheduled backups on</span>
</label> Card
daisyUIcomponents/ui/Card.astro daisyUI card — the default container for a grouped block.
card on bg-base-100 with a base-300 border, padded, rounded-box. Border-first depth — no resting shadow (see Elevation). Use a card when content genuinely forms a unit; don’t wrap everything, and never nest cards.Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | 'default' | 'elevated' | 'outlined' | 'tonal' | 'primary' | 'default' | Surface treatment; default = border-first, no shadow. |
| hover | boolean | false | Adds a hover shadow (for clickable cards). |
Do
- Use a card to group content that belongs together.
- Keep one border + surface; let spacing do the rest.
Don’t
- Don't nest a card inside a card.
- Don't wrap every element in a card — most don't need one.
Default
Daily backup
Runs every day at 02:00 UTC to Google Drive.
<div class="card max-w-sm rounded-box border border-base-300 bg-base-100 p-6">
<h3 class="text-base font-semibold">Daily backup</h3>
<p class="mt-1 text-sm text-base-content/70">Runs every day at 02:00 UTC to Google Drive.</p>
</div> Modal
daisyUIcomponents/ui/Modal.astro daisyUI modal — a focused interruption, used sparingly.
dialog.modal with a modal-box and a modal-backdrop. Reserve modals for confirming consequential or destructive actions, or a short focused task — exhaust inline and progressive options first. Destructive confirmations use btn-error and name the blast radius. (Click the trigger below to open the real dialog.)When to use a modal
A modal interrupts, so it should be the exception. Default to inline / progressive disclosure; reach for a modal only when the user must stop and decide.
| Use | When | Why |
|---|---|---|
| Confirm destructive | Delete, disconnect, overwrite — name what is affected. | Forces a deliberate stop before an irreversible action. |
| Confirm + credits | Run backup now — warn that extra credits will be used. | A cost the user should acknowledge before it happens. |
| Short focused task | Create Space, rename — a few fields, then return. | Keeps a small task in context without a page change. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | required | Unique id; the trigger calls id.showModal(). |
| size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | Box width. |
| title | string | — | Heading rendered in the box header. |
| open | boolean | false | Render open initially. |
Do
- Confirm every destructive action with a modal.
- State the consequence and what cannot be undone.
Don’t
- Don't reach for a modal as the first thought — try inline first.
- Don't stack modals.
Destructive confirm — click to open
<button class="btn btn-sm btn-error" onclick="document.getElementById('sb_modal_demo').showModal()">
Disconnect Google Drive…
</button>
<dialog id="sb_modal_demo" class="modal">
<div class="modal-box max-w-sm">
<h3 class="text-lg font-semibold">Disconnect Google Drive?</h3>
<p class="mt-2 text-sm text-base-content/70">3 Spaces back up here. They will fail until you reconnect. This can’t be undone.</p>
<div class="modal-action">
<form method="dialog" class="flex gap-2">
<button class="btn btn-sm btn-ghost">Cancel</button>
<button class="btn btn-sm btn-error">Disconnect</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog> Confirm modal
daisyUIcomponents/ui/ConfirmModal.astro A reusable "are you sure?" dialog built on the catalog Modal.
confirmHref navigates (the prototype pattern) or a confirm named slot supplies a custom control. confirmClass sets the emphasis — btn-outline btn-error for destructive, btn-neutral for a calm/reversible one. Don't rebuild a confirm inline — reuse this. (Click to open the real dialog.)Props
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | required | Dialog id; the trigger calls id.showModal(). |
| title | string | required | The question, e.g. "Cancel this backup run?". |
| confirmLabel | string | 'Confirm' | Confirm-button label. |
| confirmHref | string | — | Where confirm navigates (prototype). Omit and use the confirm slot for a custom control. |
| confirmClass | string | 'btn-primary' | Confirm emphasis — 'btn-neutral' / 'btn-outline btn-error' (destructive). |
| confirmIcon | string | — | Lucide class on the confirm button, e.g. 'lucide--x'. |
| cancelLabel | string | 'Cancel' | Dismiss-button label. |
Do
- Reuse it for every confirm — destructive or costly.
- State the consequence in a soft Alert (kept data, irreversibility, credits).
Don’t
- Don't rebuild a confirm dialog inline.
- Don't use a red/destructive confirm for a reversible action — Pause is info, not error.
Destructive confirm — click to open
<button class="btn btn-sm btn-outline btn-error" onclick="document.getElementById('sb_confirm_demo').showModal()">
Cancel run…
</button>
<dialog id="sb_confirm_demo" class="modal">
<div class="modal-box max-w-sm">
<div class="flex items-center justify-between gap-4 pb-4 border-b border-base-300/20">
<h2 class="font-headline font-semibold text-lg">Cancel this backup run?</h2>
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost" aria-label="Close"><span class="iconify lucide--x icon-lg"></span></button></form>
</div>
<div class="py-4">
<p class="text-sm text-base-content/70">The run stops where it is. Everything captured so far is kept.</p>
<div role="alert" class="alert alert-soft alert-warning mt-3">
<span class="iconify lucide--triangle-alert size-4"></span>
<span>A cancelled run <strong>can’t be resumed</strong> — start a new one with Run backup now.</span>
</div>
<div class="modal-action">
<form method="dialog" class="flex gap-2">
<button class="btn btn-ghost">Keep running</button>
<button class="btn btn-outline btn-error"><span class="iconify lucide--x size-4"></span>Cancel run</button>
</form>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog> Tabs
daisyUIcomponents/ui/Tabs.astro daisyUI tabs — switch views within one context.
tabs tabs-border); tabs-lift for a card-attached set; tabs-pills for a compact segmented control.Choosing a tab style
Underline is the in-app default. Use lift only when tabs sit on top of a card, and pills for a small inline segmented toggle.
| Use | When | Why |
|---|---|---|
| Underline · tabs tabs-border Default | Section switching within a page (the common case). | Quiet; reads as "same object, different view". |
| Lift · tabs tabs-lift | Tabs attached to the top of a card or panel. | The active tab visually connects to the panel below. |
| Pills · tabs tabs-pills | A compact inline toggle — list/grid, day/week. | Segmented-control feel in a tight space. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | Tab[] | required | The tab items. |
| variant | 'underline' | 'pills' | 'pills-full' | 'boxed' | 'vertical' | 'submenu' | 'underline' | Tab style — see the table above. |
| activeTab | string | — | Id of the initially active tab. |
Do
- Use tabs for sibling views of one object.
- Keep exactly one tab active and obvious.
Don’t
- Don't use tabs as primary page navigation.
- Don't hide critical actions behind a non-default tab.
Underline (default)
<div class="tabs tabs-border">
<a class="tab tab-active">Schema</a>
<a class="tab">Data</a>
<a class="tab">Activity</a>
</div> Table
daisyUIdaisyUI table — dense, scannable rows of records.
table; headers in a small uppercase label; IDs and counts in font-mono tabular-nums so columns align; status via a soft badge; a row’s drill-in action as a primary-coloured ghost (btn-ghost btn-sm text-sm text-primary — btn-sm keeps the compact height, text-sm matches the 14px rows, text-primary reads as the interactive drill-in). Add table-zebra only when row scanning needs the help.Do
- Right-align and tabular-num numeric columns so they compare at a glance.
- Use font-mono for IDs, durations, and counts.
- Match a row action’s text to the table (btn-ghost btn-sm text-sm text-primary) so its label is the data size and reads as interactive.
Don’t
- Don't center-align numbers.
- Don't put more than one primary action in a row.
Run history
| Status | Run | Records | Duration |
|---|---|---|---|
| Backed up | run_8f2a1c | 420,318 | 2m 14s |
| Failed | run_7b1d04 | — | 0m 38s |
<table class="table">
<thead>
<tr class="text-xs uppercase tracking-wider">
<th>Status</th>
<th>Run</th>
<th class="text-right">Records</th>
<th class="text-right">Duration</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge badge-soft badge-success">Backed up</span></td>
<td class="font-mono text-sm">run_8f2a1c</td>
<td class="text-right font-mono tabular-nums">420,318</td>
<td class="text-right font-mono tabular-nums">2m 14s</td>
</tr>
<tr>
<td><span class="badge badge-soft badge-error">Failed</span></td>
<td class="font-mono text-sm">run_7b1d04</td>
<td class="text-right font-mono tabular-nums">—</td>
<td class="text-right font-mono tabular-nums">0m 38s</td>
</tr>
</tbody>
</table> Breadcrumbs
daisyUIcomponents/ui/Breadcrumbs.astro daisyUI breadcrumbs — show where you are in a drill-down.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | Crumb[] | required | The trail; each has a label and optional href + icon. |
Do
- Make every level except the current one a link.
- Keep labels short and real (the run id, the base name).
Don’t
- Don't breadcrumb a flat page with no hierarchy.
Drill-down trail
<div class="breadcrumbs text-sm">
<ul>
<li><a>Backups</a></li>
<li><a>run_8f2a1c</a></li>
<li>Sales base</li>
</ul>
</div> Progress
daisyUIcomponents/ui/ProgressBar.astro daisyUI progress — determinate work and quota meters.
progress for a known-percentage bar: a running backup’s completion or a usage/quota meter. Colour by meaning — primary for in-progress, warning as a quota nears its cap. For unknown-duration work use a spinner, not a bar.Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | required | Current value (0 to max). |
| max | number | 100 | Upper bound. |
| variant | 'primary' | 'success' | 'warning' | 'error' | 'primary' | Colour by meaning. |
| label / showValue | string / boolean | — / false | Optional label and percentage readout. |
Do
- Use a determinate bar only when you know the percentage.
- Turn a quota bar to warning as it approaches the cap.
Don’t
- Don't fake progress for unknown-duration work — use a spinner.
Variants
<div class="flex max-w-sm flex-col gap-3">
<progress class="progress progress-primary w-full" value="64" max="100"></progress>
<progress class="progress progress-warning w-full" value="88" max="100"></progress>
<progress class="progress progress-error w-full" value="100" max="100"></progress>
</div> Alert
daisyUIdaisyUI alert — an inline banner for a state the user should notice.
alert alert-soft alert-{color}). Colour by meaning. This replaces our hand-rolled banners (failed-attachments, the “extra credits” warning).Which alert
Colour carries the meaning; pair it with an icon and keep the copy to roughly one line.
| Use | When | Why |
|---|---|---|
| Info · alert-info | Neutral notices — “Schema captured”, a heads-up. | Informs without alarm. |
| Warning · alert-warning | Quota nearing a cap, “extra credits will be used”. | Asks for attention before a cost. |
| Error · alert-error | A failure the user must see — a failed run, lost connection. | Highest urgency; pair with a recovery action. |
| Success · alert-success | A completed action worth confirming inline. | Positive confirmation in place. |
Do
- Use the soft style for calm, on-brand banners.
- Give an error alert a recovery action (Reconnect, Retry).
- Make every user hint / heads-up an alert with a leading icon — a gating hint is a persistent alert-warning + lucide--circle-alert; a non-gating tip may add a × to dismiss.
Don’t
- Don't stack multiple alerts — collapse to the most important.
- Don't use an alert for a transient confirmation — that's a Toast.
- Don't render a hint as a bare tinted <p> — it must be an alert with an icon.
Soft, by meaning
<div class="flex flex-col gap-2">
<div role="alert" class="alert alert-soft alert-warning">
<span class="iconify lucide--triangle-alert size-4"></span>
<span>Running this backup now will use additional credits.</span>
<button class="btn btn-sm btn-warning">Run anyway</button>
</div>
<div role="alert" class="alert alert-soft alert-error">
<span class="iconify lucide--circle-x size-4"></span>
<span>3 attachments could not be backed up.</span>
<button class="btn btn-sm btn-ghost">Review</button>
</div>
<div role="alert" class="alert alert-soft alert-info">
<span class="iconify lucide--info size-4"></span>
<span>Schema, data and attachments were captured.</span>
</div>
</div> Dismissible — a read-once confirmation
<div role="alert" class="alert alert-soft alert-success">
<span class="iconify lucide--circle-check size-4"></span>
<span class="flex-1">Your Space is protected. The first backup is running.</span>
<button class="btn btn-ghost btn-sm btn-square" aria-label="Dismiss" onclick="this.closest('.alert').remove()">
<span class="iconify lucide--x size-4"></span>
</button>
</div> Tooltip
daisyUIdaisyUI tooltip — a hover hint for icon-only controls and truncated text.
tooltip and set data-tip; position with tooltip-top/right/bottom/left. Use it for icon-only buttons, truncated values, and provider hints — replacing native title (slow, unstyled).Do
- Give every icon-only button a tooltip (and an aria-label).
- Use a tooltip to reveal a truncated value in full.
Don’t
- Don't hide essential info in a tooltip — it's unreachable on touch.
- Don't put actions inside a tooltip.
Positions (forced open to preview)
<div class="flex items-center gap-10 pt-6">
<div class="tooltip tooltip-open tooltip-top" data-tip="Opens the run detail"><button class="btn btn-sm btn-outline">Details</button></div>
<div class="tooltip tooltip-open tooltip-right" data-tip="Google Drive"><button class="btn btn-sm btn-square btn-ghost" aria-label="Destination"><span class="iconify lucide--folder size-4"></span></button></div>
</div> Status dot
daisyUIdaisyUI status — a tiny dot that signals state inline.
status status-{color}, sizes status-xs…lg) for at-a-glance state next to a label — connection state, a pipeline node, online/offline. For a labelled state pill use a Badge; the dot is for compact, repeated indicators.Do
- Pair the dot with a nearby label so colour is not the only signal.
- Use it where a full badge would be too heavy (lists, pipeline nodes).
Don’t
- Don't rely on the dot's colour alone — add text or an aria-label.
States
<div class="flex flex-col gap-2 text-sm">
<span class="inline-flex items-center gap-2"><span class="status status-success"></span> Connected</span>
<span class="inline-flex items-center gap-2"><span class="status status-warning"></span> Reconnect needed</span>
<span class="inline-flex items-center gap-2"><span class="status status-error"></span> Disconnected</span>
<span class="inline-flex items-center gap-2"><span class="status status-neutral"></span> Paused</span>
</div> Steps
daisyUIdaisyUI steps — a progress indicator for a linear flow.
steps-vertical) progress trail; completed/current steps get step-primary. Use for the setup wizard and any multi-step flow so the user sees where they are. Our bespoke setup stepper can move onto this.Do
- Mark completed and current steps with step-primary.
- Keep step labels short.
Don’t
- Don't use steps for navigation between unrelated pages.
Setup flow
- Source
- Destination
- Bases
- Depth
- Schedule
<ul class="steps">
<li class="step step-primary">Source</li>
<li class="step step-primary">Destination</li>
<li class="step step-primary">Bases</li>
<li class="step">Depth</li>
<li class="step">Schedule</li>
</ul> Radial progress
daisyUIdaisyUI radial-progress — a ring for a single percentage.
--value (0–100); set --size / --thickness as needed. Good for a compact health or quota ring — e.g. the future Health Score. Colour by meaning with a text-colour class.Do
- Use for a single headline percentage (health, quota).
- Colour by meaning (success / warning / error).
Don’t
- Don't use a ring for multi-series data — that's a chart.
Health-style rings
<div class="flex items-center gap-5">
<div class="radial-progress text-success" style="--value:82;--size:4rem;" role="progressbar" aria-valuenow="82">82</div>
<div class="radial-progress text-warning" style="--value:54;--size:4rem;" role="progressbar" aria-valuenow="54">54</div>
<div class="radial-progress text-error" style="--value:23;--size:4rem;" role="progressbar" aria-valuenow="23">23</div>
</div> Toast
daisyUIdaisyUI toast — a transient confirmation pinned to the top-right.
toast toast-top toast-end) that pins one or more alerts to a screen corner for brief confirmations (connected, saved, copied, backup started). Our default corner is top-right (toast-top toast-end) so it never collides with the bottom drawer / footer actions. Auto-dismiss after a few seconds and never steal focus. Live: the setup wizard fires one on connect / save.Do
- Pin to the top-right (toast-top toast-end).
- Auto-dismiss in 3–5s.
- Use aria-live so screen readers announce it.
Don’t
- Don't put a critical, must-act message in a toast — use an inline Alert.
- Don't pin it bottom-center where it overlaps footer / drawer actions.
Click to show — top-right toast
<button class="btn btn-primary" onclick="(function(){var t=document.getElementById('sb_toast_demo');t.hidden=false;clearTimeout(t._t);t._t=setTimeout(function(){t.hidden=true;},2600);})()">
<span class="iconify lucide--bell size-4"></span>Show toast
</button>
<div id="sb_toast_demo" class="toast toast-top toast-end z-[600]" hidden>
<div role="status" aria-live="polite" class="alert alert-success shadow-lg">
<span class="iconify lucide--circle-check size-4"></span>
<span>Airtable connected.</span>
</div>
</div> Skeleton
daisyUIdaisyUI skeleton — a loading placeholder.
skeleton + size utilities) shown while content loads — better than a blank gap or a long spinner for loads over ~1s. Mirror the shape of what is coming.Do
- Match the skeleton to the real content layout.
- Use for loads over ~1s; a spinner for shorter.
Don’t
- Don't leave a skeleton up forever — replace it as soon as data arrives.
Loading a row
<div class="flex max-w-sm items-center gap-4">
<div class="skeleton size-10 shrink-0 rounded-full"></div>
<div class="flex w-full flex-col gap-2">
<div class="skeleton h-3 w-3/4"></div>
<div class="skeleton h-3 w-1/2"></div>
</div>
</div> Stats
daisyUIdaisyUI stats — a row of headline metrics.
stats wrapping stat blocks (stat-title / stat-value / stat-desc, optional stat-figure). The standard option for summary numbers (run summary, usage). Note: the Home metrics we already have look good and stay custom (lightly adapted) — reach for this on new metric rows.Do
- Use stat-value for the number, stat-desc for the delta/context.
- Keep a row to 3–4 stats.
Don’t
- Don't pack a paragraph into a stat — it's a glanceable number.
Run summary
<div class="stats border border-base-300 bg-base-100">
<div class="stat">
<div class="stat-title">Records</div>
<div class="stat-value text-2xl">12,407</div>
<div class="stat-desc">across 2 bases</div>
</div>
<div class="stat">
<div class="stat-title">Attachments</div>
<div class="stat-value text-2xl">218</div>
<div class="stat-desc text-success">all captured</div>
</div>
<div class="stat">
<div class="stat-title">Duration</div>
<div class="stat-value text-2xl mono-data">7m</div>
<div class="stat-desc">scheduled</div>
</div>
</div> Drawer
daisyUIcomponents/ui/Drawer.astro A slide-over side panel — our Drawer component on daisyUI’s drawer.
drawer drawer-end + drawer-toggle checkbox + drawer-side + drawer-overlay, so open/close is pure CSS — a <label for={id}> opens it, the overlay and × close it, and Esc is wired in the component. The host drives it from JS with document.getElementById(id).checked = true. Body and footer are slots, so each screen keeps its own content. Live: the setup wizard (Connect Airtable / Add a destination). A bottom fly-out sheet variant (side="bottom": full width, slides up from the bottom edge) is also available for full-width slide-up sheets when a side panel would feel too narrow.Props
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | required | Unique id; the matching checkbox that open/close triggers toggle. |
| title | string | required | Heading shown in the panel header. |
| subtitle | string | — | Optional supporting line under the title. |
| side | 'end' | 'start' | 'bottom' | 'end' | Which edge it slides from (end = right). bottom = a full-width fly-out sheet that slides up. |
| width | string | 'w-[min(92vw,28rem)]' | Tailwind width class for the end/start panel. |
| height | string | '82dvh' | Panel height for the bottom sheet variant. |
| slot:footer | slot | — | Footer actions (right-aligned); omit for a footerless panel. |
Do
- Drive open/close by toggling the panel’s checkbox (or a <label for={id}>).
- Put the primary action in the footer; give the panel a clear title.
- Keep the × and a Cancel that close natively (label for the id).
Don’t
- Don't put a primary, always-needed action ONLY inside a drawer.
- Don't reach for a drawer when a Modal (a short confirm) or inline disclosure fits better.
Click to open — a real drawer
<div class="drawer drawer-end">
<input id="sb_drawer_demo" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<label for="sb_drawer_demo" class="btn btn-primary"><span class="iconify lucide--panel-right-open size-4"></span>Open drawer</label>
</div>
<div class="drawer-side z-[500]">
<label for="sb_drawer_demo" aria-label="Close" class="drawer-overlay"></label>
<aside class="flex h-dvh w-[min(92vw,24rem)] flex-col border-l border-base-300 bg-base-100">
<header class="flex items-start gap-4 border-b border-base-300 p-5">
<div class="min-w-0"><h3 class="font-semibold">Add a destination</h3><p class="mt-0.5 text-sm text-base-content/60">Pick where backups go.</p></div>
<label for="sb_drawer_demo" class="btn btn-sm btn-ghost btn-square ml-auto" aria-label="Close"><span class="iconify lucide--x size-4"></span></label>
</header>
<div class="flex-1 overflow-y-auto p-5 text-sm text-base-content/70">The panel body — compose catalog inputs, selects and buttons here.</div>
<footer class="flex justify-end gap-2 border-t border-base-300 p-4">
<label for="sb_drawer_demo" class="btn btn-ghost">Cancel</label>
<button class="btn btn-primary"><span class="iconify lucide--check size-4"></span>Save destination</button>
</footer>
</aside>
</div>
</div> Status rail
daisyUI + customviews/SpaceHomeView.astro (.hm-rail) The right-hand health column on Home — bespoke, not a primitive.
.hm-rail*); standardize only the primitives inside it. Live: Home.Do
- Reuse the badge and progress primitives from this catalog inside it.
- Keep the composition itself custom — it is genuinely ours.
Don’t
- Don't try to express the rail as a daisyUI primitive — it isn’t one.
Last backup 2h ago · next in 22h
Backup pipeline
daisyUI + customviews/SpaceHomeView.astro (.hm-pipe) The vertical Source → bases → Destination flow — bespoke.
.hm-pipe), and use this catalog’s badge for the per-node status. Live: Home · broken state ?broken=src.Do
- Use a soft status badge for each node’s state.
- Keep the layout custom — it encodes our data model.
Don’t
- Don't replace it with a generic stepper — the semantics differ.
Audit tables
daisyUI + customviews/BackupRunDetailView.astro · BackupRunBaseView.astro The Backups drill-down tables (run → base → tables) — bespoke layout on the table primitive.
Do
- Build on the table primitive; keep numbers font-mono + tabular.
- Use soft badges for per-row status.
Don’t
- Don't invent a non-table layout for tabular audit data.
| Base | Status | Records | Captured | Destination |
|---|---|---|---|---|
| Sales | Done | 128,400 | Schema Data | /Backups/Sales ↗ |
| Eng | Failed | — | — | — |
Setup stepper
Customviews/IntegrationsSetupWizard.astro The Space-setup wizard stepper — bespoke, gated for first-run.
Do
- Use catalog primitives for the controls in each step.
- Gate the stepper for first-run; allow free-jump editing after.
Don’t
- Don't reuse the gated stepper for routine edits — that’s the free-jump mode.
Table toolbar & pagination
daisyUI + customviews/BackupsListView.astro Search, filters and a pager wrapped around a data table — the run-history pattern.
dropdown of checkboxes with a selected-count badge on its trigger (the shadcn DataTableFacetedFilter pattern; cf. Deel / Profound). Multi-select where it helps (status, trigger), a single range for date; a red Clear with an × resets everything. The pager is a Select + prev/next Buttons. Filter client-side in the prototype; the real app pushes it to the query. Live: Backups.Do
- Search by stable identifiers (run id, error message) for support triage.
- Filter by attributes the row actually owns — status, trigger, date.
- Show a distinct “no matches” state, separate from the never-run empty state.
Don’t
- Don't filter by something that isn’t a per-row fact (e.g. base — that’s current config, not a run snapshot).
- Don't paginate the search out of reach — keep it pinned above the table.
| Status | Run | Records |
|---|---|---|
| Backed up | run_8f2a1c | 420,318 |
| Failed | run_7b1d04 | — |
Faceted filter
daisyUI + customcomponents/schema/FacetFilter.astro The one filter control used everywhere: a dropdown trigger with a count + a "filter working" active state.
n/total count; single-select opens a list of radios and the trigger shows the chosen value (e.g. "Added"). A red Clear with an × resets every filter at once. Every popover opens with a Search box (placeholder Search, a leading magnifier) that filters the rows in place and hides empty group headings — present on every facet so they all read the same. Rows that list a base or table carry the same health status dot (success / warning / error) the tree and Visualize canvas use, so the dropdowns are consistent across tabs. Field-type rows carry the vendored Airtable icon. Component: components/schema/FacetFilter.astro (Astro, self-wiring via a bubbling facetchange event) and the React twin in the Visualize island — kept visually identical. Live: Schema (Browse + Visualize + Changelog), Backups.Do
- Use this one control for ALL filters — multi-select (toggles) or single-select (radios) — never a bare native select next to it.
- Show the active state (primary tint) + count the moment a filter is applied, so a working filter is obvious.
- Keep the count inside the trigger button (n/total for multi; the chosen value for single).
- Offer Show all / Hide all on multi facets and a single red Clear that resets everything.
- Open every popover with a Search box (placeholder "Search") and give base/table rows the health status dot, so all dropdowns match.
Don’t
- Don't mix styles — no native select beside the faceted buttons (that was the bug this pattern fixes).
- Don't drop the active state; a filtered facet that looks identical to an empty one is a defect.
- Don't put the control on the left of the row label — name left, toggle/radio right, everywhere.
Triggers — default · active multi (n/total) · active single (value)
Open dropdowns — multi (toggles + Show/Hide all) · single (radios)
Field-visibility filter (Base ▸ Table ▸ Field)
daisyUI + customcomponents/schema/FieldsFilter.tsx The hierarchical sibling of the faceted filter: a tree of tri-state checkboxes that picks exactly which fields a view shows.
visible/total count per group. It uses checkboxes, not toggles, because groups need a third state: a base/table is checked when all its fields show, unchecked when none, indeterminate when partial; toggling a group cascades to its fields. Search filters the tree (keeping ancestors) and the bulk controls then apply to the matches (labelled "Show all matches"). Each field row carries its vendored Airtable field-type icon. The component is presentational: it takes the schema + the hidden-field set and emits the next set; the consumer (Visualize) owns persistence and drops the hidden rows. The red Clear in the toolbar resets it alongside the other facets. Component: components/schema/FieldsFilter.tsx. Live: Schema → Visualize.Do
- Use checkboxes (tri-state) here, not toggles — groups must show an indeterminate (partial) state.
- Cascade a group toggle to all its fields; show a visible/total count on every group and in the trigger.
- Match the faceted-filter chrome: same trigger active-state, same Search box, same Show all / Hide all.
- When a search is active, scope Show all / Hide all to the matches and label them as such.
- Show each field row’s Airtable field-type icon; collapse groups for very large schemas.
Don’t
- Don't filter bases or tables here — that's the faceted filter's job; Base/Table levels are only grouping + bulk for fields.
- Don't colour the checkbox (no checkbox-primary); keep it the one neutral checkbox used everywhere.
- Don't default to hiding fields (no "show first N") — show all; the user hides what they don't want.
Trigger — default · active (visible/total)
Open popover — tree of tri-state checkboxes (Sales CRM partial, Companies all, Deals partial, Marketing collapsed)
Deleted items (hidden by default · reveal + mark)
daisyUI + customcomponents/schema/SchemaBrowse.astro Entities deleted in the source are kept for history, hidden by default, revealed by one toggle and shown muted + dated.
unknown) entities by default; removed ones are hidden. A single neutral checkbox — "Include deleted" with a count badge (the discoverable "N deleted" affordance) and a tooltip — reveals them. Revealed rows render muted with a neutral "Deleted" badge and a caption "no longer in Airtable since <date>". They stay selectable: the entity panel opens read-only with a banner ("no longer exists in Airtable, showing the last backup") and the last-known values, no edit / publish / AI. unknown entities (couldn’t be confirmed this run) are NOT treated as deleted and stay visible. Deleted entities never appear in the live Visualize diagram or the field-visibility picker. No Restore here — Schema is read-only; restoring lives in the Backups flow. Components: components/schema/SchemaBrowse.astro + EntityPanel.astro. Live: Schema → Browse.Do
- Hide removed by default; reveal with ONE neutral checkbox + a count badge (the discoverable "N deleted").
- Mark a revealed item: muted row + a "Deleted" badge + the removal date ("no longer in Airtable since …").
- Keep removed items inspectable but read-only — the panel shows a banner + last-known values, no edit/publish/AI.
- Keep `unknown` (unconfirmed) items visible; only `removed` hides behind the toggle.
Don’t
- Don't offer Restore here — Schema is read-only; restoring belongs in the Backups flow.
- Don't treat `unknown` as deleted, and don't show deleted entities in the live diagram or field picker.
- Don't colour the toggle/checkbox or the Deleted badge red — deletion is neutral history, not an error.
Browse — "Include deleted" on, a deleted field revealed (muted + badge + date)
Entity panel — read-only banner for a removed entity
Prompt editor (resolution + override)
daisyUI + customcomponents/schema/SchemaHealth.astro Editable AI prompt with a System default ▸ Space ▸ Override resolution, reset/remove, and Pro+ gating.
components/schema/SchemaHealth.astro (the drawer body) + components/ui/Drawer.astro.Do
- Always show the resolution chips with the EFFECTIVE level highlighted, so the in-force prompt is unambiguous.
- Adapt the footer to scope: Save prompt / Reset to default at Space level; Save override / Remove override at entity level.
- Open it in a right Drawer from the metric/insight it configures; reuse one drawer, populate per item.
- Gate editing behind Pro+: read-only + an upgrade affordance below Pro+, never a dead control.
- When the prompt changed since the last run, show a stale note + a Re-run (with its credit cost).
Don’t
- Don't hide which scope is effective — the three-level resolution is the whole point.
- Don't use a modal; this is a focused side task that should keep the page in view (Drawer).
- Don't let the prompt look editable below Pro+ — make the read-only + upgrade state explicit.
Editor body — Space-level effective, override available
Insight card + list
daisyUI + customcomponents/schema/SchemaHealth.astro AI schema observations: typed card (category · date · observation · evidence · entity chips) in an active list with an archived toggle, show-more, and per-card archive.
data-entity-open (same chips as Browse / Docs). A hover-revealed Archive moves a card to the archived set (reversible via Restore); archived are hidden by default, shown muted + labelled behind the toggle. Long lists show the first 6 then “Show N more”. Built on the same field-type icons as the rest of Schema. Components: components/schema/SchemaHealth.astro; pairs with Prompt editor for config.Do
- Lead each card with a typed category + the observation; keep copy advisory and concrete (claim + evidence), never alarmist or pejorative.
- Render entity references as clickable tag chips (type icon + name) that open the SAME shared sidebar as Browse / Docs — chips are the only accent on the card.
- State source + freshness once in the section header (AI · generated date), with Configure / Re-run / Show-archived there — keep individual cards clean.
- Hide archived by default; reveal muted + labelled behind one toggle. Make archive an explicit, reversible disposition (Restore), not a silent vanish.
- Show the first handful and a “Show N more”; reuse the Prompt editor (Pro+) for space-level + per-base override config.
Don’t
- Don't decorate AI output with gradients, glass, sparkle-cards, or hero numbers — provenance is a quiet text line, not chrome.
- Don't phrase insights as commands or alarms; they're observations with a suggestion.
- Don't build a bespoke chip or sidebar — reuse the entity tag chip + detail sidebar so a tag behaves identically everywhere.
- Don't dump every insight at once or let archived items clutter the default list.
Section header + one insight card
Insights 7
AI · generated Jun 22, 2:07 PMTickets fan out to Queues at a high ratio — a single queue holds most open tickets, so it dominates the size of any restore that includes it. Consider scoping restores per queue.
One queue accounts for ~70% of linked tickets
Relationship row + list
daisyUI + customcomponents/schema/SchemaRelationships.astro A relationship as a scannable "A ↔ B" row (status dot · type icon · entities · cardinality · soft badges), with inline Confirm/Dismiss for inferred ones and a right detail panel that clicks through to the shared entity sidebar.
→ one-way / ↔ reciprocal), a small mono cardinality token, and up to two soft badges (Inferred primary, Removed history warning). Filters use the same faceted toolbar as Visualize / Browse (Dan's spec, for cross-tab consistency): Bases · Type · Status · Validity facet dropdowns + an Include-removed toggle. Inferred (synced-view) rows carry inline Confirm / Dismiss (and a bulk bar); Confirm promotes inferred → declared (badge drops), Dismiss removes it; declared rows have no such actions. Selecting a row opens a right detail panel — the A/B entities as click-through chips (→ the shared entity sidebar), a provenance line, and (Dan round-2) a rebuilt body: the redundant "Links" list is GONE (the A↔B pair IS the single primary link); formula/rollup get a "Linked Fields" section listing the OTHER referenced fields (same table); and removed links move into a "Changelog" history (added/removed events with dates) instead of the summary. Synced tables are user-declared (the API can't detect sync links): a primary "New synced relationship" toolbar button + an Edit action on every synced row open a right-Drawer form whose Synced/Source table pickers reuse the shared EntitySearch typeahead (the base is derived from the synced table); save inserts/updates the declared synced-view row. Components: components/schema/SchemaRelationships.astro + schemaRelationships.ts + RelationshipPanel.astro.Do
- Render a relationship as one scannable line: status dot · type icon · A ↔ B (entities primary, fields muted) · cardinality · ≤2 soft badges.
- Use the SAME faceted toolbar as Visualize / Browse (Bases · Type · Status · Validity facets + Include-removed) so the structural tabs read consistently.
- Give inferred (synced-view) rows inline Confirm/Dismiss + a bulk bar; declared relationships get none. Confirm promotes in place, Dismiss removes.
- Open a right detail panel whose entity chips click through to the SAME shared sidebar as Browse/Docs; show provenance + per-link removed dates.
- Degrade-in-place: removed links are history behind Include-removed; an all-links-removed relationship stays visible, flagged invalid (dimmed + grey dot).
- Phrase inferred provenance as "inferred from usage / sync-source", never "AI guesses" — more trustworthy for an ops audience.
Don’t
- Don't invent a bespoke filter shape for this tab — reuse the faceted toolbar the other Schema tabs use.
- Don't offer Confirm/Dismiss on declared (API-derived) relationships — only inferred ones are a guess.
- Don't silently drop removed/broken relationships — keep them as flagged history (a stale map is worse than none).
- Don't build a bespoke detail sidebar — reuse the shared entity sidebar via data-entity-open.
Two rows — a declared link (with removed history) and an inferred synced view
Automations & Interfaces (manual registry)
daisyUI + customcomponents/schema/SchemaAutomations.astro Two Schema tabs to hand-register the automations/interfaces Airtable's API can't export: grouped/nested listings, a right-drawer create/edit form with a raw Definition JSON field + a Table/Field tag-picker (auto vs manual chips), soft-delete, below-tier upsell, and bidirectional "Referenced by" surfacing.
EntitySearch — auto-derived tags render tinted + non-removable, manual tags outlined with an ×. There is no raw-definition JSON input — the definition is API-only (scraped automations), so it's never hand-entered and shows read-only in the detail only when a value exists. Fields (Dan round-2): automations carry an On/Off status in a dedicated Status column via our status badges (badge-soft + a bg-current dot — Active = success, Inactive = neutral, Removed = warning; distinct states, not one grey pill), a Trigger chosen from a dropdown of Airtable's canonical trigger types (When a record is created / updated / matches conditions / enters a view · At a scheduled time · form / webhook / button · Integrated — free text is gone; the API can't export automations so it's manual input, surfaced as a labeled Trigger line in the detail), two descriptions in Airtable vs Internal tabs (automations DO have an Airtable description, but the API can't sync it — so there is no Publish, just save/edit; mirrors the EntityPanel field pattern minus write-back), and email subscribers (chip input); interfaces/pages show a Published / Not published status in a Status column (same badges, for interfaces AND pages) and an Internal-note-only description (they have no Airtable description). The row tag-count reads "N tagged" (labeled, not a bare icon). Change history: the read drawer gains a Changelog section (this entity's own added/renamed/removed/config events); the same events also appear in the Changelog tab as base ▸ [concept icon] name rows (a status change reads e.g. "Automation turned off · Active → Inactive"). Bidirectional tags: a table/field's shared entity sidebar gains a "Referenced by" section listing the automations/interfaces that tag it, each click-through jumping to its tab + opening its detail. Components: components/schema/SchemaAutomations.astro + schemaAutomations.ts, SchemaInterfaces.astro + schemaInterfaces.ts; reuses ui/Drawer.astro, EntitySearch.astro, EntityPanel.astro.Do
- Frame the empty state honestly — name the API blind-spot ("Airtable's API can't export these, register them here") + one primary Register CTA. Reuse the same skeleton for the below-tier upsell.
- Automations = collapsible groups (count badge + "No group" bucket); Interfaces = parent rows with nested Pages ("N pages" sub-count), one level only.
- Open create/edit in the right Drawer (project default), never a daisyUI modal. Scalars first (incl. the required Base), then the tag-picker.
- Tag-picker: reuse Browse EntitySearch; auto-derived tags are tinted + non-removable, manual tags outlined with an × (only manual are removable).
- Associate every automation/interface with a single Base (required) and group the listings + sidebars by Base, reusing the Browse tree header/visual. Don't offer a raw-definition JSON input — it's API-only, shown read-only only when present.
- Surface tags both ways: on the entity's sidebar show a "Referenced by" section (the automations/interfaces tagging it), click-through to that tab.
- Soft-delete, never hard-delete: removed rows stay muted with a "Removed from Airtable" badge behind Include-removed.
Don’t
- Don't use a modal for the create/edit form — the project default is the right Drawer / entity sidebar.
- Don't nest Interfaces deeper than interface → pages (one level; keep it dense).
- Don't make auto-derived tags removable or visually identical to manual ones — the source distinction is the point.
- Don't hard-validate the Definition JSON against a schema — it's opaque; only check it parses.
Automations — a collapsible group with two rows
Interface with nested Pages + auto/manual tag chips
App-layer graph (Visualize · Automations & Interfaces)
daisyUI + customcomponents/schema/SchemaCanvas.tsx The third Visualize mode: automations, interfaces, and pages graphed over the table/field substrate with three typed edge kinds (references / reads / triggers), a legend that doubles as the node-type key, Base + node-type filters, field-under-table collapse, and node click-through to the shared detail.
schema:openEntity); an automation/interface/page node switches to its tab + opens its read drawer (schema:openAutomation / schema:openInterface) — the same handoff the "Referenced by" jump uses. Empty / upsell: no captured entities points at the Automations/Interfaces tabs; below the tier it's the quiet upsell. Depends on the manual registry for entities; triggers edges render from captured page→automation links. Component: components/schema/SchemaCanvas.tsx (appLayout() + AppEntityNode / AppFieldNode).Do
- Encode node TYPE as a coloured chip + icon inside a neutral card; reserve fill/opacity for the removed state (five types, no rainbow).
- Distinguish the three edge kinds by colour + dash + arrowhead + an inline label — never colour alone; keep the structural "contains" connector faint and out of the legend.
- Make the legend the single source of truth: legend swatch colour == node accent == the Node-types filter chip.
- Collapse fields under their table by default (edge docks to the table, field name on the label); reveal them with the Expand-fields toggle.
- Lay out structurally with dagre LR (app entities left → data right); never force-directed ("no hairballs").
- Reuse the shared toolbar + faceted filters (Bases · Node types · Include removed) so the mode reads identically to Data / Relationships.
- Click-through to the shared detail: table/field → entity sidebar; automation/interface/page → its tab + read drawer (same handoff as Referenced-by).
- Hovering an edge highlights it + its two endpoints (dims the rest) and shows a tooltip — the kind, A→B, the field it goes through, and a one-line consequence (impact analysis); click opens the source. Same edge-inspection pattern as the Relationships graph. The structural "contains" connector stays inert.
Don’t
- Don't spin up a second React Flow island — it's a MODE on the existing Visualize canvas.
- Don't colour the whole node by type — the chip + icon carry it; a five-colour node soup is the anti-pattern.
- Don't rely on edge colour alone to tell the kinds apart — add dash + arrowhead + label.
- Don't use a force-directed layout; keep it structural (dagre), collapse fields, and filter to stay legible at scale.
- Don't hard-hide removed entities — mute them behind Include-removed so the graph doubles as history.
Typed nodes — automation / interface / page / table
Legend — node accents + the three edge kinds
Schema chat (threads + context + references)
daisyUI + customcomponents/schema/SchemaChat.astro A dense, utility AI chat about the schema: thread rail + conversation + composer, a context bar of removable scope chips, clickable entity/doc references, convert-to-doc, Pro+ gate.
components/schema/SchemaChat.astro + schemaChat.ts.Do
- Keep it dense + utility: label + subtle background + alignment for sender, no avatars or gradient bubbles.
- Make context visible + editable: a persistent context bar of removable scope chips + doc chips; "Whole Space" when none.
- Render in-reply references as the SAME chip component as the context bar; entity → shared sidebar, doc → the Docs tab.
- Convert-to-doc drops a linked reference card in the thread (Open), and the doc lands in the Docs tab namespace.
- Gate behind Pro+ with a discoverable upgrade state (not a hidden route); show a credits hint + a Send/Stop streaming state.
Don’t
- Don't build a consumer chat (avatars, big gradient bubbles, emoji reactions) — it's a schema utility.
- Don't hide what the AI can see — the context bar is the whole point; never leave scope implicit.
- Don't invent a new chip for references — reuse the entity tag-chip so a reference behaves like everywhere else.
Assistant reply with a references row + a convert-to-doc card
Backup depth + schedule
daisyUI + customcomponents/backups/BackupScheduleScope.astro Three depth toggles (Schema always-on · Record data · Attachments) drive the schedule: a dynamic-titled data/attachments box plus a standalone Schema box that ties to the data schedule by default or splits to its own cadence. Per-cadence tier gating + next-run.
[data-frequency] mirror carries the effective primary cadence so the host cleanup/review keep working. Components: components/backups/BackupScheduleScope.astro + CadencePicker.astro. Live: Configure backup → Options. Research: Slack “different settings for mobile”, billing “same as shipping”, Veeam immediate-vs-periodic.Do
- Drive the schedule from the depth toggles: show the data/attachments box only when Record data or Attachments is on, with a title that names what's in it.
- Default the Schema box to “Same schedule as the data backup” (checked); show the tied state as a greyed read-only cadence preview, not a hidden control.
- On untie, reveal schema's own cadence picker indented under the checkbox, using the same CadencePicker as the data box.
- Name what's inherited in the tie label — never a bare “With data” (research: proven refs always name the source).
- Render every cadence option; lock the tiers above the plan with a trailing lock + “· Pro/Launch” and click-through to billing.
- Warn (non-blocking) only when untied AND schema is less frequent than data — data backups already capture schema, so it's redundant.
Don’t
- Don't bring back scope radio-cards — the depth toggles are the single source of what's backed up; schema-only is emergent.
- Don't give data and attachments separate schedules — they always share one box.
- Don't fully hide the schema cadence when tied — a utility user wants to see when schema actually runs (greyed preview).
- Don't hide locked cadences — keep them visible + lock-badged with the upgrade path.
Scope cards + a cadence row with a locked tier
Filter toolbar (search + filters)
daisyUI + customstyles/global.css (.sch-tb) One filter-bar layout for every page: search │ filters … → a tab-specific right cluster. Never a different shape per tab.
.sch-tb, .sch-tb-search, .sch-tb-div, .sch-tb-right, .sch-tb-count, .sch-tb-check in global.css) so the Astro tabs AND the React Visualize island share the exact same layout. Rules that come with it: section-wide metadata (a freshness stamp) lives at the page-title level, not inside a tab’s toolbar; checkbox filters are one neutral checkbox everywhere (never a coloured checkbox-warning variant) with a tooltip explaining what they do; toolbar action buttons are Secondary btn-neutral (Add to doc, Export) — blue (primary) is reserved for the main CTA only. Live: Schema (Browse / Visualize / Changelog).Do
- Lay every filter toolbar out the same way: search │ divider │ filters … → right cluster. Reuse the global .sch-tb classes, never a per-tab bespoke layout.
- Put section-wide metadata (a freshness/“as of” stamp) at the page-title level, right-aligned — not inside one tab’s toolbar.
- Use ONE neutral checkbox for every boolean filter, each with a tooltip; keep action buttons Secondary (btn-neutral) and reserve blue for the primary CTA.
- Give every tab a search box (even Changelog) so users can search the same way everywhere.
Don’t
- Don't give each tab a different toolbar shape, or split search and filters onto separate rows.
- Don't colour a filter checkbox (no checkbox-warning) — that made one tab look unlike the others.
- Don't randomly mix ghost and neutral action buttons, or use primary blue for a plain Export/utility.
One row — search │ divider │ filters → right cluster
Metric tile strip
daisyUI + customviews/SpaceHomeView.astro (.hm-kpis) · components/schema/EntityPanel.astro (.ep-stats) The one way to show a small set of headline numbers: a bordered strip of equal tiles split by dashed dividers.
base-100 card (1px base-300 border, rounded, overflow:hidden) holding a grid of equal-width tiles; each tile is a label + a muted icon on top (label left, icon right), then the big tabular value, then a muted sub-label; tiles are separated by a dashed left border, not a gap. The value can be a number or a small composite (e.g. a health dot + word). Set the column count to the number of tiles so they always fill the strip. Used by the Home KPI strip (.hm-kpis) and the Schema entity panel stats (.ep-stats) — reuse it anywhere a few key numbers need to read as part of the system.Do
- Use this strip for any short row of headline numbers; give it as many equal tiles as you have metrics.
- Per tile: label + muted icon on top, the big tabular value, a muted sub-label underneath.
- Separate tiles with a dashed left border inside one bordered card — not free-floating cards with gaps.
Don’t
- Don't fall back to a bare row of stacked number/label text (that's what reads as off-system).
- Don't give each tile its own border/shadow — it's one card divided by dashed lines.
Three tiles — number · number · composite (health)
Entity detail panel
daisyUI + customcomponents/schema/EntityPanel.astro The shared stacking detail panel for a base / table / field — drill in, breadcrumb back.
UINavigationController: selecting a child or related entity swaps the panel to that entity and pushes a crumb onto a sticky breadcrumb trail (Base ▸ Table ▸ Field); the back arrow or a crumb pops; drilling a different branch truncates the trail past the branch point. Descriptions use two source tabs — Airtable (Public) and Internal, each with a role meta line. The Airtable copy is the only one that syncs back: editing it locally flags it out of sync (a Draft badge on the heading + a Draft pip on the entity's list row) and surfaces Publish to Airtable, a guarded write-back (a confirm card with a stale-warning when the live value drifted → Publishing… → Synced) that overrides the live Airtable description. Internal is Baseout-only and never syncs. AI is not a stored tab but a Generate action (Pro+, cost shown on the button) available inside either tab — the Airtable draft is public-facing; the Internal draft is prompted to be more technical/verbose. The Internal note still never syncs to Airtable. Edits autosave on navigate so nothing is lost. Sections: Context+type, Descriptions, Children, Relationships, Documentation (the docs that tag it). Built from Drawer visuals + Badge + status dots + Button; the body is rendered at runtime so its styles are global. Component: components/schema/EntityPanel.astro.Do
- Reuse this one panel for every entity drill-in (Browse rows, Docs chips) — build the detail once.
- Keep the breadcrumb trail in the header; back / a crumb / Esc pops one level.
- Treat the Airtable description as the only synced source: edit → Draft → Publish overrides Airtable; once synced, show a quiet "Synced" marker, no button.
- Keep the Internal tab Baseout-only (an "Internal · never synced" meta line) — it never syncs to Airtable, but it CAN be AI-generated via its own Generate action (prompted to be more technical/verbose than the public copy). AI stays a Generate action inside each tab, not a third stored description.
- Stay inside the data boundary: counts read "as of last backup", no value-statistics section.
Don’t
- Don't open a second floating modal over the panel — push a sheet onto the same stack instead.
- Don't let the panel exceed ~40% of the viewport, and keep the list interactive behind it.
- Don't sync the Internal description to Airtable, and don't keep AI as its own persisted tab (it's a Generate action inside each tab, not a stored tab).
- Don't invent fields Airtable can't provide (record stats, workspace grouping).
Panel header (breadcrumb stack) + a field detail
Descriptions — Airtable copy edited (out of sync → Publish)
Annotation field (AI · draft → publish)
daisyUI + customcomponents/schema/EntityPanel.astro Editable text that may be AI-generated, can be public or internal, and syncs to an external system through a draft → publish lifecycle.
The placement hierarchy is the whole point — every helper has ONE fixed home, so two never collide:
- Section heading — the draft flag, its single home. When the synced copy has unpublished edits, the
Draftbadge folds its "Not yet published to Airtable" explanation into one badge in the Descriptions heading — scannable at the top, and never repeated in the body (a duplicated status top-and-bottom is exactly what this avoids). - The box — the value, and only the value, and it IS the edit target. Read text in place; click it (or focus + Enter) to drop straight into a generous textarea at the caret — no separate "Edit" button to gate it. A hover wash + a corner pencil signal it's editable. The scope caption, status line and actions are siblings below the box, never inside it.
- Below the value — scope caption. A quiet footnote for what the field IS (
Public · shown in Airtable/Internal · never synced) — placed under the value as a note, not above it as a competing header. Persistent; independent of the content. - Under the caption, above the actions — ONE status line, mutually exclusive. In edit mode it is the AI disclaimer ("AI-generated — review before publishing", or "…before saving" for a copy that doesn't sync); at rest, once published, the calm Synced confirmation. The draft state is up in the heading, so these never stack.
- Bottom — actions. Save / Regenerate / Cancel in edit; Publish / Edit / Discard at rest. The destructive one (Discard) is pushed to the right.
- Floating — confirmation. A transient success toast on Publish, auto-dismissed; it never takes layout space.
Net result: a clean top-to-bottom read — heading flag · tabs (with a baseline rail, not floating labels) · the value box · then a scope caption and at most one status line below it — never a pile.
Prior art (what this is built from): AI generate + "review for accuracy" disclaimer — Square, Udemy; draft → publish + autosave — Patreon, X, Intercom; sync + overwrite / conflict copy — Gorgias, Klaviyo; Draft badge in a list — Confluence. Component: components/schema/EntityPanel.astro.
Do
- Give every helper one fixed home: the draft flag in the heading, the value in the box, the scope caption + one status line below it, actions at the bottom — so two helpers never collide.
- Give source tabs a full-width baseline rail with the active tab sitting on it — otherwise the labels read as floating text, not tabs.
- Make the value box itself the edit target (click → edit at the caret, plus focus + Enter); never gate editing behind a separate Edit button. Give the editor generous height.
- Keep the under-value slot single-occupancy: the AI disclaimer in edit mode, the sync status at rest, never both.
- Confirm a destructive sync (Publish) with a confirm step + a stale-warning when the remote drifted; celebrate success with a transient toast, not a permanent line.
- Show a paid action's cost on the button (e.g. "Generate · 10 credits"), and reuse this whole field for any write-back annotation so the lifecycle looks identical everywhere.
- When a button has a smaller cost/suffix (e.g. "10 credits"), wrap the label + suffix in ONE inline span so they share a baseline — never leave the suffix as a separate flex item beside the icon, or it drifts off-centre.
Don’t
- Don't stack the identity caption, AI disclaimer, and sync status at once — that pile-up is exactly what this layout prevents.
- Don't bury the AI-credit cost in a tooltip, and don't make Publish a one-click destructive write (confirm it; warn on remote drift).
- Don't keep AI as a stored tab — it's a Generate action that seeds the field, not a third saved value.
Edit mode — AI disclaimer owns the under-input slot
At rest — sync status owns the same slot
Entity typeahead & tag chip
daisyUI + customcomponents/schema/EntitySearch.astro One search-as-you-type control for entities, and the inline chip that references them.
@-mention. It is an Input + a dropdown of matches grouped by kind (Bases / Tables / Fields), each row = the vendored field icon + name + parent path + a health dot, with ↑/↓ + Enter keyboard nav and a key-hint footer. The tag chip is the entity identity rendered inline in a doc: a primary-tinted pill (type icon + name) that is clickable in both edit and reading mode and opens the entity panel; a chip whose entity was removed from Airtable flips to an error-tinted "no longer in schema" state instead of being silently dropped. Components: components/schema/EntitySearch.astro (emits schema:searchInput for filter-in-place and a pick event) + the chip in SchemaDocs.astro.Do
- Reuse the one typeahead for every "find an entity" need (search, add-tag, @-mention).
- Group results by kind and show the parent path so the right entity is unambiguous.
- Render entity tags as identity chips (icon + name), clickable to the entity panel.
- Flag a removed entity on its chip; never silently drop a reference.
Don’t
- Don't build a second bespoke entity search — extend this one.
- Don't store a name snapshot in a chip; store the id and render the live name.
Typeahead dropdown (grouped) + inline tag chips
Connection-health banner
daisyUI + customcomponents/layout/ConnectionHealthBanner.astro · ConnectionHealthPill.astro · connection-health-banner.ts App-wide warning when a source / destination connection breaks — plus its topbar pill.
app-banner slot in SidebarLayout; collapsing a broken bar tucks it into a compact pill in the topbar next to the bell (it never silently disappears while broken). Warning and success states dismiss with an ×. Built from the daisyUI alert primitive plus our Button and Lucide icons; copy uses *emphasis* markers rendered bold. Live: /connection-banner.State → colour
The state is the API; colour, icon and copy follow from it. Reserve loud red (broken) for a real auth failure that stopped backups; warn amber before total silence; debounce transient blips so we never cry wolf.
| Use | When | Why |
|---|---|---|
| broken · alert-error | Token revoked or expired — backups stopped. Source, destination, or a grouped roll-up when 2+ are down. | Highest urgency; collapses to a persistent topbar pill, never a silent dismiss. |
| expiring · alert-warning | Token TTL known and close — warn before it dies. | Proactive; fix it with zero interruption. Dismissible. |
| degraded · alert-warning | No successful backup in N hours, not yet a hard auth failure (auto-retrying). | Heads-up without alarming on a transient blip. |
| reconnecting · alert-info | Re-auth in progress — verifying access with named checks. | Transient progress; spinner, no action needed. |
| restored · alert-success | Verified, and the missed backup re-queued. | Positive confirmation in place; auto-dismiss. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| state | 'broken' | 'expiring' | 'degraded' | 'reconnecting' | 'restored' | — | The graded health state — drives colour, icon, copy and behaviour. |
| provider | string | Airtable | Connection display name, shown bolded in the copy. |
| side | 'source' | 'destination' | source | Which side broke — shapes the broken copy. |
| count | number | 1 | >1 renders the grouped roll-up (broken only). |
| names | string[] | [] | Connection names listed in the grouped roll-up. |
| lastBackup | string | — | e.g. "2 days ago" — appended to broken-source copy. |
| daysToExpiry | number | 5 | Days remaining, used by the expiring state. |
| reconnectHref | string | # | Where the Reconnect CTA points. |
| collapsible | boolean | true (broken) | Show the collapse chevron that tucks the bar into the topbar pill. |
| bleed | boolean | false | Full-bleed bar (no side / top radius) for the app-shell slot; a rounded card otherwise. |
| group | string | main | Ties a collapsible bar to its topbar pill (same group string). |
Do
- Lead with the consequence and a single verb CTA ("Backups paused. Reconnect.").
- Keep one bar at a time — roll 2+ broken connections into the grouped variant.
- Use the bleed bar in the SidebarLayout app-banner slot; let a broken bar collapse to the topbar pill.
- Give the icon a soft tinted chip; keep the close / collapse control a neutral grey.
Don’t
- Don't let a broken bar be dismissed outright — it collapses to the topbar pill and stays until resolved.
- Don't colour the collapse / dismiss icon button (a ghost inherits red here) — force the neutral grey.
- Don't stack multiple bars; roll them up instead.
- Don't promise “expires in N days” unless the provider token API actually exposes a TTL.
State → colour (the graded model)
<div class="flex flex-col gap-2">
<div role="alert" class="alert alert-soft alert-error flex items-center gap-3">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-error/15 text-error"><span class="iconify lucide--triangle-alert icon-md"></span></span>
<div class="min-w-0 flex-1"><div class="font-semibold leading-snug">Backups paused: your <strong>Airtable</strong> connection expired.</div></div>
<a class="btn btn-primary btn-sm gap-1.5"><span class="iconify lucide--refresh-cw icon-sm"></span>Reconnect</a>
</div>
<div role="alert" class="alert alert-soft alert-warning flex items-center gap-3">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-warning/15 text-warning"><span class="iconify lucide--clock icon-md"></span></span>
<div class="min-w-0 flex-1"><div class="font-semibold leading-snug">Your <strong>Google Drive</strong> connection expires in <strong>5 days</strong>.</div></div>
<a class="btn btn-neutral btn-sm gap-1.5"><span class="iconify lucide--refresh-cw icon-sm"></span>Reconnect</a>
</div>
<div role="alert" class="alert alert-soft alert-info flex items-center gap-3">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-info/15 text-info"><span class="iconify lucide--refresh-cw icon-md animate-spin"></span></span>
<div class="min-w-0 flex-1"><div class="font-semibold leading-snug">Reconnecting <strong>Airtable</strong>…</div></div>
</div>
<div role="alert" class="alert alert-soft alert-success flex items-center gap-3">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-success/15 text-success"><span class="iconify lucide--circle-check icon-md"></span></span>
<div class="min-w-0 flex-1"><div class="font-semibold leading-snug">Connection restored. Backups are running again.</div></div>
<button class="btn btn-ghost btn-sm btn-square text-base-content/55 hover:text-base-content" aria-label="Dismiss"><span class="iconify lucide--x icon-md"></span></button>
</div>
</div> Broken — full anatomy (icon chip · bold copy · primary Reconnect · neutral collapse)
<div role="alert" class="alert alert-soft alert-error flex items-center gap-3">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-error/15 text-error"><span class="iconify lucide--triangle-alert icon-md"></span></span>
<div class="min-w-0 flex-1">
<div class="font-semibold leading-snug text-base-content">Backups paused: your <strong>Airtable</strong> connection expired.</div>
<div class="mt-0.5 text-sm text-base-content/70">Nothing is being backed up until you reconnect. Last successful backup: <strong>2 days ago</strong>.</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<a class="btn btn-primary btn-sm gap-1.5"><span class="iconify lucide--refresh-cw icon-sm"></span>Reconnect</a>
<button class="btn btn-ghost btn-sm btn-square text-base-content/55 hover:text-base-content" aria-label="Collapse to topbar"><span class="iconify lucide--chevron-up icon-md"></span></button>
</div>
</div> Grouped roll-up — 2+ connections down
<div role="alert" class="alert alert-soft alert-error flex items-center gap-3">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-error/15 text-error"><span class="iconify lucide--triangle-alert icon-md"></span></span>
<div class="min-w-0 flex-1">
<div class="font-semibold leading-snug text-base-content">3 connections need attention. Backups are paused.</div>
<div class="mt-0.5 text-sm text-base-content/70"><strong>Airtable</strong>, <strong>Google Drive</strong> and <strong>Dropbox</strong> have stopped working. Reconnect them to get backups running again.</div>
</div>
<a class="btn btn-primary btn-sm shrink-0">Review connections</a>
</div> Collapsed — the topbar pill (sits next to the bell; ghost Reconnect)
<div class="flex items-center justify-end gap-2 rounded-box border border-base-300 bg-base-100 px-3 py-2">
<span class="alert alert-soft alert-error inline-flex w-fit items-center gap-2 rounded-full py-1 ps-2.5 pe-1">
<span class="inline-flex items-center gap-1.5 text-sm font-medium">
<span class="grid size-5 shrink-0 place-items-center rounded-md bg-error/15 text-error"><span class="iconify lucide--triangle-alert icon-xs"></span></span>
Airtable needs reconnecting
</span>
<a class="btn btn-ghost btn-sm gap-1.5"><span class="iconify lucide--refresh-cw icon-sm"></span>Reconnect</a>
</span>
<button class="btn btn-circle btn-ghost btn-sm" aria-label="Notifications"><span class="iconify lucide--bell icon-lg"></span></button>
</div>