mirror of
https://github.com/kbenestad/invoice.git
synced 2026-06-18 08:04:32 +00:00
368 lines
21 KiB
Markdown
368 lines
21 KiB
Markdown
# DESIGN.md — kBenestad web app design contract
|
||
|
||
**This file complements `CLAUDE.md`.** `CLAUDE.md` tells Claude Code *what this app is* —
|
||
its purpose, data model, stack, commands. **`DESIGN.md` governs only the surface:** layout,
|
||
type, colour, components, copy. Follow it and any new screen — a form, a dashboard, a
|
||
settings page, a CLI-style tool — will look like it belongs to the kBenestad family.
|
||
|
||
It does **not** redefine what the app does. Keep the app's behaviour, routes, and data model
|
||
intact; only the visual surface is governed here.
|
||
|
||
This document is **app-agnostic**. The concrete examples it points you at live in the repo
|
||
under `dev/` and are exactly that — *examples* to study and fork, never code to ship as-is.
|
||
|
||
---
|
||
|
||
## 0. Where the real design lives — read this first
|
||
|
||
Three folders under `dev/` are your source of truth. **Read the relevant ones before
|
||
writing any UI.** Prose can drift; these files are the system.
|
||
|
||
| Folder | What it is | How to use it |
|
||
|--------|-----------|---------------|
|
||
| **`dev/theme/`** | The canonical **token stylesheets** (`colors.css`, `typography.css`, `spacing.css`, `elevation.css`, `fonts.css`, `base.css`) **and** the **Forgejo theme** — a worked example of mapping these tokens onto a pre-existing app's variables, with light / dark / auto builds. | **Copy the token CSS into your app and `@import` it** from one stylesheet. Reference `var(--token)` everywhere. Study the Forgejo theme to see how the palette recolours an existing component system without touching its markup. |
|
||
| **`dev/ui_kits/`** | Finished, **interactive app mockups** (e.g. `gitxt`, `invoice`, `kbpkg`). The system applied end-to-end — real screens, real components, real states. | **Find the closest kit to what you're building and fork it.** Lift its component structure, class names, and interaction patterns. This is the fastest path to a high-quality, on-brand screen. |
|
||
| **`dev/mockups/`** | Reusable **component layers + static reference screens** — notably `kbenestad-forms.css` (the proven `.kb-*` form layer) plus finished `invoice`/`timesheet`/`reimburse` HTML. | For a form-driven app, **lift `kbenestad-forms.css` wholesale** rather than rebuilding inputs/cards/buttons. Use the static HTML as a layout reference. |
|
||
|
||
### The 60-second startup ritual for a new screen
|
||
1. **Wire tokens.** Copy `dev/theme/`'s token stylesheets in; `@import` them from one app
|
||
stylesheet. Confirm `var(--surface-page)`, `var(--accent)`, `var(--font-sans)` resolve.
|
||
2. **Find the nearest example.** Form-driven → `dev/mockups/` + the `invoice` kit.
|
||
Dashboard/data → study the kits' data screens. CLI/terminal → `gitxt`. Recolouring an
|
||
existing third-party app → the **Forgejo theme** in `dev/theme/` is your template.
|
||
3. **Fork, don't reinvent.** Start from that example's structure and adapt it to this app's
|
||
data. Only build net-new when nothing fits.
|
||
4. **Check it against §11 (Do / Don't)** before you call it done.
|
||
|
||
> If `dev/theme/` is missing the token files, they come from the kBenestad design system's
|
||
> `styles.css` closure — copy that closure in. Never hardcode a hex that a token already names.
|
||
|
||
---
|
||
|
||
## 1. The one-paragraph version
|
||
|
||
Nordic-minimal, light-first, calm. A cool off-white page, pure-white cards held by 1px
|
||
hairline borders, near-black cool ink, and **one** blue accent (`#2f6fed`). Schibsted
|
||
Grotesk for everything; JetBrains Mono only for numbers, code, and identifiers. 4px spacing
|
||
grid, small deliberate radii (8px workhorse), soft shadows used sparingly. No gradients, no
|
||
emoji, no bounce. Sentence case. Borders do the structural work; shadow only when something
|
||
genuinely floats. Categorical data is coloured with a muted, config-driven palette (chip +
|
||
left border + optional row tint), never neon.
|
||
|
||
**If you only remember five rules:**
|
||
1. **One accent.** Blue is the only brand colour. Semantic hues are muted and earn their place.
|
||
2. **Borders first, shadow last.** Most surfaces have no shadow.
|
||
3. **Forms are row-major** (see §6). Never lay a form out as side-by-side column stacks.
|
||
4. **Sentence case, no emoji, mono for numbers.**
|
||
5. **Colour-code categories** with the chip / border / tint system in §8 — muted, from data.
|
||
|
||
---
|
||
|
||
## 2. Colour tokens
|
||
|
||
All colour comes from `dev/theme/colors.css`. Reference the **semantic aliases**, not the
|
||
raw ramps — the ramps exist so the aliases have something to point at; your code should
|
||
almost never name a `--blue-500` directly.
|
||
|
||
**Surfaces** — `--surface-page` (cool off-white ground), `--surface-card` (pure white),
|
||
`--surface-sunken`, `--surface-raised`, `--surface-hover`, `--surface-active`,
|
||
`--surface-inverse`.
|
||
|
||
**Borders** — `--border-subtle` (hairline, default for cards), `--border-default`
|
||
(dividers), `--border-strong` (emphasis), `--border-focus`.
|
||
|
||
**Text** — `--text-strong` (headings/primary ink), `--text-body`, `--text-muted`,
|
||
`--text-subtle`, `--text-inverse`, `--text-accent`, `--text-link`.
|
||
|
||
**Accent / interactive** — `--accent` (`#2f6fed`), `--accent-hover`, `--accent-active`,
|
||
`--accent-soft` (pale blue fill), `--accent-soft-text`, `--accent-fg` (text on accent).
|
||
|
||
**Semantic feedback (muted, never neon)** — `--success` / `--success-soft`,
|
||
`--warning` / `--warning-soft`, `--danger` / `--danger-soft` / `--danger-fg`. Use the soft
|
||
variants as fills, the solid as border/text.
|
||
|
||
**Focus** — `--focus-ring` (a 3px soft-blue ring). Always visible via `:focus-visible`.
|
||
|
||
**Terminal** (CLI / `gitxt`-style only) — `--term-bg`, `--term-fg`, `--term-dim`,
|
||
`--term-accent`, `--term-green`, `--term-amber`. Don't use these in normal app UI.
|
||
|
||
> Light values you'll see most: page `#f8f9fb`, card `#ffffff`, hairline `#e7eaef`, ink
|
||
> `#14181e`, accent `#2f6fed` / hover `#1f57cf`. Dark inverts onto `#0d1117` page /
|
||
> `#161b22` card with a lighter accent for contrast.
|
||
|
||
**Dark mode** — support both. Honour the OS by default *and* allow a manual override:
|
||
`@media (prefers-color-scheme: dark)` and `:root[data-theme="dark"]` flip the same tokens.
|
||
The Forgejo theme in `dev/theme/` ships light / dark / auto builds — copy that structure.
|
||
Never hardcode a hex that won't invert; always go through a semantic token.
|
||
|
||
---
|
||
|
||
## 3. Typography
|
||
|
||
Loaded by `dev/theme/fonts.css`; scale defined in `dev/theme/typography.css`.
|
||
|
||
- **Families:** `--font-sans` (Schibsted Grotesk) everywhere; `--font-mono` (JetBrains Mono)
|
||
**only** for numbers, code, paths, package/identifier names, and tabular figures. When
|
||
showing money, durations, counts, versions, IDs — mono + `font-variant-numeric: tabular-nums`.
|
||
- **Load with fallbacks** so the app renders offline:
|
||
```css
|
||
--font-sans: 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
||
```
|
||
- **Scale:** `--text-display` 52px (splash only), `--text-h1` 36, `--text-h2` 28,
|
||
`--text-h3` 22, `--text-h4` 18, `--text-body-lg` 17, `--text-body` 15 (UI default),
|
||
`--text-body-sm` 13, `--text-caption` 12, `--text-mono` 14.
|
||
- **Weights:** 400 body, 500 medium, 600 semibold (labels/buttons), 700 headings, 800
|
||
reserved for the wordmark/splash.
|
||
- **Leading:** `--leading-normal` 1.5 for body; `--leading-tight` 1.1 for big display.
|
||
- **Tracking:** `--tracking-tight` (−0.02em) on display/headings; `--tracking-caps`
|
||
(+0.08em) on uppercase eyebrow/overline labels.
|
||
- **Eyebrow labels** (field labels, table headers, section kickers): 12px, uppercase,
|
||
`--tracking-caps`, `--text-muted`, weight 600. This is the recurring small-label motif.
|
||
|
||
---
|
||
|
||
## 4. Spacing, shape, elevation, motion
|
||
|
||
From `dev/theme/spacing.css` and `dev/theme/elevation.css`.
|
||
|
||
- **Spacing:** 4px base grid via `--space-1`…`--space-16` (4, 8, 12, 16, 24, 32, 40, 48, 64,
|
||
96, 128). Card padding ~24px (`--space-5`). Comfortable, not airy.
|
||
- **Radii:** `--radius-xs` 3, `--radius-sm` 5 (small controls), `--radius-md` 8 (the
|
||
workhorse), `--radius-lg` 12 / `--radius-xl` 16 (cards), `--radius-full` (pills/badges
|
||
only). **Not bubbly** — never round a card more than 16px.
|
||
- **Borders:** `--border-thin` 1px (default hairline), `--border-medium` 1.5px (emphasis /
|
||
selection). Borders carry the structure.
|
||
- **Elevation:** most surfaces have **no** shadow. `--shadow-md` for menus and hover-lift,
|
||
`--shadow-lg` / `--shadow-xl` for dialogs and popovers. Shadows are soft, cool-tinted,
|
||
low-opacity. Never use shadow as decoration.
|
||
- **Containers:** cap content at `--container-lg` 1080px (forms/docs) to `--container-xl`
|
||
1280px (dashboards). Left-aligned, single-column-leaning.
|
||
- **Motion:** 120–280ms (`--duration-fast/base/slow`), `--ease-out`
|
||
`cubic-bezier(.22,.61,.36,1)`. Fades and ≤2px translations only. **No bounce, no spring.**
|
||
Honour `prefers-reduced-motion`.
|
||
- **Hover/press:** filled elements step one shade darker (`--accent-hover`); ghost/secondary
|
||
get a faint surface tint. Press adds a 0.5px downward nudge. Disabled ≈ 45% opacity, no
|
||
pointer events.
|
||
|
||
---
|
||
|
||
## 5. Voice & copy
|
||
|
||
The voice is understated, precise, practical. These are working tools, not marketing pages.
|
||
|
||
- **Sentence case everywhere** — buttons, headings, menu items ("View source", "Add row",
|
||
not Title Case). UPPERCASE only for small tracked-out eyebrow labels.
|
||
- **Calm and direct.** Short sentences. Say what a thing does, then stop. No hype
|
||
("seamless", "blazing-fast"), no exclamation marks.
|
||
- **Imperative, concrete verbs** for actions: *Add line*, *Generate*, *Validate*, *Export*,
|
||
*Remove*, *Publish*.
|
||
- **Numbers & identifiers are exact and mono:** `v2.4.0`, `12 kB`, `8.0 h`, `3 items`.
|
||
- **No emoji, ever.** Status is a coloured dot, a Lucide icon, or a tinted chip.
|
||
- **Errors & empty states are honest and helpful** — what happened, then the next step:
|
||
"Time out must be after time in." / "No expenses yet — add your first line to begin."
|
||
- **Speak to the user as "you"**; keep institutional "we" out of working UI.
|
||
|
||
---
|
||
|
||
## 6. Form-driven UIs (anything with inputs — forms, settings, editors, document builders)
|
||
|
||
If your app collects or edits structured input, **start from `dev/mockups/kbenestad-forms.css`
|
||
and the `invoice` kit in `dev/ui_kits/`.** The rules below are what those examples encode.
|
||
|
||
### 6.1 Page shell
|
||
A centred column (`--container-lg` or narrower), `--surface-page` ground, content in
|
||
`--surface-card` cards with `--border-subtle` and at most `--shadow-sm`. Optional top
|
||
**utility bar** (language toggle, text-size A−/A/A+, About) as small segmented controls;
|
||
then a **document header** where the *subject's* identity leads (logo tile + org name on the
|
||
left, title + period on the right). kBenestad is the quiet signature in the footer, never
|
||
the headline.
|
||
|
||
### 6.2 Forms are ROW-MAJOR — this is the load-bearing rule
|
||
Group fields into **rows**; never build a form as two independent side-by-side column
|
||
stacks. Each row is a **top-aligned CSS grid**. When one field grows — a hint appears, a
|
||
validation message shows, an explanation expands — **the whole row grows and the rows below
|
||
push down**, while the field beside it stays aligned to the top. This keeps labels and
|
||
controls in register no matter what expands.
|
||
|
||
- Use a `.kb-form` / row container with span helpers (`kb-col-2`, `kb-col-3`, `kb-col-full`)
|
||
to control how many columns a field occupies within its row. (All provided in
|
||
`dev/mockups/kbenestad-forms.css`.)
|
||
- Label above field. Label = eyebrow style (12px, uppercase, tracked, `--text-muted`, 600).
|
||
- One thought per row; don't cram unrelated fields together to save vertical space.
|
||
|
||
### 6.3 Inputs
|
||
1px `--border-strong` border, `--radius-sm`, ~9–11px padding, 15px text. Focus =
|
||
`--accent` border + `--focus-ring`. Disabled/readonly = `--surface-sunken` fill, muted
|
||
text, `not-allowed`. Numeric inputs: mono, right-aligned, tabular-nums. Selects get a custom
|
||
chevron. Error state = `--danger` border; warning = `--warning` border (optional soft fill).
|
||
|
||
### 6.4 Buttons
|
||
- **Primary:** filled `--accent`, `--accent-fg` text → hover `--accent-hover`.
|
||
- **Ghost/secondary:** white surface, `--border-strong` → hover accent border + accent text.
|
||
- **Soft:** `--accent-soft` fill, accent text.
|
||
- **Dashed:** transparent + dashed accent border — the "add another" affordance.
|
||
- **Round add/remove:** 24px circle, `+` (accent) / `−` (danger) — for line-item rows.
|
||
- Disabled ≈ 50% opacity. Icons (Lucide, 16px) sit left of the label with a small gap.
|
||
|
||
### 6.5 Line items / repeating rows
|
||
A header row of eyebrow labels, then data rows on a matching grid. Row hover gets a faint
|
||
`--surface-hover`. A left-border slot (`border-left: 3px solid transparent`) is reserved so
|
||
rows can be colour-coded (see §8). Trailing add/remove circle buttons. Subtotals and the
|
||
running total use mono tabular figures.
|
||
|
||
### 6.6 Totals / summary panel
|
||
Right-aligned, ~380px. Rows of `label … value` (value mono, tabular). A `grand` row on top
|
||
of a `--border-strong` rule, bold, with the figure in `--accent` at ~20px. Notes/derived
|
||
explanations below in muted small text.
|
||
|
||
### 6.7 Validation & feedback (notes / banners)
|
||
A tinted note block: icon + text, `--*-soft` background with matching `--*-border` and text
|
||
colour. Four kinds: error (`--danger`), warning (`--warning`), success (`--success`), info
|
||
(`--accent`). Blocking errors disable the primary action; warnings don't. Mirror issues
|
||
inline: tint the offending field's border and show the message next to its row. Keep
|
||
messages specific ("Description is required for OTH rows").
|
||
|
||
### 6.8 Config-driven & white-label
|
||
Wherever an app has a `config.yml` (org name, logo, accent colour, codes, categories,
|
||
holidays, UI strings, language), **drive the UI from it** — don't hardcode. The accent is a
|
||
single recolourable token so a customer brand can replace the kBenestad blue without
|
||
touching anything else — exactly how the Forgejo theme in `dev/theme/` swaps one palette for
|
||
another. Strings come from the config's i18n map; support the configured languages.
|
||
|
||
---
|
||
|
||
## 7. Dashboards & data-dense screens
|
||
|
||
Same DNA, just wider (`--container-xl` 1280px) and grid-arranged. Study the data screens in
|
||
the `dev/ui_kits/` mockups for worked examples.
|
||
|
||
- **Metric / stat cards:** white card, hairline border, no shadow. A small uppercase eyebrow
|
||
label, then the figure **large in mono tabular** (`--text-h2`/`h3`), then an optional delta
|
||
line. Show change with a small coloured chip or arrow in `--success` / `--danger` —
|
||
**muted, not neon**, never a red/green gradient.
|
||
- **Card grid:** lay metric cards on a responsive grid with `--space-4`/`--space-5` gaps.
|
||
Group related cards under a section header (eyebrow label + thin accent tick).
|
||
- **Tables:** the workhorse. Eyebrow-label header row, hairline row dividers (or zebra via
|
||
`--surface-sunken` at low contrast), generous row height, mono tabular for any numeric
|
||
column, right-align numbers. Status/category cells use the chip system (§8). Sticky header
|
||
for long tables. Row hover = `--surface-hover`.
|
||
- **Charts:** flat fills, no 3D, no drop shadows, no gradients-as-decoration. Blue accent as
|
||
the primary series; additional series from the muted categorical palette (§8) so chart
|
||
colours match the chips/legends elsewhere. Thin gridlines in `--border-subtle`, axis labels
|
||
in `--text-muted`. Legend reuses the same chip styling.
|
||
- **Density:** comfortable, not cramped. Don't add stats, sparklines, or icons that aren't
|
||
answering a real question — less is more. No "data slop."
|
||
- **Filters / toolbars:** segmented controls and ghost buttons in a row above the content,
|
||
separated by a hairline. Active segment = `--accent-soft` fill + accent text.
|
||
- **Empty & loading states:** a calm centred message in `--text-muted` with one clear
|
||
action — never a spinner alone with no context.
|
||
|
||
---
|
||
|
||
## 8. Colour-coding categorical data (use it everywhere it fits)
|
||
|
||
Any time data falls into **named categories** — work codes, expense categories, statuses
|
||
(draft/sent/paid/overdue), project tags, leave types, priority levels — give each category a
|
||
**stable, muted colour identity** and surface it consistently. It turns a wall of rows into
|
||
something scannable.
|
||
|
||
### 8.1 The colour identity
|
||
Each category owns a trio, ideally defined in **config/data, not hardcoded**, exposed as CSS
|
||
custom properties so they stay configurable:
|
||
|
||
```
|
||
--chip-border : a saturated-but-muted hue (the category's "true" colour)
|
||
--chip-bg : a soft pale tint of that hue (fills behind text — must keep AA contrast)
|
||
--chip-text : a darker shade of the hue (readable label colour)
|
||
```
|
||
|
||
Specify all three for control, or specify just `--chip-border` and derive the tint with
|
||
`color-mix(in srgb, var(--chip-border) 16%, var(--surface))`. **Muted, never neon** — think
|
||
`#0078d7` blue, `#8cbd18` olive, `#ed616f` coral, `#393939` slate, not pure primaries.
|
||
|
||
### 8.2 Three ways to surface it (use together)
|
||
1. **Chip / pill** — a `--radius-full` pill with a leading filled **dot** in the category
|
||
colour, the code + short name. The primary, always-legible token. Used in cells, filters,
|
||
and legends.
|
||
2. **Left border on the row** — `border-left: 3–4px solid var(--chip-border)`. A quiet,
|
||
always-on stripe that lets you scan a long table by category at a glance.
|
||
3. **Optional muted row tint** — fill the whole row with `--chip-bg` (or a 45% `color-mix`
|
||
of it). Make this **toggleable**: some users want the calm border-only view, others want
|
||
the colour wash. Default to the quieter one.
|
||
|
||
### 8.3 Legend
|
||
Whenever colour carries meaning, show a **legend** of chips mapping each colour to its label.
|
||
Colour is never the *only* signal — the code/name text is always present, so the system
|
||
works for colour-blind users and in print/PDF.
|
||
|
||
### 8.4 Sub-states & variants
|
||
A category can carry more than one colour for sub-states (e.g. a full-day vs partial-day
|
||
pair). Model these as variant classes/props rather than inventing ad-hoc colours at the call
|
||
site.
|
||
|
||
### 8.5 Dark mode
|
||
Tints are too dark to read directly inverted. Either define dark-mode `--chip-bg`/`--chip-text`
|
||
in the dark scope, or in dark mode show the chip as the border colour on a transparent fill
|
||
(`color-mix` the hue into the surface) and brighten slightly. Always re-check contrast.
|
||
|
||
### 8.6 Reuse across the app
|
||
The *same* category palette should drive chips, row borders, row tints, **and** chart series
|
||
— so "Travel" is the same colour in the table, the legend, and the pie chart. Define the
|
||
palette once per app and pull from it everywhere.
|
||
|
||
---
|
||
|
||
## 9. Iconography & app icons
|
||
|
||
- **System:** Lucide — 2px-stroke outline icons. Size `1em`–18px, round caps/joins,
|
||
`currentColor` so they inherit text colour. Bump stroke to 2.2–2.4 at very small sizes.
|
||
- **Status** is a small filled dot or a Lucide glyph + colour. **Never emoji.**
|
||
- **App icon / favicon:** if this app needs its own mark, follow the family pattern — a
|
||
single Nordic-blue squircle (`#2f6fed`, ~25% corner radius) carrying **one** white
|
||
Lucide-weight glyph. Keep it to one glyph; fall back to a mono lettermark where a glyph
|
||
won't fit. (Reference sets live with the design system's `app-icons`.)
|
||
- Don't draw bespoke illustrations in SVG; prefer clean icons, real screenshots, or
|
||
diagrams. No stock photos, no grain, no duotone.
|
||
|
||
---
|
||
|
||
## 10. Dark mode checklist
|
||
|
||
- Every colour goes through a semantic token; nothing hardcoded that can't invert.
|
||
- Page `#0d1117`, card `#161b22`, raised `#1c232c`; borders lighten, text lightens, accent
|
||
shifts lighter for contrast on dark.
|
||
- Shadows get heavier/darker (handled by the dark elevation tokens).
|
||
- Re-derive category tints (§8.5). Re-check chip and note contrast.
|
||
- Support both auto (`prefers-color-scheme`) and a manual `data-theme` override, persisted.
|
||
The `dev/theme/` Forgejo build is a complete light/dark/auto reference.
|
||
|
||
---
|
||
|
||
## 11. Do / Don't
|
||
|
||
**Do**
|
||
- Read `dev/theme/`, `dev/ui_kits/`, `dev/mockups/` and **fork the nearest example** before
|
||
building from scratch.
|
||
- Use one blue accent; let borders and whitespace do the work.
|
||
- Keep forms row-major; keep numbers in mono tabular.
|
||
- Colour-code categories with the muted chip/border/tint system, with a legend.
|
||
- Sentence case, exact numbers, honest empty/error states.
|
||
- Cap width, left-align, single-column-lean; comfortable 4px-grid spacing.
|
||
- Support light + dark through tokens.
|
||
|
||
**Don't**
|
||
- Don't reinvent components the examples already provide.
|
||
- No gradients, photographic washes, textures, or glassmorphism.
|
||
- No emoji, no exclamation marks, no hype words, no Title Case.
|
||
- No neon semantic colours; no red/green gradient deltas.
|
||
- No bubbly radii (>16px on cards), no bouncy/springy motion.
|
||
- No shadow as decoration — only for things that truly float.
|
||
- Don't hardcode colours, strings, or the accent when a config can drive them.
|
||
- Don't redefine what the app *is* — restyle the surface, keep the behaviour.
|
||
|
||
---
|
||
|
||
*This file governs surface only. The `dev/` examples are the canonical reference; when this
|
||
app has its own real brand guide or codebase conventions, reconcile against those rather than
|
||
overriding the product.*
|