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-primary page background
  • --bg-elevated cards, nav drawer, inputs
  • --bg-tag chips, subtle fills
Text
  • --text-primary body, headings
  • --text-muted meta, hints, labels
  • --text-faint de-emphasised meta, dropped
Border
  • --border-subtle default field, card edge
  • --border-focus focused input, active field
Semantic
  • --accent primary action, active state
  • --success done, paid, healthy
  • --warning snoozed, due soon
  • --danger errors, 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

active
@Chip(ChipStatus, "active")
Kind chip

discriminator for task / capture variants

payment
@Chip(ChipKind, "payment")
Location chip

shorthand for hierarchical location refs

Sendai
@Chip(ChipLocation, "Sendai")
Soft chip — success

outcome state — paid, done, healthy

paid
@Chip(ChipSuccess, "paid")
Soft chip — warning

transitional state — snoozed, due soon

snoozed
@Chip(ChipWarning, "snoozed")
Soft chip — danger

blocked / overdue / failed

overdue
@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

Esc
@Kbd("Esc")
Chord

modifier + key combo — render as one chip

⌘K
@Kbd("⌘K")
Inline in prose

embedded in body copy — sized down a touch

Press ⌘B to toggle the nav.
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

in progress
@StatusDot(StatusDotActive, "active") in progress
Snoozed

deliberately deferred — warm tone

deferred to next week
@StatusDot(StatusDotSnoozed, "snoozed") deferred to next week
Done

completed — success tone

shipped 2 days ago
@StatusDot(StatusDotDone, "done") shipped 2 days ago
Dropped

intentionally abandoned — muted tone

dropped — not pursuing
@StatusDot(StatusDotDropped, "dropped") dropped — not pursuing

Avatar

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

Ada Lovelace
@Avatar(AvatarSmall, url, "AL", "Ada Lovelace")
Large — image

detail headers

Ada Lovelace
@Avatar(AvatarLarge, url, "AL", "Ada Lovelace")
Initials fallback

no image uploaded

AL
@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
Email
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

Price
@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

Floor area
@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

¥260,000 planned out
  • 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

¥180,000 planned out
  • 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

1,850 kcal
  • 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 MarketingNavMarketingHeroMarketingSections → 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

Hero image lantern-lit alleyway · 16:9 or taller

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

  1. Iroha Yokocho

    Photo Iroha Yokocho

    Sushi, sake, and small plates in a narrow lantern-lit alley.

  2. Bunka Yokocho

    Photo Bunka Yokocho

    Hidden counter bars with six seats, big personalities behind them.

  3. Craft cocktails

    Photo Cocktail bar

    A 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")
    }
}

Adding a primitive: define the typed Go function in pkg/design, add its CSS rule to pkg/design/css/primitives.css, add a FooDemos() constructor in pkg/design/gallery.go, add a drift-guard test, and reference it here.