aobalabs design system
Shared visual vocabulary for sites in the aobalabs family. Pick a theme from the switcher to preview each component in the tenant palette it will ship in.
How to use this page. Each demo shows a live snippet and the templ call that produced it. Click through the themes to verify a primitive reads correctly across every tenant before adopting it.
Foundations
Color tokens
All colours go through CSS custom properties so each tenant theme
swaps the palette at :root[data-theme="<tenant>"].
Use semantic tokens (--text-muted, --danger)
over raw hex anywhere outside the package's themes.css.
Surface
-
--bg-primarypage background -
--bg-elevatedcards, nav drawer, inputs -
--bg-tagchips, subtle fills
Text
-
--text-primarybody, headings -
--text-mutedmeta, hints, labels -
--text-faintde-emphasised meta, dropped
Border
-
--border-subtledefault field, card edge -
--border-focusfocused input, active field
Semantic
-
--accentprimary action, active state -
--successdone, paid, healthy -
--warningsnoozed, due soon -
--dangererrors, destructive actions
Primitives
Buttons
Four variants, each backed by a Go const on
ButtonVariant. The variant value IS the CSS class
suffix so adding a new style is one Go const + one CSS rule.
Primary
the dominant action on a screen — Capture, Save, Create
@Button(ButtonPrimary, "button", false) { Capture }Secondary
supporting action — Back, Cancel, alternate path
@Button(ButtonSecondary, "button", false) { Cancel }Ghost
low-emphasis action, used in dense rows
@Button(ButtonGhost, "button", false) { Edit }Destructive
irreversible action — Drop, Archive, Delete
@Button(ButtonDestructive, "button", false) { Drop }Links
Default, muted (for row meta), arrow (for section-to-section navigation). The arrow variant lifts on hover to telegraph the destination.
Default link
inline action or navigation target
@Link(LinkDefault, "#") { view all captures }Muted link
metadata link, used in row meta
@Link(LinkMuted, "#") { 2 days ago }Arrow link
section-to-section navigation
@Link(LinkArrow, "#") { go to triage }Chips
Compact state and kind labels. Soft variants (success / warning / danger) pair a background tint with the matching text colour so the tone reads at a glance in dense rows.
Status chip
current entity state in dense lists
@Chip(ChipStatus, "active")Kind chip
discriminator for task / capture variants
@Chip(ChipKind, "payment")Location chip
shorthand for hierarchical location refs
@Chip(ChipLocation, "Sendai")Soft chip — success
outcome state — paid, done, healthy
@Chip(ChipSuccess, "paid")Soft chip — warning
transitional state — snoozed, due soon
@Chip(ChipWarning, "snoozed")Soft chip — danger
blocked / overdue / failed
@Chip(ChipDanger, "overdue")Keyboard chord
Single visual unit for any keypress hint. Compose multi-key chords as one string ("⌘K") rather than chaining multiple Kbd calls — the design treats each chip as one unit.
Single key
single keypress with no modifier
@Kbd("Esc")Chord
modifier + key combo — render as one chip
@Kbd("⌘K")Inline in prose
embedded in body copy — sized down a touch
Press @Kbd("⌘B") to toggle the nav.Status dot
Tiny coloured circle that pairs with adjacent label text. Four tone variants. Site-specific score-coloured variants (e.g. a 0–10 motivation scale) live in the consuming site, not here.
Active
currently in scope — default colour, no chip needed
@StatusDot(StatusDotActive, "active") in progressSnoozed
deliberately deferred — warm tone
@StatusDot(StatusDotSnoozed, "snoozed") deferred to next weekDone
completed — success tone
@StatusDot(StatusDotDone, "done") shipped 2 days agoDropped
intentionally abandoned — muted tone
@StatusDot(StatusDotDropped, "dropped") dropped — not pursuingAvatar
Circular image at two sizes — small for dense list rows, large for detail headers — with an initials fallback when no image is set. Private-file consumers point the URL at an auth-gated serve route that redirects to a presigned URL (ADR 0036).
Small — image
dense list rows
@Avatar(AvatarSmall, url, "AL", "Ada Lovelace")Large — image
detail headers
@Avatar(AvatarLarge, url, "AL", "Ada Lovelace")Initials fallback
no image uploaded
@Avatar(AvatarLarge, "", "AL", "Ada Lovelace")Tabs
Segmented control for in-page view switching. The active tab
carries the underline; one Tab per --view=… filter.
View filter tabs
segmented control for in-page view switching — list view filter, future activity-feed filters
@Tabs("Filter") {
@Tab("#", "Active", true)
@Tab("#", "Snoozed", false)
@Tab("#", "Dormant", false)
@Tab("#", "Done", false)
@Tab("#", "Dropped", false)
@Tab("#", "All", false)
}Combobox
Free-text input + autocomplete dropdown. Type to create; press
↓ then Enter to attach an existing item. It
submits two fields — the typed text and a hidden id — so one input
means both "create this" and "attach that". Transport-agnostic: the
consumer wires hx-get to fill the listbox. Pair with one
@ComboboxScript() per page.
Combobox
free-text input + keyboard-navigable suggestion list — type to create, ↓ + Enter to pick an existing item. Pair with one @ComboboxScript() per page.
- Renew passport
- Pay NHK bill
- Email the accountant
@Combobox(ComboboxParams{
ID: "task-add", TextName: "title", ValueName: "task_id",
Placeholder: "Add or find a task…",
InputAttrs: templ.Attributes{
"hx-get": "/today/suggest", "hx-trigger": "keyup changed delay:150ms",
"hx-target": "#task-add-listbox",
},
}) {
// listbox filled by the server as the user types:
@ComboboxOption(task.ID, task.Title, nil)
}
// once per page:
@ComboboxScript()Patterns
Entity card
Standard chrome for detail pages: title + optional kind chip on the header, key-value fields in a two-column grid below.
Entity card
the chrome of a detail page — title + kind chip + key-value fields
Yuki Tanaka
person- yuki@example.jp
- Lives in
- Sendai, Miyagi
- Last contact
@Card() {
@CardHeader("Yuki Tanaka") {
@Chip(ChipKind, "person")
}
@CardFields() {
@CardField("Email") { yuki@example.jp }
@CardField("Lives in") { Sendai, Miyagi }
@CardField("Last contact") {
<time class="time" datetime="2026-05-12">May 12 · 18:20</time>
}
}
}List row
Whole-row clickable surface — wrap row contents in
<a class="list-row"> for a single hit area.
Hovers lift to --bg-tag.
Clickable row
whole-row link — label on the left, meta on the right
@ListRows("") {
@ListRow("", "#") {
@ListRowLabel() { Renew passport }
@ListRowMeta() { due Jun 1 }
}
@ListRow("", "#") {
@ListRowLabel() { Pay NHK bill }
@ListRowMeta() { Jan 12 }
}
}Breadcrumb
Single-page nav chrome with a back arrow. Optional right-aligned action button (Edit / Archive); more than one action wants a dedicated toolbar instead.
Back link
single-step nav up — leading arrow + muted link
@Breadcrumb("#", "All tasks")Breadcrumb with action
back link + right-aligned action — the standard show-page header
@Breadcrumb("#", "All projects") {
@BreadcrumbAction("#") { Edit }
}Page section
Standard subsection chrome. PageSection uses an
<h3>; PageSectionTop uses an
<h2> for the single page-level heading on new
/ edit pages.
Section (h3)
in-page subsection beneath the breadcrumb
Members
Section body content goes here.
@PageSection("Members") {
<p>Section body content goes here.</p>
}Top section (h2)
the single page-level heading on a new/edit page
Edit profile
Top-level form content.
@PageSectionTop("Edit profile") {
<p>Top-level form content.</p>
}Button row
Flex container for action button groupings. Pairs of primary / destructive on detail pages, or grouped secondary actions in a card footer.
Action row
groups action buttons — typically Complete + Drop on detail pages
@ButtonRow() {
@Button(ButtonPrimary, "button", false) { Complete }
@Button(ButtonDestructive, "button", false) { Drop }
}Input field
The typed wrapper over a single <label> +
<input> — what forms would otherwise hand-roll.
Type defaults to text and ID to
Name; an empty Value shows the placeholder.
For paired numeric bounds reach for the Range field; for typeahead, the
Combobox. HTMX-agnostic — wire hx-* on the enclosing form.
Text
single-line labelled text input
@InputField(InputFieldParams{
Label: "Display name", Name: "display_name",
Value: "Aiko", Placeholder: "e.g. Aiko",
})Number with bounds
number input with min / max / step
@InputField(InputFieldParams{
Label: "Goal weight (kg)", Name: "goal_weight", Type: "number",
Value: "70", Min: "1", Max: "500", Step: "0.1",
})Disabled
locked field — value shown, not editable
@InputField(InputFieldParams{
Label: "Sex", Name: "sex", Value: "male", Disabled: true,
})Range field
Paired Min / Max <input type="number"> for bounded
numeric filters. Uses real number inputs (not a custom slider) so
step buttons, keyboard, and screen readers work natively — chosen
for audiences where two-handle sliders would be a usability tax.
HTMX wiring lives on the consumer's form.
Price range (£)
currency-adorned min/max pair for bounded filters
@RangeField(RangeFieldParams{
Label: "Price", Unit: "£",
NameMin: "min_price", NameMax: "max_price",
Min: 0, Max: 100000, Step: 1000,
ValueMin: "20000", ValueMax: "",
})Area range (m²)
non-currency adornment — same primitive, different unit + step
@RangeField(RangeFieldParams{
Label: "Floor area", Unit: "m²",
NameMin: "min_area", NameMax: "max_area",
Min: 0, Max: 100, Step: 1,
})Photo gallery
Data-driven thumbnail grid (PhotoGallery([]PhotoGalleryImage))
plus one reused native <dialog> lightbox per page.
Thumbnails are real <a> links to the full image, so
with JS off they still open the photo; with JS the
PhotoGalleryScript IIFE intercepts the click and opens the
lightbox (prev / next, arrow keys, Esc, backdrop dismiss). Thumbnails are
the full image scaled by CSS — no thumbnail generation. Emit
@PhotoGalleryLightbox() + @PhotoGalleryScript()
once per page (this gallery page mounts them below so the demo is live).
Thumbnail grid
responsive grid; each thumb opens the shared lightbox (prev/next, Esc, arrow keys). Pair with one @PhotoGalleryLightbox() + @PhotoGalleryScript() per page.
@PhotoGallery([]PhotoGalleryImage{
{URL: photo.URL(), Alt: photo.Caption},
// …one per photo…
})
// once per page, after the grid:
@PhotoGalleryLightbox()
@PhotoGalleryScript()Donut
Static, server-known part/whole breakdown as an inline
<svg> donut + legend (Donut(DonutView)) —
zero JS, themed entirely through CSS custom properties so it recolours
the instant the theme toggles (the reason it is inline SVG, not Chart.js;
ADR 0040). Each wedge's Kind picks its fill by visual role:
a muted chart-cat hue for a Category, solid grey
for a Muted "other" bucket, a hatch for a Hatched
remainder/surplus wedge. Graduated from brain's budget category-breakdown
and nutrition macro donuts once the technique had two consumers
(ADR 0020). Toggle the theme above to watch the wedges re-tint live.
Category breakdown (over)
categories + a solid-grey muted bucket, with a danger footnote — the budget over-allocated case
- Rent ¥117,000 45%
- Food ¥78,000 30%
- Transport ¥39,000 15%
- Uncategorized ¥26,000 10%
Over by ¥20,000
@Donut(DonutView{
CenterLabel: "¥260,000", CenterSub: "planned out",
Note: "Over by ¥20,000", NoteTone: DonutNoteDanger,
Wedges: []DonutWedge{
{Label: "Rent", Kind: DonutCategory, ColorIndex: 0, Pct: 45, Offset: 0, ...},
{Label: "Uncategorized", Kind: DonutMuted, Pct: 10, Offset: 90, ...},
},
})Category breakdown (surplus)
adds a hatched remainder wedge — the budget with unplanned income surplus
- Rent ¥72,000 40%
- Food ¥45,000 25%
- Uncategorized ¥27,000 15%
- Unplanned ¥36,000 20%
@Donut(DonutView{
CenterLabel: "¥180,000", CenterSub: "planned out",
Wedges: []DonutWedge{
{Label: "Rent", Kind: DonutCategory, ColorIndex: 0, ...},
{Label: "Unplanned", Kind: DonutHatched, Pct: 20, Offset: 80, ...},
},
})Macro split
all-category wedges with the day's kcal in the centre — brain's nutrition dashboard donut
- Protein 139 g 30%
- Fat 72 g 35%
- Carbs 139 g 30%
- Alcohol 13 g 5%
@Donut(DonutView{
CenterLabel: "1,850", CenterSub: "kcal",
Wedges: []DonutWedge{
{Label: "Protein", Kind: DonutCategory, ColorIndex: 0, Pct: 30, ...},
// …one per macro…
},
})Marketing
Full-bleed section chrome for marketing landing pages — composed
by consumers into MarketingNav →
MarketingHero → MarketingSections →
MarketingFooter. See the assembled preview at
/landing/japan-at-night.
Section intro
Eyebrow label + h2 title + free-form body slot. Left variant for most sections, center variant for closing CTA panels.
Left intro
the standard eyebrow + h2 + body header used at the top of most marketing sections
What this is
The Sendai your guidebook doesn't know.
Iroha Yokocho. Bunka Yokocho. Tiny counters behind unmarked doors.
@SectionIntro(SectionIntroLeft, "What this is", "The Sendai your guidebook doesn't know.") {
<p class="marketing-body">Iroha Yokocho. Bunka Yokocho. …</p>
}Centered intro
centred variant for closing CTA panels — eyebrow + h2 + lede + button
Ready?
Come find the Sendai I love.
Small groups, flexible dates, full refund up to 24 hours before.
@SectionIntro(SectionIntroCenter, "Ready?", "Come find the Sendai I love.") {
<p class="marketing-lede">Small groups, flexible dates…</p>
@Button(ButtonPrimary, "button", false) { Book a night → }
}Marketing section
Vertical-padded wrapper that gives every band the same rhythm
and border. Optional id makes the section a jump
target for in-page nav links.
Section wrapper
vertical-padded band that holds a SectionIntro plus a grid / list / FAQ
Stories from the table
What guests have said.
A wrapper that gives every band the same vertical rhythm + border.
@MarketingSection("the-night") {
@SectionIntro(SectionIntroLeft, "What this is", "…") {
<p class="marketing-body">…</p>
}
@FeatureGrid() { … }
}Marketing nav
Top-of-page nav band — brand wordmark, inline link group, and right-aligned CTA pill. The theme toggle drops in as a child between links and CTA.
Landing nav
brand wordmark + inline link group + right-aligned CTA pill
@MarketingNav() {
@NavBrand("Japan at Night")
@NavLinks() {
@NavLink("#the-night", "The night")
@NavLink("#guide", "Your guide")
@NavLink("#faq", "FAQ")
}
@NavCTA("#book", "Book a night")
}Marketing hero
Full-bleed hero band. Image slot + caption slot composed as
children — copy lives in the consumer's HeroCaption,
not in typed args, so i18n and multi-line headlines aren't
foreclosed.
Hero band
full-bleed image surface + caption block with h1, lede, and button
An evening in Sendai's hidden alleyways.
Hole-in-the-wall bars, locals-only counters, and the occasional cursed snack.
@MarketingHero() {
@HeroImage() {
<img src="…" alt="…"/>
}
@HeroCaption() {
<h1 class="marketing-h1">An evening in Sendai's hidden alleyways.</h1>
<p class="marketing-lede">Hole-in-the-wall bars, locals-only counters…</p>
@Button(ButtonPrimary, "button", false) { Book a night → }
}
}Feature grid
3-up grid of accent-glyph + title + body cards, collapses to 1-up on mobile. Variable child count.
Feature grid
3-up grid of accent-glyph + title + body cards; collapses to 1-up on mobile
Hidden spots
Bars that don't show up online, with seats reserved for regulars.Tailored to you
Sushi, sake, craft cocktails, dietary needs — we plan around what you want.Local welcome
Owners greet me by name, which means they'll greet you warmly too.
@FeatureGrid() {
@Feature("Hidden spots") { … body … }
@Feature("Tailored to you") { … body … }
@Feature("Local welcome") { … body … }
}Step grid
Numbered "how it works" sequence — image + accent circle + title + body per step. CSS reorders so the photo reads first visually while screen readers get number+title up front.
Numbered steps
3-up grid of image + numbered circle + title + body; collapses to 1-up on mobile
Iroha Yokocho
Photo Iroha YokochoSushi, sake, and small plates in a narrow lantern-lit alley.
Bunka Yokocho
Photo Bunka YokochoHidden counter bars with six seats, big personalities behind them.
Craft cocktails
Photo Cocktail barA nightcap somewhere quiet, where the bartender takes their time.
@StepGrid() {
@Step(1, "Iroha Yokocho") {
@StepImage() { <img src="…" alt="…"/> }
@StepBody() { Sushi, sake, and small plates… }
}
@Step(2, "Bunka Yokocho") { … }
@Step(3, "Craft cocktails") { … }
}Review grid
2-up testimonial cards with N filled-star glyphs, italicised
quote, and attribution. Stars rendered with clip-path so they
pick up --accent in every theme.
Review grid
2-up grid of star row + italicised quote + attribution; collapses to 1-up on mobile
Ali showed all the hidden experiences that only a local would really know.
Calder L. · March 2025
One of my most memorable nights out ever.
Alex W. · December 2024
@ReviewGrid() {
@Review(5, "Calder L. · March 2025") {
Ali showed all the hidden experiences that only a local would really know.
}
@Review(5, "Alex W. · December 2024") {
One of my most memorable nights out ever.
}
}FAQ list
Native <details> / <summary>
accordion — accessible by default, no JS, screen readers
announce the open/closed state correctly.
FAQ accordion
vertical list of expand-to-reveal entries using native <details>/<summary>
Do I need to speak any Japanese?
No — Ali guides in English and handles all the introductions.
What if I have dietary restrictions?
Tell us in advance and we'll plan the route around them.
How long is the tour, and what does it cost?
Three to five hours, ¥18,000 per person, drinks and food included.
@FAQList() {
@FAQ("Do I need to speak any Japanese?") {
<p class="marketing-body">No — Ali guides in English…</p>
}
@FAQ("What if I have dietary restrictions?") { … }
@FAQ("How long is the tour?") { … }
}Marketing footer
Left-aligned brand text + right-aligned link group. Flex-wraps on narrow screens.
Landing footer
left text + right-aligned link group; flex-wraps on narrow screens
@MarketingFooter() {
@FooterText() { Japan at Night · Sendai }
@FooterLinks() {
@FooterLink("#", "Instagram")
@FooterLink("#", "Contact")
@FooterLink("#", "© 2026")
}
}