diff --git a/DESIGN.md b/DESIGN.md
index a021732..408cc95 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1,27 +1,55 @@
-# kBenestad — app UI brief
+# DESIGN.md — kBenestad web app design contract
-**Drop this into a project's `CLAUDE.md` (or paste it to Claude Code) whenever you build or
-restyle a kBenestad web app.** It is the shared visual contract: follow it and any new
-screen — invoice, timesheet, reimburse, a dashboard, a settings page — will look like it
-belongs to the same family.
+**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.
-This brief tells you *how things should look and behave*. It does **not** redefine what an
-app does. Keep each app's existing purpose, data model, and screens intact — only the
-surface (layout, type, colour, components, copy) is governed here.
+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. The one-paragraph version
+## 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.
+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:
+**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.
@@ -30,36 +58,11 @@ If you only remember five rules:
---
-## 1. Setup — wire in the design system
-
-All tokens are CSS custom properties. Pull them in once, then reference `var(--token)`
-everywhere. The canonical source is the kBenestad design system's `styles.css` closure
-(`tokens/colors.css`, `typography.css`, `spacing.css`, `elevation.css`, `fonts.css`,
-`base.css`). Copy those into the app and `@import` them from a single stylesheet, or copy
-the token blocks inline.
-
-**Fonts** — Schibsted Grotesk (400–800) + JetBrains Mono (400–600). Load from a webfont host
-with a `local()` first and a full system fallback stack so the app still 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;
-```
-
-**Dark mode** — support both. Honour the OS by default and allow a manual override:
-`@media (prefers-color-scheme: dark)` *and* `:root[data-theme="dark"]` both flip the same
-tokens. Never hardcode a hex that won't invert — always go through a semantic token.
-
-**For form-driven apps specifically:** `mockups/kbenestad-forms.css` is a finished,
-proven component layer (the `.kb-*` classes used below) built on exactly these tokens. Lift
-it wholesale rather than rebuilding inputs/cards/buttons from scratch.
-
----
-
## 2. Colour tokens
-Reference the **semantic aliases**, not the raw ramps. The raw ramps exist so the aliases
-have something to point at; your code should almost never name a `--blue-500` directly.
+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`,
@@ -80,20 +83,32 @@ variants as fills, the solid as border/text.
**Focus** — `--focus-ring` (a 3px soft-blue ring). Always visible via `:focus-visible`.
-**Terminal** (CLI / gitxt only) — `--term-bg`, `--term-fg`, `--term-dim`, `--term-accent`,
-`--term-green`, `--term-amber`. Don't use these in normal app UI.
+**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
-- **Families:** `--font-sans` everywhere; `--font-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`.
+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.
@@ -109,6 +124,8 @@ variants as fills, the solid as border/text.
## 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
@@ -144,21 +161,22 @@ The voice is understated, precise, practical. These are working tools, not marke
- **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"**; the maker is "I/me". Avoid corporate "we".
+- **Speak to the user as "you"**; keep institutional "we" out of working UI.
---
-## 6. Form-driven UIs (invoice, timesheet, reimburse, settings, anything with inputs)
+## 6. Form-driven UIs (anything with inputs — forms, settings, editors, document builders)
-These apps are the core of the family. The rules below are what make them feel unified.
+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 *customer's* identity leads (logo tile + org name on
-the left, document title + period on the right). kBenestad is the quiet signature in the
-footer, never the headline.
+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
@@ -168,7 +186,8 @@ push down**, while the field beside it stays aligned to the top. This keeps labe
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.
+ 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.
@@ -192,7 +211,7 @@ A header row of eyebrow labels, then data rows on a matching grid. Row hover get
rows can be colour-coded (see §8). Trailing add/remove circle buttons. Subtotals and the
running total use mono tabular figures.
-### 6.6 Totals panel
+### 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.
@@ -200,52 +219,53 @@ 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`/info). 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
+(`--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. Strings come from the config's i18n map; support the configured
-languages.
+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
-Dashboards use the same DNA, just wider (`--container-xl` 1280px) and grid-arranged.
+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.
+- **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 of dashboards. 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. Use the blue
- accent as the primary series; pull 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`. A legend reuses the same chip styling.
+- **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 from it by a hairline. Active segment = `--accent-soft` fill + accent text.
+ 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 (the timesheet pattern — use it everywhere it fits)
+## 8. Colour-coding categorical data (use it everywhere it fits)
-This is the pattern from the timesheet that works well: any time data falls into **named
-categories** — work codes, expense categories, invoice 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.
+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
@@ -257,47 +277,51 @@ custom properties so they stay configurable:
--chip-text : a darker shade of the hue (readable label colour)
```
-You can 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.
+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. This is the primary, always-legible token. Used in cells,
- filters, and legends.
+ 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** (the timesheet's `muted-background` option): some users
- want the calm border-only view, others want the colour wash. Default to the quieter one.
+ 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 Full-day vs partial, and state variants
-A category can carry more than one colour for sub-states (the timesheet's PPT uses a
-red full-day vs amber partial-day pair). Model these as variant classes/props
-(`c-PPT` vs `c-PPTp`) rather than inventing ad-hoc colours at the call site.
+### 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 family
-The *same* category palette should drive chips, row borders, row tints, **and** chart
-series — so a "Travel" expense is the same colour in the table, the legend, and the pie
-chart. Define the palette once per app and pull from it everywhere.
+### 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
+## 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.
@@ -307,16 +331,19 @@ chart. Define the palette once per app and pull from it everywhere.
- 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 (`--blue-400`-ish) for contrast on dark.
-- Shadows get heavier/darker (already handled by the dark elevation tokens).
+ 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.
@@ -325,6 +352,7 @@ chart. Define the palette once per app and pull from it everywhere.
- 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.
@@ -335,5 +363,6 @@ chart. Define the palette once per app and pull from it everywhere.
---
-*This brief governs surface only. When a real codebase or brand guide exists, reconcile
-these tokens against it rather than overriding the product.*
+*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.*
diff --git a/dev/design_assets/favicons/apple-touch-icon.png b/dev/design_assets/favicons/apple-touch-icon.png
new file mode 100644
index 0000000..b85a5fb
Binary files /dev/null and b/dev/design_assets/favicons/apple-touch-icon.png differ
diff --git a/dev/design_assets/favicons/favicon-16.png b/dev/design_assets/favicons/favicon-16.png
new file mode 100644
index 0000000..919374b
Binary files /dev/null and b/dev/design_assets/favicons/favicon-16.png differ
diff --git a/dev/design_assets/favicons/favicon-32.png b/dev/design_assets/favicons/favicon-32.png
new file mode 100644
index 0000000..3a90aeb
Binary files /dev/null and b/dev/design_assets/favicons/favicon-32.png differ
diff --git a/dev/design_assets/favicons/favicon-48.png b/dev/design_assets/favicons/favicon-48.png
new file mode 100644
index 0000000..bf021df
Binary files /dev/null and b/dev/design_assets/favicons/favicon-48.png differ
diff --git a/dev/design_assets/favicons/favicon.svg b/dev/design_assets/favicons/favicon.svg
new file mode 100644
index 0000000..9fc741b
--- /dev/null
+++ b/dev/design_assets/favicons/favicon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dev/design_assets/favicons/icon-512.png b/dev/design_assets/favicons/icon-512.png
new file mode 100644
index 0000000..ad7cda2
Binary files /dev/null and b/dev/design_assets/favicons/icon-512.png differ
diff --git a/dev/design_assets/favicons/site.webmanifest b/dev/design_assets/favicons/site.webmanifest
new file mode 100644
index 0000000..0890ad1
--- /dev/null
+++ b/dev/design_assets/favicons/site.webmanifest
@@ -0,0 +1,35 @@
+{
+ "name": "Reimburse",
+ "short_name": "reimburse",
+ "theme_color": "#2f6fed",
+ "background_color": "#2f6fed",
+ "display": "standalone",
+ "icons": [
+ {
+ "src": "icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "apple-touch-icon.png",
+ "sizes": "180x180",
+ "type": "image/png"
+ },
+ {
+ "src": "favicon-48.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ },
+ {
+ "src": "favicon-32.png",
+ "sizes": "32x32",
+ "type": "image/png"
+ },
+ {
+ "src": "favicon-16.png",
+ "sizes": "16x16",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/dev/design_assets/reimburse-glyph.svg b/dev/design_assets/reimburse-glyph.svg
new file mode 100644
index 0000000..96b9f2e
--- /dev/null
+++ b/dev/design_assets/reimburse-glyph.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/dev/design_assets/reimburse.svg b/dev/design_assets/reimburse.svg
new file mode 100644
index 0000000..9fc741b
--- /dev/null
+++ b/dev/design_assets/reimburse.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dev/mockups/invoice.html b/dev/mockups/invoice.html
new file mode 100644
index 0000000..c1d1b5f
--- /dev/null
+++ b/dev/mockups/invoice.html
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+Invoice — kBenestad reskin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bill to
+
+
+ Recipient
+ Acme Corporation Example NGO Other…
+
+
+ Address
+
+
+
+
VAT ID
+
Project code
+ AC-100 AC-110 AC-200
+
+
+
+
+
+ Details
+
+
Issue date
+
Due date
+
Currency
+ USD — US dollar EUR — Euro NOK — Norwegian krone
+
+
Terms
+
+
+
+
+
+
+
+
+
+
+ Tax
+
+ Type Rate % Amount
+
+
+ VAT GST Sales Tax
+
+ 1,400.00
+ −
+
+ + Add tax line
+
+
+
+ Summary
+
+
Subtotal 5,600.00
+
VAT 25% 1,400.00
+
Total due (USD) 7,000.00
+
+
+
+
+
+
+ Payment information
+
+
+
+
+
+ Generate Invoice PDF
+
+
+
+
+
+
diff --git a/dev/mockups/kbenestad-forms.css b/dev/mockups/kbenestad-forms.css
new file mode 100644
index 0000000..b8aa98e
--- /dev/null
+++ b/dev/mockups/kbenestad-forms.css
@@ -0,0 +1,356 @@
+/* ============================================================================
+ kBenestad — unified forms design language
+ Shared foundation for invoice · timesheet · reimburse
+ ----------------------------------------------------------------------------
+ Customer-facing white-label apps: the CUSTOMER's identity leads (logo + org
+ name in the header); kBenestad is the quiet craft signature.
+
+ Configurable in each app's config.yml (sensible defaults shown):
+ accent-colour: "#2F6FED" → --accent (recolour to the customer brand)
+ font-size: 1.0 → --font-scale (screen text multiplier)
+ code colours (timesheet) → per-chip --chip-border / --chip-bg
+ ----------------------------------------------------------------------------
+ Type: Schibsted Grotesk (text) + JetBrains Mono (figures), system fallback
+ so the forms render fully offline if the webfonts are unavailable.
+ ========================================================================== */
+
+/* ── Fonts: load if present, but the stacks below fall back to system ─────── */
+@font-face {
+ font-family: "Schibsted Grotesk"; font-style: normal; font-weight: 400 800;
+ font-display: swap;
+ src: local("Schibsted Grotesk"),
+ url("https://fonts.bunny.net/schibsted-grotesk/files/schibsted-grotesk-latin-400-normal.woff2") format("woff2");
+}
+@font-face {
+ font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 600;
+ font-display: swap;
+ src: local("JetBrains Mono"),
+ url("https://fonts.bunny.net/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2") format("woff2");
+}
+
+/* ── Tokens ───────────────────────────────────────────────────────────────── */
+:root {
+ --font-sans: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, system-ui, sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+
+ /* screen type multiplier (config: font-size) — base unit is 16px */
+ --font-scale: 1;
+ --fs-base: calc(15px * var(--font-scale));
+ --fs-input: calc(15px * var(--font-scale));
+ --fs-label: calc(12px * var(--font-scale));
+ --fs-title: calc(13px * var(--font-scale));
+ --fs-small: calc(12.5px * var(--font-scale));
+ --fs-h1: calc(22px * var(--font-scale));
+
+ /* accent — single recolourable token (config: accent-colour) */
+ --accent: #2F6FED;
+ --accent-hover: #1F57CF;
+ --accent-soft: #EEF3FE;
+ --accent-border: #C7D9FB;
+ --on-accent: #FFFFFF;
+
+ /* surfaces & ink (light) */
+ --bg: #F4F6F9;
+ --surface: #FFFFFF;
+ --surface-2: #F8F9FB;
+ --surface-3: #F1F3F6;
+ --border: #E3E7EE;
+ --border-strong:#D3D9E2;
+ --text: #14181E;
+ --text-soft: #3A434F;
+ --text-muted: #5F6975;
+ --placeholder: #9AA3AF;
+
+ /* semantic */
+ --danger: #D64545; --danger-soft: #FBEAEA; --danger-border: #F0C9C9;
+ --warning: #C9851F; --warning-soft: #FBF1DD; --warning-border: #EED9AD;
+ --success: #1F9D5F; --success-soft: #E2F3EA; --success-border: #BFE3CF;
+ --info: #2F6FED; --info-soft: #EEF3FE; --info-border: #C7D9FB;
+
+ /* shape & depth */
+ --radius: 8px;
+ --radius-sm: 6px;
+ --radius-pill: 999px;
+ --shadow-sm: 0 1px 2px rgba(20,24,30,.05);
+ --shadow: 0 6px 22px rgba(20,24,30,.08);
+ --ring: 0 0 0 3px rgba(47,111,237,.20);
+
+ color-scheme: light;
+}
+
+/* ── Dark — auto by system, or forced via [data-theme="dark"] ─────────────── */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --accent: #5685E9; --accent-hover: #6C98EF; --accent-soft: #16233F; --accent-border: #21386A; --on-accent: #FFFFFF;
+ --bg: #0D1117; --surface: #161B22; --surface-2: #1C232C; --surface-3: #1C232C;
+ --border: #232A33; --border-strong: #2D3641;
+ --text: #EEF1F5; --text-soft: #C2CAD3; --text-muted: #8B95A1; --placeholder: #6F7986;
+ --danger:#E06464; --danger-soft:#341819; --danger-border:#5A2A2A;
+ --warning:#D99A3A; --warning-soft:#33270F; --warning-border:#574017;
+ --success:#3BB97A; --success-soft:#13301F; --success-border:#1F4D33;
+ --info:#88ABF2; --info-soft:#16233F; --info-border:#21386A;
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.4); --shadow: 0 8px 28px rgba(0,0,0,.5);
+ --ring: 0 0 0 3px rgba(86,133,233,.32);
+ color-scheme: dark;
+ }
+}
+:root[data-theme="dark"] {
+ --accent: #5685E9; --accent-hover: #6C98EF; --accent-soft: #16233F; --accent-border: #21386A; --on-accent: #FFFFFF;
+ --bg: #0D1117; --surface: #161B22; --surface-2: #1C232C; --surface-3: #1C232C;
+ --border: #232A33; --border-strong: #2D3641;
+ --text: #EEF1F5; --text-soft: #C2CAD3; --text-muted: #8B95A1; --placeholder: #6F7986;
+ --danger:#E06464; --danger-soft:#341819; --danger-border:#5A2A2A;
+ --warning:#D99A3A; --warning-soft:#33270F; --warning-border:#574017;
+ --success:#3BB97A; --success-soft:#13301F; --success-border:#1F4D33;
+ --info:#88ABF2; --info-soft:#16233F; --info-border:#21386A;
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.4); --shadow: 0 8px 28px rgba(0,0,0,.5);
+ --ring: 0 0 0 3px rgba(86,133,233,.32);
+ color-scheme: dark;
+}
+
+/* ── Base ─────────────────────────────────────────────────────────────────── */
+* { box-sizing: border-box; }
+.kb {
+ font-family: var(--font-sans);
+ font-size: var(--fs-base);
+ line-height: 1.55;
+ color: var(--text);
+ background: var(--bg);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ letter-spacing: -0.006em;
+}
+.kb-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
+
+/* ── Page shell ───────────────────────────────────────────────────────────── */
+.kb-wrap { max-width: 960px; margin: 0 auto; padding: 28px 20px 56px; }
+
+/* ── Top utility bar (language / text-size / about) ───────────────────────── */
+.kb-toolbar {
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
+ margin-bottom: 16px;
+}
+.kb-toolbar .spacer { flex: 1; }
+.kb-seg {
+ display: inline-flex; align-items: center; gap: 2px;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); padding: 3px;
+}
+.kb-seg button {
+ font: 600 var(--fs-small)/1 var(--font-sans);
+ color: var(--text-muted); background: transparent; border: 0;
+ white-space: nowrap;
+ padding: 6px 11px; border-radius: 4px; cursor: pointer;
+}
+.kb-seg button.is-active { background: var(--accent-soft); color: var(--accent); }
+.kb-seg button:hover:not(.is-active) { color: var(--text); }
+.kb-iconbtn {
+ display: inline-grid; place-items: center; width: 34px; height: 34px;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius-sm); color: var(--text-muted); cursor: pointer;
+}
+.kb-iconbtn:hover { color: var(--accent); border-color: var(--accent-border); }
+
+/* ── Document header: customer leads ──────────────────────────────────────── */
+.kb-header {
+ display: flex; justify-content: space-between; align-items: flex-start;
+ gap: 24px; padding-bottom: 20px; margin-bottom: 22px;
+ border-bottom: 1px solid var(--border);
+}
+.kb-brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
+.kb-brand .logo {
+ height: 46px; width: 46px; flex: 0 0 46px; border-radius: 10px;
+ display: grid; place-items: center; background: var(--accent-soft);
+ color: var(--accent); font-weight: 800; font-size: 18px; overflow: hidden;
+}
+.kb-brand .logo img { width: 100%; height: 100%; object-fit: contain; }
+.kb-brand .org { font-size: 17px; font-weight: 700; color: var(--text); letter-spacing: -0.01em; }
+.kb-brand .org small { display: block; font-size: var(--fs-small); font-weight: 500; color: var(--text-muted); letter-spacing: 0; }
+.kb-doctitle { text-align: right; }
+.kb-doctitle h1 {
+ margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em;
+ color: var(--text);
+}
+.kb-doctitle .meta { margin-top: 4px; font-size: var(--fs-small); color: var(--text-muted); font-family: var(--font-mono); }
+
+/* ── Cards / sections ─────────────────────────────────────────────────────── */
+.kb-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-sm);
+ padding: 20px 22px;
+ margin-bottom: 16px;
+}
+.kb-card__title {
+ display: flex; align-items: center; gap: 9px;
+ font-size: var(--fs-title); font-weight: 700; letter-spacing: -0.005em;
+ color: var(--text-soft);
+ margin: 0 0 16px;
+}
+.kb-card__title::before {
+ content: ""; width: 3px; height: 14px; border-radius: 2px; background: var(--accent);
+}
+.kb-card__title .count { margin-left: auto; font-weight: 500; color: var(--text-muted); font-size: var(--fs-small); }
+
+/* ── Fields ───────────────────────────────────────────────────────────────── */
+.kb-grid { display: grid; gap: 14px 16px; }
+.kb-grid.cols-2 { grid-template-columns: 1fr 1fr; }
+.kb-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
+.kb-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
+.kb-field.grow { flex: 1; }
+.kb-label {
+ font-size: var(--fs-label); font-weight: 600; letter-spacing: 0.03em;
+ text-transform: uppercase; color: var(--text-muted);
+}
+.kb-input, .kb-select, .kb-textarea {
+ width: 100%; font: 400 var(--fs-input)/1.4 var(--font-sans);
+ color: var(--text); background: var(--surface);
+ border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
+ padding: 9px 11px; outline: none; transition: border-color .14s, box-shadow .14s;
+}
+.kb-input::placeholder, .kb-textarea::placeholder { color: var(--placeholder); }
+.kb-input:focus, .kb-select:focus, .kb-textarea:focus {
+ border-color: var(--accent); box-shadow: var(--ring);
+}
+.kb-input:disabled, .kb-select:disabled, .kb-input[readonly] {
+ background: var(--surface-3); color: var(--text-muted); cursor: not-allowed;
+}
+.kb-input.num { font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums; }
+.kb-textarea { resize: vertical; min-height: 46px; }
+.kb-select {
+ appearance: none;
+ background-image: url("data:image/svg+xml;utf8, ");
+ background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px;
+}
+.kb-input.is-error, .kb-select.is-error { border-color: var(--danger); }
+.kb-input.is-warn { border-color: var(--warning); background: var(--warning-soft); }
+
+/* ── Buttons ──────────────────────────────────────────────────────────────── */
+.kb-btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
+ white-space: nowrap;
+ font: 600 var(--fs-input)/1 var(--font-sans);
+ padding: 10px 16px; border-radius: var(--radius-sm);
+ border: 1px solid transparent; cursor: pointer; transition: background .14s, border-color .14s, color .14s;
+}
+.kb-btn svg { width: 16px; height: 16px; }
+.kb-btn--primary { background: var(--accent); color: var(--on-accent); }
+.kb-btn--primary:hover { background: var(--accent-hover); }
+.kb-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
+.kb-btn--ghost { background: var(--surface); color: var(--text-soft); border-color: var(--border-strong); }
+.kb-btn--ghost:hover { border-color: var(--accent); color: var(--accent); }
+.kb-btn--soft { background: var(--accent-soft); color: var(--accent); }
+.kb-btn--soft:hover { background: var(--accent); color: var(--on-accent); }
+.kb-btn--dashed { background: transparent; color: var(--accent); border: 1px dashed var(--accent-border); }
+.kb-btn--dashed:hover { background: var(--accent-soft); border-color: var(--accent); }
+.kb-btn--danger-ghost { background: transparent; color: var(--danger); padding: 6px 10px; }
+.kb-btn--danger-ghost:hover { background: var(--danger-soft); }
+.kb-btn--lg { padding: 13px 26px; font-size: calc(15px * var(--font-scale)); }
+.kb-btn--block { width: 100%; }
+
+/* round add/remove */
+.kb-circbtn {
+ width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center;
+ font-size: 15px; line-height: 1; font-weight: 700; cursor: pointer; padding: 0;
+ background: var(--surface); border: 1px solid var(--accent); color: var(--accent);
+}
+.kb-circbtn:hover { background: var(--accent); color: var(--on-accent); }
+.kb-circbtn--rm { border-color: var(--danger); color: var(--danger); }
+.kb-circbtn--rm:hover { background: var(--danger); color: #fff; }
+
+/* ── Dividers — deliberately simple (no overlap, no doubled rules) ────────── */
+.kb-divider { height: 1px; background: var(--border); border: 0; margin: 18px 0; }
+.kb-divider--strong { background: var(--border-strong); }
+
+/* ── Item / line blocks ───────────────────────────────────────────────────── */
+.kb-block {
+ border: 1px solid var(--border); border-radius: var(--radius);
+ background: var(--surface-2); padding: 16px 18px; margin-bottom: 14px;
+}
+.kb-block__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.kb-block__head .tag { font-size: var(--fs-label); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--accent); }
+.kb-subtotal { font-family: var(--font-mono); font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
+
+/* ── Tables / row grids ───────────────────────────────────────────────────── */
+.kb-rowhead, .kb-row { display: grid; align-items: center; gap: 8px; }
+.kb-rowhead {
+ padding: 0 10px 8px; font-size: var(--fs-label); font-weight: 700;
+ text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted);
+}
+.kb-rowhead .r { text-align: right; }
+.kb-row {
+ padding: 7px 10px; border-radius: var(--radius-sm);
+ border-left: 3px solid transparent;
+}
+.kb-row:hover { background: var(--surface-2); }
+.kb-row .r { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
+
+/* ── Code chips (timesheet) — colours come from config per code ───────────── */
+.kb-chip {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 3px 10px; border-radius: var(--radius-pill);
+ font-size: var(--fs-small); font-weight: 600; line-height: 1.3;
+ white-space: nowrap;
+ /* per-code overrides set --chip-bg / --chip-border / --chip-text inline */
+ background: var(--chip-bg, var(--surface-3));
+ border: 1px solid var(--chip-border, var(--border-strong));
+ color: var(--chip-text, var(--text-soft));
+}
+.kb-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--chip-border, var(--text-muted)); }
+
+/* a row tinted by its code colour */
+.kb-row.coded { border-left-color: var(--chip-border, transparent); }
+.kb-row.coded.tint { background: color-mix(in srgb, var(--chip-bg, transparent) 45%, var(--surface)); }
+
+/* ── Totals panel ─────────────────────────────────────────────────────────── */
+.kb-totals { margin-left: auto; width: min(380px, 100%); }
+.kb-totals--fill { margin-left: 0; width: 100%; }
+.kb-card--flex { display: flex; flex-direction: column; }
+.kb-card--flex .kb-totals { margin-top: auto; }
+.kb-totals .row { display: flex; justify-content: space-between; gap: 16px; padding: 6px 0; font-size: var(--fs-base); }
+.kb-totals .row .lab { color: var(--text-muted); }
+.kb-totals .row .val { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text); }
+.kb-totals .grand {
+ margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border-strong);
+ display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
+}
+.kb-totals .grand .lab { font-weight: 700; color: var(--text); }
+.kb-totals .grand .val { font-family: var(--font-mono); font-weight: 700; font-size: calc(20px * var(--font-scale)); color: var(--accent); font-variant-numeric: tabular-nums; }
+
+/* ── Validation summary ───────────────────────────────────────────────────── */
+.kb-note {
+ border-radius: var(--radius-sm); padding: 12px 16px; margin-bottom: 16px;
+ font-size: var(--fs-small); line-height: 1.7;
+ display: flex; gap: 10px; align-items: flex-start;
+}
+.kb-note svg { width: 17px; height: 17px; flex: 0 0 17px; margin-top: 1px; }
+.kb-note--error { background: var(--danger-soft); border: 1px solid var(--danger-border); color: var(--danger); }
+.kb-note--warning { background: var(--warning-soft); border: 1px solid var(--warning-border); color: var(--warning); }
+.kb-note--success { background: var(--success-soft); border: 1px solid var(--success-border); color: var(--success); }
+.kb-note--info { background: var(--info-soft); border: 1px solid var(--info-border); color: var(--info); }
+.kb-note b { font-weight: 700; }
+
+/* ── Signature ────────────────────────────────────────────────────────────── */
+.kb-sig { border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); height: 96px; }
+
+/* ── Footer (software credit — stays kBenestad) ───────────────────────────── */
+.kb-footer {
+ max-width: 960px; margin: 0 auto; padding: 18px 20px 8px;
+ font-size: var(--fs-small); color: var(--text-muted);
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+}
+.kb-footer a { color: var(--text-muted); text-decoration: none; }
+.kb-footer a:hover { color: var(--accent); text-decoration: underline; }
+.kb-footer .sep { opacity: .45; }
+/* kBenestad mark — two offset rounded squares, upper-right outlined + lower-left solid.
+ Usage: … kBenestad */
+.kb-mark { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-soft); }
+.kb-mark svg { width: 18px; height: 18px; flex: 0 0 18px; overflow: visible; }
+
+@media (max-width: 680px) {
+ .kb-grid.cols-2, .kb-grid.cols-3 { grid-template-columns: 1fr; }
+ .kb-header { flex-direction: column; gap: 14px; }
+ .kb-doctitle { text-align: left; }
+}
diff --git a/dev/mockups/reimburse.html b/dev/mockups/reimburse.html
new file mode 100644
index 0000000..87e1a24
--- /dev/null
+++ b/dev/mockups/reimburse.html
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+Reimbursement — kBenestad reskin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Claimant
+
+
Name
+
Program
+ Legal Aid Program Protection Program General Operations Other…
+
+
Account code
+ 2000 — Travel & Transport 3000 — Office Supplies 4000 — Professional Services
+
+
Claim currency
+ USD — US dollar THB — Thai baht EUR — Euro
+
+
Purpose
+
+
+
+
+
+ Expenses 2 items
+
+
+
+ Item 1 · Transport
+ USD 184.00
+
+
+ Description Amount Currency In USD
+
+
+
+
+ THB USD
+ 184.00
+ −
+
+
+
+
receipt-flight-bkk.pdf 240 KB
+
+
+
+
+
+
+
Foreign currency — enter exchange rate
+
+
+
+ 1 USD =
+
+ THB
+
+
+ 6,440.00 THB
+ ÷
+ 35.00
+ =
+ USD 184.00
+
+
+
+
+
+
+
+
+
+ Item 2 · Accommodation
+ USD 96.00
+
+
+ Description Amount Currency In USD
+
+
+
+
+ USD THB
+ 96.00
+ −
+
+
+
+
guesthouse-invoice.jpg 1.1 MB
+
+
+
+ + Add expense item
+
+
+
+
+
+ Declaration
+
+
+
I certify that the above expenses were incurred on official business and are supported by the attached receipts.
+
+
+
+
+
+ Summary
+
+
Transport 184.00
+
Accommodation 96.00
+
Total claim (USD) 280.00
+
+
+
+
+
+
+ Generate Reimbursement PDF
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/mockups/review.html b/dev/mockups/review.html
new file mode 100644
index 0000000..431d537
--- /dev/null
+++ b/dev/mockups/review.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+kBenestad — unified forms review
+
+
+
+
+
+
+
+
+
+ kBenestad — unified formsinvoice · timesheet · reimburse — review build
+
+
+
App
+
+ Invoice
+ Timesheet
+ Reimburse
+
+
Theme
+
+ Auto
+ Light
+ Dark
+
+
+
+
+
+
+
+
diff --git a/dev/mockups/timesheet.html b/dev/mockups/timesheet.html
new file mode 100644
index 0000000..905d115
--- /dev/null
+++ b/dev/mockups/timesheet.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+Timesheet — kBenestad reskin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Employee
+
Employee type
+ Monthly Hourly Freelance
+
+
Period
+
+
+
+
+
+
+
+
+ Summary
+
+
Total hours 35.0
+
Of which paid leave 4.0
+
Of which holiday 8.0
+
Total (decimal) 35.00
+
+
+
+
+
+
+
+
New timesheet
+
+
Validate
+
+
+ Generate Timesheet
+
+
+
+
+
+
+
diff --git a/dev/mockups/tweaks-panel.jsx b/dev/mockups/tweaks-panel.jsx
new file mode 100644
index 0000000..bec00c3
--- /dev/null
+++ b/dev/mockups/tweaks-panel.jsx
@@ -0,0 +1,541 @@
+// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
+
+/* BEGIN USAGE */
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
+// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw , segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "palette": ["#D97757", "#29261b", "#f6f4ef"],
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('palette', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
+// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
+// options are many or long. For color tweaks always curate 3-4 options rather than
+// a free picker; an option can also be a whole 2–5 color palette (the stored value
+// is the array). The Tweak* controls are a floor, not a ceiling — build custom
+// controls inside the panel if a tweak calls for UI they don't cover.
+/* END USAGE */
+// ─────────────────────────────────────────────────────────────────────────────
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8, ");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
+ overflow-wrap:anywhere}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+
+ .twk-chips{display:flex;gap:6px}
+ .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
+ padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
+ box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
+ transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
+ .twk-chip:hover{transform:translateY(-1px);
+ box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
+ .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
+ 0 2px 6px rgba(0,0,0,.15)}
+ .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
+ display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i:first-child{box-shadow:none}
+ .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
+`;
+
+// ── useTweaks ───────────────────────────────────────────────────────────────
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
+ // useState-style call doesn't write a "[object Object]" key into the persisted
+ // JSON block.
+ const setTweak = React.useCallback((keyOrEdits, val) => {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ setValues((prev) => ({ ...prev, ...edits }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
+ // Same-window signal so in-page listeners (deck-stage rail thumbnails)
+ // can react — the parent message only reaches the host, not peers.
+ window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
+ }, []);
+ return [values, setTweak];
+}
+
+// ── TweaksPanel ─────────────────────────────────────────────────────────────
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability — if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(clampToViewport);
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+
+
+
+ {title}
+ e.stopPropagation()}
+ onClick={dismiss}>✕
+
+
+ {children}
+
+
+ >
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value} }
+
+ {children}
+
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(!value)}>
+
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ // Segments wrap mid-word once per-segment width runs out. The track is
+ // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
+ // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
+ // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
+ // back to a dropdown rather than wrap.
+ const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
+ const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
+ const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
+ if (!fitsAsSegments) {
+ // emits strings — map back to the original option value so the
+ // fallback stays type-preserving (numbers, booleans) like the segment path.
+ const resolve = (s) => {
+ const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
+ return m === undefined ? s : typeof m === 'object' ? m.value : m;
+ };
+ return onChange(resolve(s))} />;
+ }
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
+
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+ onChange(e.target.value)}>
+ {options.map((o) => {
+ const v = typeof o === 'object' ? o.value : o;
+ const l = typeof o === 'object' ? o.label : o;
+ return {l} ;
+ })}
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit} }
+
+ );
+}
+
+// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
+// read on both #111 and #fafafa without per-option configuration. Hex input
+// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
+function __twkIsLight(hex) {
+ const h = String(hex).replace('#', '');
+ const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
+ const n = parseInt(x.slice(0, 6), 16);
+ if (Number.isNaN(n)) return true;
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ return r * 299 + g * 587 + b * 114 > 148000;
+}
+
+const __TwkCheck = ({ light }) => (
+
+
+
+);
+
+// TweakColor — curated color/palette picker. Each option is either a single
+// hex string or an array of 1-5 hex strings; the card adapts — a lone color
+// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
+// rest stacked in a sharp column on the right. onChange emits the
+// option in the shape it was passed (string stays string, array stays array).
+// Without options it falls back to the native color input for back-compat.
+function TweakColor({ label, value, options, onChange }) {
+ if (!options || !options.length) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+ }
+ // Native emits lowercase hex per the HTML spec, so
+ // compare case-insensitively. String() guards JSON.stringify(undefined),
+ // which returns the primitive undefined (no .toLowerCase).
+ const key = (o) => String(JSON.stringify(o)).toLowerCase();
+ const cur = key(value);
+ return (
+
+
+ {options.map((o, i) => {
+ const colors = Array.isArray(o) ? o : [o];
+ const [hero, ...rest] = colors;
+ const sup = rest.slice(0, 4);
+ const on = key(o) === cur;
+ return (
+ onChange(o)}>
+ {sup.length > 0 && (
+
+ {sup.map((c, j) => )}
+
+ )}
+ {on && <__TwkCheck light={__twkIsLight(hero)} />}
+
+ );
+ })}
+
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+ {label}
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
diff --git a/dev/theme/README.md b/dev/theme/README.md
new file mode 100644
index 0000000..4f61b2a
--- /dev/null
+++ b/dev/theme/README.md
@@ -0,0 +1,159 @@
+# kBenestad theme for Forgejo
+
+The kBenestad design language as a Forgejo theme — Nordic-minimal, light-first,
+one calm blue accent. Ships in **light**, **dark**, and **auto** (follows the
+visitor's OS preference).
+
+| File | Theme name | Notes |
+|------|------------|-------|
+| `theme-kbenestad-light.css` | `kbenestad-light` | cool paper ground, slate ink |
+| `theme-kbenestad-dark.css` | `kbenestad-dark` | deep slate, brightened blue |
+| `theme-kbenestad-auto.css` | `kbenestad-auto` | light by day, dark by night |
+
+## How it works
+
+Each theme defines a **complete, self-contained** kBenestad variable set — every
+`--color-*`, the type stack, and the corner radii — then layers a small set of
+**structural overrides** that carry the identity the colors alone can't: Schibsted
+Grotesk / JetBrains Mono type, accent-soft topic pills, flat primary buttons, and
+hairline 8px cards. It also `@import`s Forgejo's matching shipped theme as a
+harmless safety net (it fills any future upstream variables if present, and is
+silently ignored if absent), so the theme keeps working across upgrades.
+
+## Matching the kBenestad mark
+
+The brand direction is **Stack**. Final SVGs live in the design system under
+`assets/logo/`. To dress Forgejo:
+
+| Forgejo path | Use this file |
+|---|---|
+| `custom/public/img/logo.svg` | `mark-stack-color.svg` (or `app-icon.svg` for a tiled mark) |
+| `custom/public/img/favicon.svg` | `favicon.svg` |
+
+No restart needed — hard refresh. For the reversed navbar on the dark theme the
+mark already inherits `currentColor` where possible; if you want a fixed reverse,
+use `mark-stack-white.svg`.
+
+## Install
+
+1. Copy the `.css` files into your custom assets CSS directory:
+
+ ```
+ /public/assets/css/
+ ```
+
+ On most installs `FORGEJO_CUSTOM` is `/data/gitea` (Docker) or the `custom/`
+ folder beside your `app.ini`. The files must sit next to the shipped
+ `theme-forgejo-*.css` so the relative `@import`s resolve.
+
+2. Register the themes in `app.ini` under `[ui]`:
+
+ ```ini
+ [ui]
+ THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-auto,kbenestad-light,kbenestad-dark
+ DEFAULT_THEME = kbenestad-auto
+ ```
+
+ Or via environment variables (Docker):
+
+ ```
+ FORGEJO__ui__THEMES=forgejo-auto,forgejo-light,forgejo-dark,kbenestad-auto,kbenestad-light,kbenestad-dark
+ FORGEJO__ui__DEFAULT_THEME=kbenestad-auto
+ ```
+
+3. A hard refresh is enough — no restart needed for CSS changes. Users can also
+ pick the theme per-account under **Settings → Appearance**.
+
+## Fonts
+
+The themes load **Schibsted Grotesk** and **JetBrains Mono** from Google Fonts via
+`@import` at the top of each file, and force the families directly so they apply
+the instant the fonts are available.
+
+### If the type still looks like the system font
+
+This means the **web font never loaded** — the colors and layout will look right,
+but text falls back to your OS sans-serif. The `@import` from `fonts.googleapis.com`
+is being blocked. Common causes:
+
+- A privacy / tracker blocker in the browser (Vivaldi's built-in blocker, uBlock,
+ Privacy Badger, etc.) blocks Google Fonts domains.
+- A reverse-proxy or `[security]` Content-Security-Policy that disallows external
+ styles/fonts.
+- The instance is offline / air-gapped.
+
+**Fix — self-host the fonts (recommended, bulletproof):**
+
+1. Download the woff2 files:
+ - Schibsted Grotesk (400/500/600/700) —
+ - JetBrains Mono (400/500/600) —
+2. Drop them in `custom/public/assets/fonts/`.
+3. Delete the `@import url('https://fonts.googleapis.com/…')` line at the top of
+ each kBenestad theme file and paste a local block in its place, e.g.:
+
+ ```css
+ @font-face {
+ font-family: "Schibsted Grotesk";
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("/assets/fonts/SchibstedGrotesk.woff2") format("woff2");
+ }
+ @font-face {
+ font-family: "JetBrains Mono";
+ font-weight: 400 600;
+ font-display: swap;
+ src: url("/assets/fonts/JetBrainsMono.woff2") format("woff2");
+ }
+ ```
+
+ (Adjust filenames to the files you downloaded. `/assets/fonts/…` is served
+ directly by Forgejo from `custom/public/assets/fonts/`.)
+
+The `--fonts-proportional` / `--fonts-monospace` variables already point at the
+right family names, so no other change is needed.
+
+## Troubleshooting — "the theme deployed but it still looks stock"
+
+Forgejo's compiled `index.css` is the raw Fomantic/Semantic-UI base, which
+*hardcodes* legacy colours (`.ui.primary.button{background:#2185d0}`,
+`.ui.button{background:#e0e1e2}`, `.ui.label`…). Your `--color-*` variables only
+take effect once the theme stylesheet that re-points those components onto the
+variables is the one actually loaded. If the page still looks like default
+Forgejo, work down this list:
+
+1. **Stale cache (most common).** Forgejo serves files in
+ `custom/public/assets/css/` with a 6-hour browser cache and **no `?v=` buster**
+ (unlike the versioned `index.css?v=9.0.3~gitea-1.22.0`). After editing a theme
+ file, the browser keeps the old copy. Fix: hard-refresh (Ctrl/Cmd-Shift-R), or
+ bump `STATIC_CACHE_TIME` down while iterating, or append a throwaway query when
+ testing. A server restart does **not** clear the *browser's* copy.
+2. **Theme not selected.** Confirm `DEFAULT_THEME = kbenestad-light` (or `-auto`)
+ in `[ui]`, *and* that your account isn't pinned to another theme under
+ **Settings → Appearance**. A per-user choice overrides the default.
+3. **Quick sanity check.** Open dev-tools → inspect `` → Computed →
+ `--color-primary`. It must read `#2f6fed` (ours), not `#4183c4`/`#2185d0`
+ (stock). If it's the stock value, the kBenestad file isn't winning the cascade
+ — that's cause 1 or 2, not the CSS itself.
+4. **Right file path.** The `.css` must sit in `custom/public/assets/css/` so it's
+ served at `/assets/css/theme-kbenestad-light.css` and the relative
+ `@import "./theme-forgejo-light.css"` resolves next to it.
+
+## What the theme recolours (and what it can't)
+
+Pulled onto the brand: primary/positive buttons, repo-header owner/name, repo
+tabs (active label + accent underline) and their count pills, topic chips,
+dropdown/pagination active states, form-focus rings, checkboxes/toggles and
+progress bars. **Language bars** (the Go/Shell/HTML stripe on the repo home) are
+**not** themeable — Forgejo emits those segment colours as inline styles from its
+per-language colour table, so they stay their canonical hues by design.
+
+## Tweaking the accent
+
+The entire accent ramp derives from the Nordic blue `#2f6fed`. To shift it, edit
+the `--color-primary*` block (and the matching `rgba(47, 111, 237, …)` alpha
+values) in each theme file.
+
+## Compatibility
+
+Built against the modern Forgejo CSS-variable theming system (Forgejo v7.0+).
+Gitea compatibility is likely but untested.
diff --git a/dev/theme/kbenestad.yaml b/dev/theme/kbenestad.yaml
new file mode 100644
index 0000000..59defe8
--- /dev/null
+++ b/dev/theme/kbenestad.yaml
@@ -0,0 +1,101 @@
+# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
+# mdcms theme — kBenestad
+# Nordic-minimal: cool paper ground, near-black slate ink, one calm blue accent.
+# Mirrors the kBenestad design language (Schibsted Grotesk + a single #2F6FED blue).
+#
+# Install:
+# 1. Copy this file into your project root (next to config.yml).
+# 2. In config.yml set: theme: kbenestad.yaml
+# (or rename this file to theme.yml to replace the default)
+
+# ──────────────────────────────────
+# Colours
+# ──────────────────────────────────
+light:
+ accent: "#2F6FED"
+ background: "#FFFFFF"
+ nav-background: "#F8F9FB"
+ text: "#14181E"
+ text-muted: "#6B7785"
+ nav-link: "#3A434F" # inactive nav link text
+ nav-link-active: "#2F6FED" # active nav link text
+ nav-section-heading: "#6B7785" # nav section label text
+ nav-sitename: "#14181E" # site name in sidebar header
+ nav-description: "#6B7785" # site description in sidebar header
+ nav-toggle: "#6B7785" # dark/light mode toggle
+ divider: "#E7EAEF" # border/hr colour
+
+dark:
+ accent: "#5685E9"
+ background: "#0D1117"
+ nav-background: "#161B22"
+ text: "#EEF1F5"
+ text-muted: "#8B95A1"
+ nav-link: "#C2CAD3" # inactive nav link text
+ nav-link-active: "#5685E9" # active nav link text
+ nav-section-heading: "#8B95A1" # nav section label text
+ nav-sitename: "#EEF1F5" # site name in sidebar header
+ nav-description: "#8B95A1" # site description in sidebar header
+ nav-toggle: "#8B95A1" # dark/light mode toggle
+ divider: "#232A33" # border/hr colour
+
+# ──────────────────────────────────
+# Semantic colours
+# Used by callout tags (info, warning, success, error).
+# colours-semantic applies to both modes; colours-semantic-dark overrides for dark mode.
+# ──────────────────────────────────
+colours-semantic:
+ info: "#2F6FED"
+ warning: "#C9851F"
+ success: "#1F9D5F"
+ error: "#D64545"
+
+colours-semantic-dark:
+ info: "#88ABF2"
+ warning: "#D99A3A"
+ success: "#3BB97A"
+ error: "#E06464"
+
+# ──────────────────────────────────
+# Callout defaults
+# primary-colour matches colours-semantic (light mode).
+# ──────────────────────────────────
+callouts:
+ info:
+ icon: info
+ primary-colour: "#2F6FED"
+ background-colour: "#2F6FED"
+ warning:
+ icon: warning
+ primary-colour: "#C9851F"
+ background-colour: "#C9851F"
+ success:
+ icon: success
+ primary-colour: "#1F9D5F"
+ background-colour: "#1F9D5F"
+ error:
+ icon: error
+ primary-colour: "#D64545"
+ background-colour: "#D64545"
+
+# ──────────────────────────────────
+# Typography
+# Format: "provider:Font Name:weight" (provider: bunny | google)
+# Schibsted Grotesk is the kBenestad type voice — clean Nordic grotesque.
+# ──────────────────────────────────
+font-body: "bunny:Schibsted Grotesk:400"
+font-heading: "bunny:Schibsted Grotesk:700"
+font-size: 1.00 # unitless multiplier (1.0 = 16px base)
+line-height: 1.70 # unitless multiplier
+
+# ──────────────────────────────────
+# Nav section toggle icons
+# ──────────────────────────────────
+nav-section-expand-icon: keyboard_arrow_right
+nav-section-collapse-icon: keyboard_arrow_down
+
+# ──────────────────────────────────
+# Layout
+# ──────────────────────────────────
+main-width: 80em
+nav-width: 20em
diff --git a/dev/theme/preview.html b/dev/theme/preview.html
new file mode 100644
index 0000000..f173c5d
--- /dev/null
+++ b/dev/theme/preview.html
@@ -0,0 +1,827 @@
+
+
+
+
+
+kb / utils · code.kbenestad
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Light
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ main
+
+
+
+
+
Go to file
+
+
+ Add file
+
+
+
+ Code
+
+
+
+
+
+
+
+
KB
+
kb
+
Tighten retry backoff jitter; cap at 30s
+
a8f3c0e
+
· 2 days ago
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
utils
+
A small, opinionated bundle of Go helpers used across kb/* services —
+ retry/backoff, slug, env loading, structured errors. Zero non-stdlib runtime
+ dependencies; everything else is dev-only.
+
+
Install
+
# Go module
+go get code.kbenestad.net/kb/utils@v2.4.0
+
+# Or via kbpkg, our internal package manager
+kbpkg install kb/utils
+
+
+
Usage
+
package main
+
+import (
+ "context"
+ "code.kbenestad.net/kb/utils/retry"
+)
+
+func main() {
+ ctx := context.Background()
+ _ = retry.Do(ctx, retry.Default, func () error {
+ // network call here
+ return nil
+ })
+}
+
+
+
What's inside
+
+ retry — context-aware exponential backoff with full jitter (cap 30s).
+ slug — Unicode-correct slugification; handles combining marks.
+ envx — typed env loading with defaults and required-key checks.
+ errs — structured error wrapping that survives JSON round-trips.
+ iox — small io helpers (limited readers, atomic file writes).
+
+
+
Stability
+
Public API follows semver. Anything under internal/ is fair game and
+ will change without notice.
+
+
+
+
+
+
+
+
+ About
+
+
+ Small Go helpers shared across kBenestad services — retry, slug, env, structured errors.
+
+
+
+
+
+
+
+
+
+
+
+ Languages
+
+
+
+
+
+
+ Go 78.2%
+ Shell 13.7%
+ Makefile 8.1%
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/theme/theme-kbenestad-auto.css b/dev/theme/theme-kbenestad-auto.css
new file mode 100644
index 0000000..35c91f6
--- /dev/null
+++ b/dev/theme/theme-kbenestad-auto.css
@@ -0,0 +1,15 @@
+/* ============================================================================
+ theme-kbenestad-auto.css
+ kBenestad — Forgejo theme (AUTO)
+ Follows the visitor's OS light/dark preference: kBenestad light by day,
+ kBenestad dark by night. Register as `kbenestad-auto` in app.ini [ui] THEMES.
+ ----------------------------------------------------------------------------
+ Media-conditional @imports load the matching kBenestad theme; the base
+ forgejo-auto import guarantees a complete variable set as a fallback.
+ ========================================================================== */
+
+@import "./theme-forgejo-auto.css";
+@import "./theme-kbenestad-light.css" (prefers-color-scheme: light);
+@import "./theme-kbenestad-dark.css" (prefers-color-scheme: dark);
+
+:root { color-scheme: light dark; }
diff --git a/dev/theme/theme-kbenestad-dark.css b/dev/theme/theme-kbenestad-dark.css
new file mode 100644
index 0000000..5a1f670
--- /dev/null
+++ b/dev/theme/theme-kbenestad-dark.css
@@ -0,0 +1,464 @@
+/* ============================================================================
+ theme-kbenestad-dark.css
+ kBenestad — Forgejo theme (DARK)
+ Faithful inversion onto deep slate (#0d1117) with a lighter blue for contrast.
+
+ Install: drop this file in custom/public/assets/css/
+ then in app.ini [ui]
+ THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-light,kbenestad-dark
+ DEFAULT_THEME = kbenestad-dark
+ ----------------------------------------------------------------------------
+ Strategy: a COMPLETE, self-contained variable set followed by the same
+ structural overrides as the light theme — Schibsted Grotesk type, accent-soft
+ topic pills, flat buttons, hairline cards.
+ ========================================================================== */
+
+@import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
+@import "./theme-forgejo-dark.css";
+
+:root {
+ color-scheme: dark;
+ --is-dark-theme: true;
+
+ /* ── Type ──────────────────────────────────────────────────────────────────
+ Forgejo resolves body type as var(--fonts-override, var(--fonts-proportional)),
+ "Noto Sans", … — so these vars ARE the hook. Marked !important to win even
+ if the shipped base theme sets them !important first (per Forgejo's docs). */
+ --fonts-proportional: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
+ --fonts-regular: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
+ --fonts-monospace: "JetBrains Mono", ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace !important;
+
+ /* ── Corners ───────────────────────────────────────────────────────────── */
+ --border-radius: 6px;
+ --border-radius-medium: 8px;
+ --border-radius-full: 99999px;
+
+ /* ── Accent — Nordic blue, brightened toward light for dark ground ─────── */
+ --color-primary: #2f6fed;
+ --color-primary-contrast: #ffffff;
+ --color-primary-dark-1: #4f82ec;
+ --color-primary-dark-2: #6c98ef;
+ --color-primary-dark-3: #88abf2;
+ --color-primary-dark-4: #a6c1f5;
+ --color-primary-dark-5: #c2d5f8;
+ --color-primary-dark-6: #dbe7fb;
+ --color-primary-dark-7: #eef3fe;
+ --color-primary-light-1: #245ac8;
+ --color-primary-light-2: #1f57cf;
+ --color-primary-light-3: #1b46a3;
+ --color-primary-light-4: #163b88;
+ --color-primary-light-5: #142e63;
+ --color-primary-light-6: #102450;
+ --color-primary-light-7: #0c1c3e;
+ --color-primary-alpha-10: rgba(86, 133, 233, 0.10);
+ --color-primary-alpha-20: rgba(86, 133, 233, 0.20);
+ --color-primary-alpha-30: rgba(86, 133, 233, 0.30);
+ --color-primary-alpha-40: rgba(86, 133, 233, 0.40);
+ --color-primary-alpha-50: rgba(86, 133, 233, 0.50);
+ --color-primary-alpha-60: rgba(86, 133, 233, 0.60);
+ --color-primary-alpha-70: rgba(86, 133, 233, 0.70);
+ --color-primary-alpha-80: rgba(86, 133, 233, 0.80);
+ --color-primary-alpha-90: rgba(86, 133, 233, 0.90);
+ --color-primary-hover: #4f82ec;
+ --color-primary-active: #6c98ef;
+
+ /* ── Secondary ─────────────────────────────────────────────────────────── */
+ --color-secondary: #2d3641;
+ --color-secondary-dark-1: #3d4856;
+ --color-secondary-dark-2: #4a5666;
+ --color-secondary-dark-3: #5f6975;
+ --color-secondary-dark-4: #6f7986;
+ --color-secondary-dark-5: #8b95a1;
+ --color-secondary-dark-6: #9aa4af;
+ --color-secondary-dark-7: #aab2bd;
+ --color-secondary-dark-8: #c2cad3;
+ --color-secondary-dark-9: #d3d9e0;
+ --color-secondary-dark-10: #e1e6eb;
+ --color-secondary-dark-11: #eef1f5;
+ --color-secondary-dark-12: #f5f7f9;
+ --color-secondary-dark-13: #ffffff;
+ --color-secondary-light-1: #262e38;
+ --color-secondary-light-2: #232a33;
+ --color-secondary-light-3: #1c232c;
+ --color-secondary-light-4: #161b22;
+ --color-secondary-alpha-10: rgba(123, 134, 148, 0.10);
+ --color-secondary-alpha-20: rgba(123, 134, 148, 0.20);
+ --color-secondary-alpha-30: rgba(123, 134, 148, 0.30);
+ --color-secondary-alpha-40: rgba(123, 134, 148, 0.40);
+ --color-secondary-alpha-50: rgba(123, 134, 148, 0.50);
+ --color-secondary-alpha-60: rgba(123, 134, 148, 0.60);
+ --color-secondary-alpha-70: rgba(123, 134, 148, 0.70);
+ --color-secondary-alpha-80: rgba(123, 134, 148, 0.80);
+ --color-secondary-alpha-90: rgba(123, 134, 148, 0.90);
+ --color-secondary-button: #3d4856;
+ --color-secondary-hover: #1c232c;
+ --color-secondary-active: #232b35;
+
+ /* ── Text / ink ─────────────────────────────────────────────────────────── */
+ --color-text-dark: #ffffff;
+ --color-text: #eef1f5;
+ --color-text-light: #c2cad3;
+ --color-text-light-1: #8b95a1;
+ --color-text-light-2: #6f7986;
+ --color-text-light-3: #5f6975;
+ --color-placeholder-text: #5f6975;
+ --color-text-focus: #ffffff;
+
+ /* ── Surfaces ──────────────────────────────────────────────────────────── */
+ --color-body: #0d1117; /* page ground (deep slate) */
+ --color-box-body: #161b22; /* cards, file list, panels */
+ --color-box-body-highlight: #1c232c;
+ --color-box-header: #1c232c; /* segment / table headers */
+ --color-nav-bg: #161b22; /* top navbar */
+ --color-nav-hover-bg: #1c232c;
+ --color-secondary-nav-bg: #0d1117;
+ --color-footer: #161b22;
+ --color-light: rgba(139, 149, 161, 0.05);
+ --color-light-mimic-enabled: rgba(255, 255, 255, calc(8 / 255));
+ --color-light-border: #232a33;
+ --color-hover: #1c232c; /* row / item hover */
+ --color-active: #232b35; /* active row */
+ --color-menu: #161b22;
+ --color-card: #161b22;
+ --fancy-card-bg: #161b22;
+ --fancy-card-border: #232a33;
+ --color-markup-tab-default: var(--color-box-header);
+ --color-markup-tab-active: var(--color-box-body);
+ --color-header-wrapper: #161b22;
+ --color-header-wrapper-transparent: rgba(22, 27, 34, 0);
+
+ /* ── Inputs ────────────────────────────────────────────────────────────── */
+ --color-input-text: #eef1f5;
+ --color-input-background: #0f141a;
+ --color-input-toggle-background: #0f141a;
+ --color-input-border: #2d3641;
+ --color-input-border-hover: #3d4856;
+
+ /* ── Borders / dividers ────────────────────────────────────────────────── */
+ --color-border: #232a33;
+ --color-secondary-bg: #1c232c;
+
+ /* ── Code / markup ─────────────────────────────────────────────────────── */
+ --color-markup-code-block: #0f141a;
+ --color-markup-code-inline: #1c232c;
+ --color-markup-table-row: rgba(255, 255, 255, 0.024);
+ --color-code-bg: #161b22;
+ --color-code-sidebar-bg: #0f141a;
+
+ /* ── Shadows ───────────────────────────────────────────────────────────── */
+ --color-shadow: rgba(0, 0, 0, 0.45);
+ --color-secondary-shadow: rgba(0, 0, 0, 0.30);
+
+ /* ── Accent line / selection / timeline / caret ────────────────────────── */
+ --color-accent: #5685e9;
+ --color-small-accent: #19243a;
+ --color-active-line: #16233f;
+ --color-editor-line-highlight: #16233f;
+ --color-timeline: #2d3641;
+ --color-caret: #eef1f5;
+ --color-highlight-fg: #88abf2;
+ --color-highlight-bg: rgba(86, 133, 233, 0.16);
+ --color-selection-bg: #16233f;
+ --color-selection-fg: #eef1f5;
+ --color-overlay-backdrop: rgba(0, 0, 0, 0.55);
+
+ /* ── Semantic hues ─────────────────────────────────────────────────────── */
+ --color-red: #e06464;
+ --color-orange: #d99a3a;
+ --color-yellow: #d99a3a;
+ --color-olive: #a3a34a;
+ --color-green: #3bb97a;
+ --color-teal: #3aa6a6;
+ --color-blue: #5685e9;
+ --color-violet: #8579e0;
+ --color-purple: #a06ce0;
+ --color-pink: #e06ca6;
+ --color-brown: #a3866b;
+ --color-grey: #8b95a1;
+ --color-gold: #d99a3a;
+ --color-white: #ffffff;
+ --color-black: #0d1117;
+ --color-pure-black: #000000;
+
+ /* light variants */
+ --color-red-light: #e88585;
+ --color-green-light: #5fd28a;
+ --color-blue-light: #7ba2f0;
+ --color-grey-light: #aab2bd;
+
+ /* dark-1 variants */
+ --color-red-dark-1: #c95252;
+ --color-green-dark-1: #2fa069;
+ --color-blue-dark-1: #4f82ec;
+
+ /* Status text / background / border */
+ --color-success-text: #5fd28a;
+ --color-success-bg: #13301f;
+ --color-success-border: #1f4d33;
+ --color-error-text: #f08a8a;
+ --color-error-bg: #341819;
+ --color-error-bg-active:#4a2222;
+ --color-error-bg-hover: #3d1e1f;
+ --color-error-border: #5a2a2a;
+ --color-warning-text: #e7b35a;
+ --color-warning-bg: #33270f;
+ --color-warning-border: #574017;
+ --color-info-text: #88abf2;
+ --color-info-bg: #16233f;
+ --color-info-border: #21386a;
+ --color-danger-bg: #2a1718;
+
+ /* Diff */
+ --color-diff-removed-word-bg: #5a2a2a;
+ --color-diff-added-word-bg: #1f4d33;
+ --color-diff-removed-row-bg: #2a1718;
+ --color-diff-added-row-bg: #122a1d;
+ --color-diff-removed-row-border: #3d2122;
+ --color-diff-added-row-border: #1b3a28;
+ --color-diff-moved-row-bg: #33270f;
+ --color-diff-moved-row-border:#574017;
+ --color-diff-inactive: #1c232c;
+
+ /* Labels / reactions / tooltip / nav */
+ --color-label-text: #c2cad3;
+ --color-label-bg: #2d3641;
+ --color-label-hover-bg: #3d4856;
+ --color-label-active-bg: #4a5666;
+ --color-reaction-bg: #1c232c;
+ --color-reaction-hover-bg: rgba(86, 133, 233, 0.22);
+ --color-reaction-active-bg: rgba(86, 133, 233, 0.32);
+ --color-tooltip-text: #14181e;
+ --color-tooltip-bg: #eef1f5;
+ --color-button: #3d4856;
+ --color-expand-button: #2d3641;
+
+ /* badges */
+ --color-red-badge: #e06464;
+ --color-red-badge-bg: rgba(224, 100, 100, 0.16);
+ --color-red-badge-bg-hover: rgba(224, 100, 100, 0.30);
+ --color-green-badge: #3bb97a;
+ --color-green-badge-bg: rgba(59, 185, 122, 0.16);
+ --color-green-badge-bg-hover: rgba(59, 185, 122, 0.30);
+ --color-yellow-badge: #d99a3a;
+ --color-yellow-badge-bg: rgba(217, 154, 58, 0.16);
+ --color-yellow-badge-bg-hover: rgba(217, 154, 58, 0.30);
+ --color-orange-badge: #d99a3a;
+ --color-orange-badge-bg: rgba(217, 154, 58, 0.16);
+ --color-orange-badge-bg-hover: rgba(217, 154, 58, 0.30);
+
+ /* status indicators */
+ --color-indicator-offline: #5f6975;
+ --color-indicator-idle: #d99a3a;
+ --color-indicator-active: #3bb97a;
+
+ /* checkerboard (image diff) */
+ --checkerboard-color-1: #161b22;
+ --checkerboard-color-2: #0f141a;
+
+ /* project board */
+ --color-project-board-bg: #0d1117;
+
+ /* console */
+ --color-console-fg: #e6e9ef;
+ --color-console-fg-subtle: #8b95a1;
+ --color-console-bg: #0b0f14;
+ --color-console-border: #1c232c;
+ --color-console-hover-bg: rgba(255, 255, 255, 0.06);
+ --color-console-active-bg: rgba(255, 255, 255, 0.10);
+ --color-console-menu-bg: #161b22;
+ --color-console-menu-border:#2d3641;
+
+ accent-color: var(--color-accent);
+}
+
+/* ===========================================================================
+ STRUCTURAL OVERRIDES — identical identity layer to the light theme.
+ ========================================================================== */
+
+/* ── Type ───────────────────────────────────────────────────────────────── */
+body,
+input, button, select, textarea, optgroup,
+.ui, .ui.menu, .ui.header, .ui.form, .ui.dropdown,
+.ui.dropdown .menu > .item, .ui.input > input,
+.markup, h1, h2, h3, h4, h5, h6,
+.repo-header, .repository .header-wrapper, .commit-summary, .file-info {
+ font-family: var(--fonts-proportional) !important;
+}
+code, pre, tt, kbd, samp, .mono,
+.ui.input.mono, .commit-id, .sha, .ui.label.commit-id,
+.code-view, .lines-code, .lines-num, .CodeMirror,
+.markup code, .markup pre, .markup tt {
+ font-family: var(--fonts-monospace) !important;
+}
+body {
+ letter-spacing: -0.005em;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ── Primary / positive buttons ─────────────────────────────────────────── */
+.ui.primary.button,
+.ui.primary.buttons .button,
+.ui.positive.button,
+.ui.positive.buttons .button {
+ background: var(--color-primary) !important;
+ color: #fff !important;
+ border-color: var(--color-primary) !important;
+ font-weight: 600;
+ box-shadow: none !important;
+}
+.ui.primary.button:hover,
+.ui.primary.buttons .button:hover,
+.ui.positive.button:hover,
+.ui.positive.buttons .button:hover {
+ background: var(--color-primary-hover) !important;
+ border-color: var(--color-primary-hover) !important;
+}
+
+/* ── Default / secondary buttons ────────────────────────────────────────── */
+.ui.button {
+ font-weight: 500;
+ box-shadow: none;
+}
+.ui.basic.button,
+.ui.basic.buttons .button {
+ box-shadow: inset 0 0 0 1px var(--color-border) !important;
+}
+
+/* ── Repo tabs — accent active label + accent underline ─────────────────────
+ The repo tab bar is Forgejo's `.ui.secondary.pointing.menu`; the active item's
+ colour is set by the full active/hover/focus/dropdown selector group, so we
+ match it verbatim to actually win the cascade. */
+.ui.secondary.pointing.menu .active.item,
+.ui.secondary.pointing.menu .active.item:hover,
+.ui.secondary.pointing.menu .active.item:focus,
+.ui.secondary.pointing.menu .dropdown.item:hover,
+.ui.secondary.pointing.menu .dropdown.item:focus,
+.ui.tabular.menu .active.item {
+ color: var(--color-accent) !important;
+ border-color: var(--color-primary) !important;
+ font-weight: 600;
+}
+.ui.secondary.pointing.menu .item:hover {
+ color: var(--color-text) !important;
+}
+/* Tab count pills (12 · 3 · 4) render as plain `.ui.label` inside each item →
+ accent-tinted instead of flat grey. */
+.ui.secondary.pointing.menu .item .ui.label,
+.ui.tabular.menu .item .ui.label {
+ background: var(--color-primary-alpha-10) !important;
+ color: var(--color-accent) !important;
+ border: 1px solid var(--color-primary-alpha-20) !important;
+}
+.ui.secondary.pointing.menu .active.item .ui.label,
+.ui.tabular.menu .active.item .ui.label {
+ background: var(--color-primary) !important;
+ color: #fff !important;
+ border-color: var(--color-primary) !important;
+}
+
+/* ── Repo header title — owner / name carry the accent (branded chrome) ── */
+.repo-header .flex-text-block a,
+.repo-header .repo-title a,
+.repository .header-wrapper .repo-title a {
+ color: var(--color-accent) !important;
+}
+.repo-header .flex-text-block a:hover { text-decoration: underline; }
+/* Watch / Star / Fork stay button-coloured — never accent text */
+.repo-header .repo-buttons a,
+.repo-header .repo-buttons .ui.button {
+ color: var(--color-text) !important;
+}
+
+/* ── Repo topics → real accent pills (clearly blue, never grey) ─────────────
+ Forgejo's topic markup has shifted across versions (#repo-topics > a,
+ a.repo-topic, a.topic.label …) — match them all so the chips are always
+ branded, with a visible blue fill + ring instead of the pale wash. */
+#repo-topics a,
+#repo-topics .ui.label,
+a.repo-topic,
+a.repo-topic.ui.label,
+.repository-topics a.ui.label,
+.topic.ui.label,
+a.topic.label {
+ background: var(--color-primary-alpha-10) !important;
+ color: var(--color-accent) !important;
+ border: 1px solid var(--color-primary-alpha-30) !important;
+ border-radius: var(--border-radius-full) !important;
+ font-weight: 600 !important;
+}
+#repo-topics a:hover,
+a.repo-topic:hover,
+.topic.ui.label:hover {
+ background: var(--color-primary) !important;
+ color: #fff !important;
+ border-color: var(--color-primary) !important;
+}
+
+/* ── Extra interactive coverage — the bits that quietly stay grey ──────────
+ Dropdowns, pagination, form focus, toggles and progress bars across the rest
+ of the app, pulled onto the accent so the brand reads everywhere, not just on
+ the repo home. Trim any rule you don't want. */
+
+/* Dropdown / select menus — selected + active item in accent */
+.ui.dropdown .menu > .item.selected,
+.ui.dropdown .menu > .item.active,
+.ui.selection.dropdown .menu > .item.active.selected {
+ color: var(--color-accent) !important;
+ background: var(--color-primary-alpha-10) !important;
+}
+
+/* Pagination — current page in solid accent */
+.ui.pagination.menu .active.item {
+ background: var(--color-primary) !important;
+ color: #fff !important;
+}
+
+/* Form fields — accent focus ring instead of the default grey/teal */
+.ui.input input:focus,
+.ui.form input:focus,
+.ui.form textarea:focus,
+.ui.selection.active.dropdown,
+.ui.selection.dropdown:focus {
+ border-color: var(--color-primary) !important;
+ box-shadow: 0 0 0 2px var(--color-primary-alpha-20) !important;
+}
+
+/* Checkboxes, radios, toggles — checked state in accent */
+.ui.checkbox input:checked ~ .box::before,
+.ui.checkbox input:checked ~ label::before,
+.ui.radio.checkbox input:checked ~ .box::before,
+.ui.radio.checkbox input:checked ~ label::before,
+.ui.toggle.checkbox input:checked ~ .box::before,
+.ui.toggle.checkbox input:checked ~ label::before {
+ background: var(--color-primary) !important;
+ border-color: var(--color-primary) !important;
+}
+
+/* Progress bars (upload, migration, theme-driven stats) */
+.ui.progress .bar {
+ background: var(--color-primary) !important;
+}
+
+/* ── Cards / segments ───────────────────────────────────────────────────── */
+.ui.segment,
+.ui.segments,
+.ui.attached.segment,
+.repository-summary,
+.repo-description-box,
+.ui.card,
+.ui.cards > .card {
+ box-shadow: 0 1px 0 var(--color-shadow);
+}
+.ui.segments,
+.ui.card,
+.ui.cards > .card {
+ border-radius: var(--border-radius-medium);
+}
+
+/* ── Links ──────────────────────────────────────────────────────────────── */
+a { text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Octicons ───────────────────────────────────────────────────────────── */
+.svg, .gt-octicon, .octicon { fill: currentColor; }
diff --git a/dev/theme/theme-kbenestad-light.css b/dev/theme/theme-kbenestad-light.css
new file mode 100644
index 0000000..ff85472
--- /dev/null
+++ b/dev/theme/theme-kbenestad-light.css
@@ -0,0 +1,472 @@
+/* ============================================================================
+ theme-kbenestad-light.css
+ kBenestad — Forgejo theme (LIGHT)
+ Nordic minimal: cool paper ground, near-black slate ink, one calm blue.
+
+ Install: drop this file in custom/public/assets/css/
+ then in app.ini [ui]
+ THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-light,kbenestad-dark
+ DEFAULT_THEME = kbenestad-light
+ ----------------------------------------------------------------------------
+ Strategy: a COMPLETE, self-contained variable set (so the theme renders
+ correctly even when Forgejo's shipped base theme isn't present) followed by
+ the structural overrides that give kBenestad its identity — Schibsted Grotesk
+ type, accent-soft topic pills, flat buttons, hairline cards.
+
+ The forgejo-light import below is a harmless safety net: if the shipped file
+ exists it fills any future upstream variables; if it's missing it's ignored.
+ ========================================================================== */
+
+@import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
+@import "./theme-forgejo-light.css";
+
+:root {
+ color-scheme: light;
+ --is-dark-theme: false;
+
+ /* ── Type ──────────────────────────────────────────────────────────────────
+ Forgejo resolves body type as var(--fonts-override, var(--fonts-proportional)),
+ "Noto Sans", … — so these vars ARE the hook. Marked !important to win even
+ if the shipped base theme sets them !important first (per Forgejo's docs). */
+ --fonts-proportional: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
+ --fonts-regular: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
+ --fonts-monospace: "JetBrains Mono", ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace !important;
+
+ /* ── Corners (kBenestad: 6px workhorse, 8px cards, small + deliberate) ──── */
+ --border-radius: 6px;
+ --border-radius-medium: 8px;
+ --border-radius-full: 99999px;
+
+ /* ── Accent — Nordic blue #2f6fed and its ramp ──────────────────────────── */
+ --color-primary: #2f6fed;
+ --color-primary-contrast: #ffffff;
+ --color-primary-dark-1: #1f57cf;
+ --color-primary-dark-2: #1b46a3;
+ --color-primary-dark-3: #163b88;
+ --color-primary-dark-4: #142e63;
+ --color-primary-dark-5: #102450;
+ --color-primary-dark-6: #0c1c3e;
+ --color-primary-dark-7: #08142c;
+ --color-primary-light-1: #5685e9;
+ --color-primary-light-2: #88abf2;
+ --color-primary-light-3: #b7cdf8;
+ --color-primary-light-4: #d9e4fc;
+ --color-primary-light-5: #eef3fe;
+ --color-primary-light-6: #f5f8ff;
+ --color-primary-light-7: #fafcff;
+ --color-primary-alpha-10: rgba(47, 111, 237, 0.10);
+ --color-primary-alpha-20: rgba(47, 111, 237, 0.20);
+ --color-primary-alpha-30: rgba(47, 111, 237, 0.30);
+ --color-primary-alpha-40: rgba(47, 111, 237, 0.40);
+ --color-primary-alpha-50: rgba(47, 111, 237, 0.50);
+ --color-primary-alpha-60: rgba(47, 111, 237, 0.60);
+ --color-primary-alpha-70: rgba(47, 111, 237, 0.70);
+ --color-primary-alpha-80: rgba(47, 111, 237, 0.80);
+ --color-primary-alpha-90: rgba(47, 111, 237, 0.90);
+ --color-primary-hover: #1f57cf;
+ --color-primary-active: #1b46a3;
+
+ /* ── Secondary (neutral fills, borders, secondary buttons) ──────────────── */
+ --color-secondary: #e7eaef;
+ --color-secondary-dark-1: #d8dde4;
+ --color-secondary-dark-2: #c7cdd6;
+ --color-secondary-dark-3: #aab2bd;
+ --color-secondary-dark-4: #97a0ac;
+ --color-secondary-dark-5: #7b8694;
+ --color-secondary-dark-6: #6b7785;
+ --color-secondary-dark-7: #56606d;
+ --color-secondary-dark-8: #3a434f;
+ --color-secondary-dark-9: #2c343d;
+ --color-secondary-dark-10: #232a33;
+ --color-secondary-dark-11: #1b212a;
+ --color-secondary-dark-12: #14181e;
+ --color-secondary-dark-13: #0f141a;
+ --color-secondary-light-1: #eef0f4;
+ --color-secondary-light-2: #f1f3f6;
+ --color-secondary-light-3: #f5f6f9;
+ --color-secondary-light-4: #f8f9fb;
+ --color-secondary-alpha-10: rgba(123, 134, 148, 0.10);
+ --color-secondary-alpha-20: rgba(123, 134, 148, 0.20);
+ --color-secondary-alpha-30: rgba(123, 134, 148, 0.30);
+ --color-secondary-alpha-40: rgba(123, 134, 148, 0.40);
+ --color-secondary-alpha-50: rgba(123, 134, 148, 0.50);
+ --color-secondary-alpha-60: rgba(123, 134, 148, 0.60);
+ --color-secondary-alpha-70: rgba(123, 134, 148, 0.70);
+ --color-secondary-alpha-80: rgba(123, 134, 148, 0.80);
+ --color-secondary-alpha-90: rgba(123, 134, 148, 0.90);
+ --color-secondary-button: #d8dde4;
+ --color-secondary-hover: #f1f3f6;
+ --color-secondary-active: #e7eaef;
+
+ /* ── Text / ink ─────────────────────────────────────────────────────────── */
+ --color-text-dark: #0d1117;
+ --color-text: #14181e;
+ --color-text-light: #3a434f;
+ --color-text-light-1: #56606d;
+ --color-text-light-2: #7b8694;
+ --color-text-light-3: #aab2bd;
+ --color-placeholder-text: #aab2bd;
+ --color-text-focus: #ffffff;
+
+ /* ── Surfaces ──────────────────────────────────────────────────────────── */
+ --color-body: #f8f9fb; /* page ground (cool paper) */
+ --color-box-body: #ffffff; /* cards, file list, panels */
+ --color-box-body-highlight: #f5f8ff;
+ --color-box-header: #f8f9fb; /* segment / table headers */
+ --color-nav-bg: #ffffff; /* top navbar */
+ --color-nav-hover-bg: #f1f3f6;
+ --color-secondary-nav-bg: #f8f9fb;
+ --color-footer: #ffffff;
+ --color-light: rgba(123, 134, 148, 0.04);
+ --color-light-mimic-enabled: rgba(0, 0, 0, calc(6 / 255));
+ --color-light-border: #e7eaef;
+ --color-hover: #f1f3f6; /* row / item hover */
+ --color-active: #e7eaef; /* active row */
+ --color-menu: #ffffff;
+ --color-card: #ffffff;
+ --fancy-card-bg: #ffffff;
+ --fancy-card-border: #e7eaef;
+ --color-markup-tab-default: var(--color-box-header);
+ --color-markup-tab-active: var(--color-box-body);
+ --color-header-wrapper: #ffffff;
+ --color-header-wrapper-transparent: rgba(255, 255, 255, 0);
+
+ /* ── Inputs ────────────────────────────────────────────────────────────── */
+ --color-input-text: #14181e;
+ --color-input-background: #ffffff;
+ --color-input-toggle-background: #ffffff;
+ --color-input-border: #d8dde4;
+ --color-input-border-hover: #aab2bd;
+
+ /* ── Borders / dividers ────────────────────────────────────────────────── */
+ --color-border: #e7eaef;
+ --color-secondary-bg: #f1f3f6;
+
+ /* ── Code / markup ─────────────────────────────────────────────────────── */
+ --color-markup-code-block: #f1f3f6;
+ --color-markup-code-inline: #eef0f4;
+ --color-markup-table-row: rgba(0, 0, 0, 0.024);
+ --color-code-bg: #ffffff;
+ --color-code-sidebar-bg: #f8f9fb;
+
+ /* ── Shadows (soft, cool, low opacity) ─────────────────────────────────── */
+ --color-shadow: rgba(20, 24, 30, 0.06);
+ --color-secondary-shadow: rgba(20, 24, 30, 0.04);
+
+ /* ── Accent line / selection / timeline / caret ────────────────────────── */
+ --color-accent: #2f6fed;
+ --color-small-accent: #eef3fe;
+ --color-active-line: #eef3fe;
+ --color-editor-line-highlight: #f5f8ff;
+ --color-timeline: #e7eaef;
+ --color-caret: #14181e;
+ --color-highlight-fg: #1f57cf;
+ --color-highlight-bg: rgba(47, 111, 237, 0.10);
+ --color-selection-bg: #d9e4fc;
+ --color-selection-fg: #14181e;
+ --color-overlay-backdrop: rgba(20, 24, 30, 0.32);
+
+ /* ── Semantic hues (muted, never neon) ─────────────────────────────────── */
+ --color-red: #d64545;
+ --color-orange: #c9851f;
+ --color-yellow: #c9851f;
+ --color-olive: #8a8a2f;
+ --color-green: #1f9d5f;
+ --color-teal: #1f8a8a;
+ --color-blue: #2f6fed;
+ --color-violet: #6b5fd2;
+ --color-purple: #8a4fd2;
+ --color-pink: #cf4f8a;
+ --color-brown: #8a6b4f;
+ --color-grey: #7b8694;
+ --color-gold: #c9851f;
+ --color-white: #ffffff;
+ --color-black: #14181e;
+ --color-pure-black: #000000;
+
+ /* light variants */
+ --color-red-light: #e06464;
+ --color-green-light: #34b677;
+ --color-blue-light: #5685e9;
+ --color-grey-light: #aab2bd;
+
+ /* dark-1 variants */
+ --color-red-dark-1: #b8332f;
+ --color-green-dark-1: #178049;
+ --color-blue-dark-1: #1f57cf;
+
+ /* Status text / background / border */
+ --color-success-text: #178049;
+ --color-success-bg: #d7f0e1;
+ --color-success-border: #aee0c4;
+ --color-error-text: #b8332f;
+ --color-error-bg: #fadcdc;
+ --color-error-bg-active:#f3bcbc;
+ --color-error-bg-hover: #fbe6e6;
+ --color-error-border: #f3bcbc;
+ --color-warning-text: #a86c14;
+ --color-warning-bg: #fbeacb;
+ --color-warning-border: #f3d79a;
+ --color-info-text: #1b46a3;
+ --color-info-bg: #eef3fe;
+ --color-info-border: #b7cdf8;
+ --color-danger-bg: #fbeeee;
+
+ /* Diff */
+ --color-diff-removed-word-bg: #f3bcbc;
+ --color-diff-added-word-bg: #aee0c4;
+ --color-diff-removed-row-bg: #fbeeee;
+ --color-diff-added-row-bg: #e9f7ef;
+ --color-diff-removed-row-border: #f3d2d2;
+ --color-diff-added-row-border: #cfeadd;
+ --color-diff-moved-row-bg: #fbeacb;
+ --color-diff-moved-row-border:#f3d79a;
+ --color-diff-inactive: #f1f3f6;
+
+ /* Labels / reactions / tooltip / nav */
+ --color-label-text: #3a434f;
+ --color-label-bg: #e7eaef;
+ --color-label-hover-bg: #d8dde4;
+ --color-label-active-bg: #c7cdd6;
+ --color-reaction-bg: #f1f3f6;
+ --color-reaction-hover-bg: rgba(47, 111, 237, 0.20);
+ --color-reaction-active-bg: rgba(47, 111, 237, 0.30);
+ --color-tooltip-text: #ffffff;
+ --color-tooltip-bg: #232a33;
+ --color-button: #d8dde4;
+ --color-expand-button: #e7eaef;
+
+ /* badges */
+ --color-red-badge: #d64545;
+ --color-red-badge-bg: rgba(214, 69, 69, 0.13);
+ --color-red-badge-bg-hover: rgba(214, 69, 69, 0.27);
+ --color-green-badge: #1f9d5f;
+ --color-green-badge-bg: rgba(31, 157, 95, 0.13);
+ --color-green-badge-bg-hover: rgba(31, 157, 95, 0.27);
+ --color-yellow-badge: #c9851f;
+ --color-yellow-badge-bg: rgba(201, 133, 31, 0.13);
+ --color-yellow-badge-bg-hover: rgba(201, 133, 31, 0.27);
+ --color-orange-badge: #c9851f;
+ --color-orange-badge-bg: rgba(201, 133, 31, 0.13);
+ --color-orange-badge-bg-hover: rgba(201, 133, 31, 0.27);
+
+ /* status indicators */
+ --color-indicator-offline: #aab2bd;
+ --color-indicator-idle: #c9851f;
+ --color-indicator-active: #1f9d5f;
+
+ /* checkerboard (image diff) */
+ --checkerboard-color-1: #ffffff;
+ --checkerboard-color-2: #f1f3f6;
+
+ /* project board */
+ --color-project-board-bg: #f8f9fb;
+
+ /* console — calm dark surface */
+ --color-console-fg: #e6e9ef;
+ --color-console-fg-subtle: #8b95a1;
+ --color-console-bg: #161b22;
+ --color-console-border: #232a33;
+ --color-console-hover-bg: rgba(255, 255, 255, 0.06);
+ --color-console-active-bg: rgba(255, 255, 255, 0.10);
+ --color-console-menu-bg: #1c232c;
+ --color-console-menu-border:#2d3641;
+
+ accent-color: var(--color-accent);
+}
+
+/* ===========================================================================
+ STRUCTURAL OVERRIDES — kBenestad identity, mirroring the design mockup.
+ Scoped, conservative, modeled on real Forgejo / Semantic-UI selectors.
+ ========================================================================== */
+
+/* ── Type: enforce the kBenestad faces everywhere ───────────────────────────
+ Setting the variable alone isn't enough when a privacy blocker or CSP stops
+ the web font from loading on the var fallback. We force the family directly,
+ so the moment the font is available (web or self-hosted) it renders. */
+body,
+input, button, select, textarea, optgroup,
+.ui, .ui.menu, .ui.header, .ui.form, .ui.dropdown,
+.ui.dropdown .menu > .item, .ui.input > input,
+.markup, h1, h2, h3, h4, h5, h6,
+.repo-header, .repository .header-wrapper, .commit-summary, .file-info {
+ font-family: var(--fonts-proportional) !important;
+}
+code, pre, tt, kbd, samp, .mono,
+.ui.input.mono, .commit-id, .sha, .ui.label.commit-id,
+.code-view, .lines-code, .lines-num, .CodeMirror,
+.markup code, .markup pre, .markup tt {
+ font-family: var(--fonts-monospace) !important;
+}
+body {
+ letter-spacing: -0.005em;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ── Primary / positive buttons — flat accent, no gloss ─────────────────── */
+.ui.primary.button,
+.ui.primary.buttons .button,
+.ui.positive.button,
+.ui.positive.buttons .button {
+ background: var(--color-primary) !important;
+ color: #fff !important;
+ border-color: var(--color-primary) !important;
+ font-weight: 600;
+ box-shadow: none !important;
+}
+.ui.primary.button:hover,
+.ui.primary.buttons .button:hover,
+.ui.positive.button:hover,
+.ui.positive.buttons .button:hover {
+ background: var(--color-primary-hover) !important;
+ border-color: var(--color-primary-hover) !important;
+}
+
+/* ── Default / secondary buttons — calm, hairline, no shadow ────────────── */
+.ui.button {
+ font-weight: 500;
+ box-shadow: none;
+}
+.ui.basic.button,
+.ui.basic.buttons .button {
+ box-shadow: inset 0 0 0 1px var(--color-border) !important;
+}
+
+/* ── Repo tabs — accent active label + accent underline ─────────────────────
+ The repo tab bar is Forgejo's `.ui.secondary.pointing.menu`; the active item's
+ colour is set by the full active/hover/focus/dropdown selector group, so we
+ match it verbatim to actually win the cascade. */
+.ui.secondary.pointing.menu .active.item,
+.ui.secondary.pointing.menu .active.item:hover,
+.ui.secondary.pointing.menu .active.item:focus,
+.ui.secondary.pointing.menu .dropdown.item:hover,
+.ui.secondary.pointing.menu .dropdown.item:focus,
+.ui.tabular.menu .active.item {
+ color: var(--color-accent) !important;
+ border-color: var(--color-primary) !important;
+ font-weight: 600;
+}
+.ui.secondary.pointing.menu .item:hover {
+ color: var(--color-text) !important;
+}
+/* Tab count pills (12 · 3 · 4) render as plain `.ui.label` inside each item →
+ accent-tinted instead of flat grey. */
+.ui.secondary.pointing.menu .item .ui.label,
+.ui.tabular.menu .item .ui.label {
+ background: var(--color-primary-alpha-10) !important;
+ color: var(--color-accent) !important;
+ border: 1px solid var(--color-primary-alpha-20) !important;
+}
+.ui.secondary.pointing.menu .active.item .ui.label,
+.ui.tabular.menu .active.item .ui.label {
+ background: var(--color-primary) !important;
+ color: #fff !important;
+ border-color: var(--color-primary) !important;
+}
+
+/* ── Repo header title — owner / name carry the accent (branded chrome) ── */
+.repo-header .flex-text-block a,
+.repo-header .repo-title a,
+.repository .header-wrapper .repo-title a {
+ color: var(--color-accent) !important;
+}
+.repo-header .flex-text-block a:hover { text-decoration: underline; }
+/* Watch / Star / Fork stay button-coloured — never accent text */
+.repo-header .repo-buttons a,
+.repo-header .repo-buttons .ui.button {
+ color: var(--color-text) !important;
+}
+
+/* ── Repo topics → real accent pills (clearly blue, never grey) ─────────────
+ Forgejo's topic markup has shifted across versions (#repo-topics > a,
+ a.repo-topic, a.topic.label …) — match them all so the chips are always
+ branded, with a visible blue fill + ring instead of the pale wash. */
+#repo-topics a,
+#repo-topics .ui.label,
+a.repo-topic,
+a.repo-topic.ui.label,
+.repository-topics a.ui.label,
+.topic.ui.label,
+a.topic.label {
+ background: var(--color-primary-alpha-10) !important;
+ color: var(--color-accent) !important;
+ border: 1px solid var(--color-primary-alpha-30) !important;
+ border-radius: var(--border-radius-full) !important;
+ font-weight: 600 !important;
+}
+#repo-topics a:hover,
+a.repo-topic:hover,
+.topic.ui.label:hover {
+ background: var(--color-primary) !important;
+ color: #fff !important;
+ border-color: var(--color-primary) !important;
+}
+
+/* ── Extra interactive coverage — the bits that quietly stay grey ──────────
+ Dropdowns, pagination, form focus, toggles and progress bars across the rest
+ of the app, pulled onto the accent so the brand reads everywhere, not just on
+ the repo home. Trim any rule you don't want. */
+
+/* Dropdown / select menus — selected + active item in accent */
+.ui.dropdown .menu > .item.selected,
+.ui.dropdown .menu > .item.active,
+.ui.selection.dropdown .menu > .item.active.selected {
+ color: var(--color-accent) !important;
+ background: var(--color-primary-alpha-10) !important;
+}
+
+/* Pagination — current page in solid accent */
+.ui.pagination.menu .active.item {
+ background: var(--color-primary) !important;
+ color: #fff !important;
+}
+
+/* Form fields — accent focus ring instead of the default grey/teal */
+.ui.input input:focus,
+.ui.form input:focus,
+.ui.form textarea:focus,
+.ui.selection.active.dropdown,
+.ui.selection.dropdown:focus {
+ border-color: var(--color-primary) !important;
+ box-shadow: 0 0 0 2px var(--color-primary-alpha-20) !important;
+}
+
+/* Checkboxes, radios, toggles — checked state in accent */
+.ui.checkbox input:checked ~ .box::before,
+.ui.checkbox input:checked ~ label::before,
+.ui.radio.checkbox input:checked ~ .box::before,
+.ui.radio.checkbox input:checked ~ label::before,
+.ui.toggle.checkbox input:checked ~ .box::before,
+.ui.toggle.checkbox input:checked ~ label::before {
+ background: var(--color-primary) !important;
+ border-color: var(--color-primary) !important;
+}
+
+/* Progress bars (upload, migration, theme-driven stats) */
+.ui.progress .bar {
+ background: var(--color-primary) !important;
+}
+
+/* ── Cards / segments — 8px corners, hairline border, faint cool shadow ─── */
+.ui.segment,
+.ui.segments,
+.ui.attached.segment,
+.repository-summary,
+.repo-description-box,
+.ui.card,
+.ui.cards > .card {
+ box-shadow: 0 1px 0 var(--color-shadow);
+}
+.ui.segments,
+.ui.card,
+.ui.cards > .card {
+ border-radius: var(--border-radius-medium);
+}
+
+/* ── Links — accent, underline only on hover (matches mockup chrome) ────── */
+a { text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Octicons stay legible on light chrome ──────────────────────────────── */
+.svg, .gt-octicon, .octicon { fill: currentColor; }
diff --git a/dev/ui_kits/gitxt/index.html b/dev/ui_kits/gitxt/index.html
new file mode 100644
index 0000000..6ec1843
--- /dev/null
+++ b/dev/ui_kits/gitxt/index.html
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+gitxt
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/ui_kits/gitxt/pages.jsx b/dev/ui_kits/gitxt/pages.jsx
new file mode 100644
index 0000000..78755d9
--- /dev/null
+++ b/dev/ui_kits/gitxt/pages.jsx
@@ -0,0 +1,128 @@
+// gitxt — teletext for git. Pages addressed by 3-digit numbers; navigate by
+// typing digits or clicking the coloured FASTEXT bar. Authentic teletext look.
+const { useState, useEffect, useRef } = React;
+
+// colour helpers (teletext palette)
+const C = ({ children }) => {children} ; // cyan
+const Y = ({ children }) => {children} ; // yellow
+const G = ({ children }) => {children} ; // green
+const R = ({ children }) => {children} ; // red
+const M = ({ children }) => {children} ; // magenta
+const W = ({ children }) => {children} ; // white
+
+const Row = ({ children, center }) => (
+ {children || '\u00A0'}
+);
+// double-height title block
+const Title = ({ color = '#3fe0e0', children }) => (
+ {children}
+);
+const Link = ({ n, go, children }) => (
+ go(n)}>{n} {children}
+);
+
+const PAGES = {
+ 100: { fast: [200, 300, 400, 100], render: (go) => (<>
+ gitxt
+ teletext for git · kBenestad
+
+ ━━━━━━━━━━━━━━ index ━━━━━━━━━━━━━━
+
+ repositories
+ recent commits
+ open issues
+ build status
+ help & navigation
+
+ type a page number, or use the
+ coloured buttons below.
+ >) },
+
+ 200: { fast: [210, 220, 300, 100], render: (go) => (<>
+ repositories
+ page 200 · 8 repos tracked
+
+ kbpkg v2.4.0
+ gitxt v0.3.0
+ mdcms v0.6.1
+ capcms v0.2.0
+ invoice v1.1.0
+ timesheet v1.0.0
+
+ select a repo for detail.
+ >) },
+
+ 210: { fast: [300, 400, 200, 100], render: (go) => (<>
+ kbpkg
+ repo 210 · v2.4.0 · main
+
+ git-based package manager
+
+ branch main ↑0 ↓0
+ commits 1,284
+ open 3 issues
+ build ● passing
+
+ last: fix: resolve nested deps
+ by karl · 1 day ago
+ >) },
+
+ 300: { fast: [310, 200, 400, 100], render: (go) => (<>
+ recent commits
+ page 300 · all repos
+
+ a3f1 fix: resolve nested deps
+ kbpkg · 1d
+ 9c02 feat: number navigation
+ gitxt · 2d
+ 1e7d chore: bump cms/md
+ mdcms · 4d
+ b840 fix: locale rounding
+ invoice · 5d
+ >) },
+
+ 400: { fast: [200, 300, 500, 100], render: (go) => (<>
+ open issues
+ page 400 · 6 open
+
+ #42 resolve circular dep graph
+ kbpkg · high
+ #38 page 9xx reserved range
+ gitxt · low
+ #31 fr-NO plural forms
+ mdcms · medium
+ >) },
+
+ 500: { fast: [200, 300, 400, 100], render: (go) => (<>
+ build status
+ page 500 · last 24h
+
+ ● passing kbpkg
+ ● passing gitxt
+ ● passing invoice
+ ● pending mdcms
+ ● failing capcms
+
+ capcms: test timeout in
+ case-export suite.
+ >) },
+
+ 888: { fast: [100, 200, 300, 100], render: (go) => (<>
+ help
+ page 888
+
+ type any 3-digit page number
+ to jump straight to it.
+
+ 100 index
+ 200 repositories
+ 300 commits
+
+ coloured buttons jump to the
+ four pages shown at the foot.
+ >) },
+};
+// alias detail pages
+[220, 230, 240, 250, 260, 310].forEach(n => { if (!PAGES[n]) PAGES[n] = PAGES[210]; });
+
+Object.assign(window, { PAGES, C, Y, G, R, M, W, Row, Title });
diff --git a/dev/ui_kits/invoice/app.jsx b/dev/ui_kits/invoice/app.jsx
new file mode 100644
index 0000000..ee8c3e2
--- /dev/null
+++ b/dev/ui_kits/invoice/app.jsx
@@ -0,0 +1,152 @@
+// invoice app — sidebar shell, invoice list, invoice detail
+const { Icon, Button, StatusBadge, Avatar, kr, useState } = window;
+
+function Sidebar() {
+ const nav = [
+ { icon: 'file', label: 'Invoices', active: true },
+ { icon: 'users', label: 'Clients' },
+ { icon: 'chart', label: 'Reports' },
+ { icon: 'settings', label: 'Settings' },
+ ];
+ const apps = [['inv', 'invoice', true], ['ts', 'timesheet', false], ['re', 'reimburse', false]];
+ return (
+
+
+
+
+
+
+ k Benestad
+
+ invoice
+
+ {nav.map(n => (
+
+ {n.label}
+
+ ))}
+
+
+
kBenestad apps
+
+ {apps.map(([k, label, on]) => (
+
{k}
+ ))}
+
+
+
+ );
+}
+
+function InvoiceList({ onOpen }) {
+ const { invoices } = window.INVOICE_DATA;
+ const [q, setQ] = useState('');
+ const list = invoices.filter(i => (i.id + i.client).toLowerCase().includes(q.toLowerCase()));
+ const outstanding = invoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((s, i) => s + i.amount, 0);
+ const paid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + i.amount, 0);
+ return (
+
+
+
Invoices
+ }>New invoice
+
+
+
Outstanding kr {kr(outstanding)}
+
Paid this period kr {kr(paid)}
+
Open invoices {invoices.filter(i => i.status !== 'paid' && i.status !== 'draft').length}
+
+
+
+ setQ(e.target.value)} placeholder="Search invoices or clients…" />
+
+
+
+
+ Invoice Client Status Due Amount
+
+
+ {list.map(inv => (
+ onOpen(inv)}>
+ {inv.id}
+ {inv.client}
+
+ {inv.due}
+ kr {kr(inv.amount)}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function InvoiceDetail({ inv, onBack }) {
+ const { from } = window.INVOICE_DATA;
+ const subtotal = inv.items.reduce((s, [, qty, rate]) => s + qty * rate, 0);
+ const vat = Math.round(subtotal * 0.25);
+ const total = subtotal + vat;
+ return (
+
+
+
Invoices
+
+ {inv.status !== 'paid' && }>Mark paid}
+ }>PDF
+ {inv.status === 'draft'
+ ? }>Send
+ : }>Resend}
+
+
+
+
+
+
+ {from.org}
+ {from.name}
+ {from.email}
+ {from.orgnr}
+
+
+
+
+ Description Qty Rate Amount
+
+ {inv.items.map(([desc, qty, rate], i) => (
+ {desc} {qty} kr {kr(rate)} kr {kr(qty * rate)}
+ ))}
+
+
+
+
Subtotal kr {kr(subtotal)}
+
VAT 25% kr {kr(vat)}
+
Total kr {kr(total)}
+
+
+
+ );
+}
+
+function App() {
+ const [inv, setInv] = useState(null);
+ return (
+
+
+
+ {inv ? setInv(null)} /> : }
+
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render( );
diff --git a/dev/ui_kits/invoice/data.js b/dev/ui_kits/invoice/data.js
new file mode 100644
index 0000000..bc5bccd
--- /dev/null
+++ b/dev/ui_kits/invoice/data.js
@@ -0,0 +1,16 @@
+// invoice — mock data for the internal-tools app family
+window.INVOICE_DATA = {
+ invoices: [
+ { id: 'INV-0042', client: 'Nordlys Media AS', amount: 18400, currency: 'NOK', status: 'paid', issued: '12 May', due: '26 May',
+ items: [['Design system audit', 1, 14000], ['Component build', 1, 4400]] },
+ { id: 'INV-0041', client: 'Bergen Legal Aid', amount: 9600, currency: 'NOK', status: 'sent', issued: '02 Jun', due: '16 Jun',
+ items: [['capcms monthly retainer', 1, 9600]] },
+ { id: 'INV-0040', client: 'Fjord Software', amount: 26250, currency: 'NOK', status: 'overdue', issued: '18 Apr', due: '02 May',
+ items: [['kbpkg integration', 35, 750]] },
+ { id: 'INV-0039', client: 'Oslo Kommune', amount: 12000, currency: 'NOK', status: 'draft', issued: '—', due: '—',
+ items: [['mdcms migration', 1, 12000]] },
+ { id: 'INV-0038', client: 'Nordlys Media AS', amount: 7200, currency: 'NOK', status: 'paid', issued: '28 Apr', due: '12 May',
+ items: [['Maintenance', 12, 600]] },
+ ],
+ from: { name: 'Karl Benestad', org: 'kBenestad', email: 'karl@kbenestad.no', orgnr: 'NO 998 877 665' },
+};
diff --git a/dev/ui_kits/invoice/index.html b/dev/ui_kits/invoice/index.html
new file mode 100644
index 0000000..1817548
--- /dev/null
+++ b/dev/ui_kits/invoice/index.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+invoice — kBenestad
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/ui_kits/invoice/ui.jsx b/dev/ui_kits/invoice/ui.jsx
new file mode 100644
index 0000000..83ad05e
--- /dev/null
+++ b/dev/ui_kits/invoice/ui.jsx
@@ -0,0 +1,41 @@
+// invoice UI primitives + icons (mirrors design-system components via .kb-* classes)
+const { useState } = React;
+
+const ICONS = {
+ file: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6ZM14 2v6h6M8 13h8M8 17h8M8 9h2',
+ users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8',
+ chart: 'M3 3v18h18M7 16v-5M12 16V8M17 16v-9',
+ settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2V21a2 2 0 1 1-4 0v-.1A1.7 1.7 0 0 0 6 19.4l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H2a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 6l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9.4A1.7 1.7 0 0 0 11 2.1V2a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9.4a1.7 1.7 0 0 0 2.1 1.6H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z',
+ plus: 'M12 5v14M5 12h14',
+ download: 'M12 3v12m0 0 4-4m-4 4-4-4M5 21h14',
+ send: 'M22 2 11 13M22 2l-7 20-4-9-9-4 20-7Z',
+ check: 'M20 6 9 17l-5-5',
+ search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z',
+ chevron: 'm9 6 6 6-6 6',
+ back: 'm15 18-6-6 6-6',
+ clock: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
+};
+
+function Icon({ name, size = 16, strokeWidth = 2, style }) {
+ return ;
+}
+
+function Button({ variant = 'primary', size, leftIcon, className = '', children, ...rest }) {
+ const cls = ['kb-btn', `kb-btn--${variant}`, size && `kb-btn--${size}`, className].filter(Boolean).join(' ');
+ return {leftIcon}{children} ;
+}
+
+const STATUS_TONE = { paid: 'success', sent: 'accent', draft: 'neutral', overdue: 'danger' };
+function StatusBadge({ status }) {
+ return {status} ;
+}
+
+function Avatar({ name = '', size = 32, square }) {
+ const initials = name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
+ return {initials} ;
+}
+
+const kr = (n) => n.toLocaleString('nb-NO');
+
+Object.assign(window, { Icon, Button, StatusBadge, Avatar, kr, useState });
diff --git a/dev/ui_kits/kbpkg/data.js b/dev/ui_kits/kbpkg/data.js
new file mode 100644
index 0000000..bbd8111
--- /dev/null
+++ b/dev/ui_kits/kbpkg/data.js
@@ -0,0 +1,57 @@
+// kbpkg — mock registry data
+window.KBPKG_PACKAGES = [
+ {
+ id: 'utils/fmt', name: 'utils/fmt', version: '1.3.0', updated: '3 days ago',
+ desc: 'Deterministic code formatter for kBenestad repos. Zero config, fast, opinionated.',
+ tags: ['cli', 'formatting', 'typescript'], installs: '1.2k', license: 'MIT',
+ repo: 'git@kb:utils/fmt', size: '12 kB', deps: 0, owner: 'Karl Benestad',
+ readme: 'A deterministic formatter. Point it at a directory and it rewrites every file to the canonical style — no options to argue about.',
+ versions: [['1.3.0','3 days ago'],['1.2.1','3 weeks ago'],['1.2.0','2 months ago'],['1.1.0','5 months ago']],
+ dependencies: [],
+ },
+ {
+ id: 'core/log', name: 'core/log', version: '2.4.0', updated: '1 day ago',
+ desc: 'Structured, leveled logging with pretty terminal output and JSON for production.',
+ tags: ['logging', 'observability'], installs: '3.8k', license: 'MIT',
+ repo: 'git@kb:core/log', size: '24 kB', deps: 1, owner: 'Karl Benestad',
+ readme: 'Leveled logging that looks good in a terminal and parses cleanly in production. Tiny surface area.',
+ versions: [['2.4.0','1 day ago'],['2.3.0','1 month ago'],['2.0.0','4 months ago']],
+ dependencies: [['ansi/color','^1.0.0']],
+ },
+ {
+ id: 'git/sync', name: 'git/sync', version: '0.9.2', updated: '6 days ago',
+ desc: 'Bidirectional sync between kbpkg registry mirrors over plain git remotes.',
+ tags: ['git', 'registry', 'sync'], installs: '640', license: 'Apache-2.0',
+ repo: 'git@kb:git/sync', size: '58 kB', deps: 2, owner: 'kBenestad',
+ readme: 'Keeps two registry mirrors in lock-step using nothing but git. Resolves conflicts by version precedence.',
+ versions: [['0.9.2','6 days ago'],['0.9.0','3 weeks ago'],['0.8.0','2 months ago']],
+ dependencies: [['core/log','^2.0.0'],['utils/fmt','^1.0.0']],
+ },
+ {
+ id: 'ansi/color', name: 'ansi/color', version: '1.0.0', updated: '2 months ago',
+ desc: 'Minimal ANSI color + style helpers. No dependencies, tree-shakeable.',
+ tags: ['cli', 'terminal'], installs: '5.1k', license: 'MIT',
+ repo: 'git@kb:ansi/color', size: '4 kB', deps: 0, owner: 'Karl Benestad',
+ readme: 'Just enough ANSI to make terminal output readable. Four functions, no theme system.',
+ versions: [['1.0.0','2 months ago'],['0.4.0','6 months ago']],
+ dependencies: [],
+ },
+ {
+ id: 'cms/md', name: 'cms/md', version: '0.6.1', updated: '2 weeks ago',
+ desc: 'Markdown + front-matter parser powering mdcms. Multilingual-aware.',
+ tags: ['markdown', 'cms', 'i18n'], installs: '410', license: 'MIT',
+ repo: 'git@kb:cms/md', size: '31 kB', deps: 1, owner: 'kBenestad',
+ readme: 'Parses Markdown with YAML front-matter and a light multilingual convention. Built for mdcms.',
+ versions: [['0.6.1','2 weeks ago'],['0.6.0','1 month ago']],
+ dependencies: [['utils/fmt','^1.2.0']],
+ },
+ {
+ id: 'pdf/invoice', name: 'pdf/invoice', version: '1.1.0', updated: '5 weeks ago',
+ desc: 'Render invoices to print-ready PDF. Used by the invoice and reimburse apps.',
+ tags: ['pdf', 'invoicing'], installs: '290', license: 'MIT',
+ repo: 'git@kb:pdf/invoice', size: '88 kB', deps: 1, owner: 'Karl Benestad',
+ readme: 'Takes a small JSON document and renders a clean, print-ready PDF invoice. Currency- and locale-aware.',
+ versions: [['1.1.0','5 weeks ago'],['1.0.0','3 months ago']],
+ dependencies: [['core/log','^2.0.0']],
+ },
+];
diff --git a/dev/ui_kits/kbpkg/index.html b/dev/ui_kits/kbpkg/index.html
new file mode 100644
index 0000000..91bddfc
--- /dev/null
+++ b/dev/ui_kits/kbpkg/index.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+kbpkg — registry
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/ui_kits/kbpkg/screens.jsx b/dev/ui_kits/kbpkg/screens.jsx
new file mode 100644
index 0000000..f46405c
--- /dev/null
+++ b/dev/ui_kits/kbpkg/screens.jsx
@@ -0,0 +1,179 @@
+// kbpkg screens — Header, install command, registry list, package detail.
+const { Icon, Button, Badge, Tag, Avatar, Lockup, useState } = window;
+
+function Header({ view, onHome }) {
+ const nav = ['Packages', 'Docs', 'Changelog'];
+ return (
+
+
+
+ {nav.map((n, i) => (
+ {n}
+ ))}
+
+
+
+ );
+}
+
+function CopyCommand({ id }) {
+ const [copied, setCopied] = useState(false);
+ const cmd = `kbpkg add ${id}`;
+ const copy = () => { setCopied(true); setTimeout(() => setCopied(false), 1400); };
+ return (
+
+ $
+ {cmd}
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+ );
+}
+
+function PackageRow({ pkg, onOpen }) {
+ return (
+ onOpen(pkg)}>
+
+
+
+
+
+ {pkg.name}
+ v{pkg.version}
+
+
{pkg.desc}
+
{pkg.tags.map(t => {t} )}
+
+
+ {pkg.installs}
+ {pkg.updated}
+
+
+ );
+}
+
+function Registry({ onOpen }) {
+ const all = window.KBPKG_PACKAGES;
+ const [q, setQ] = useState('');
+ const [filter, setFilter] = useState(null);
+ const tags = [...new Set(all.flatMap(p => p.tags))].slice(0, 8);
+ const list = all.filter(p => {
+ const matchQ = !q || (p.name + ' ' + p.desc).toLowerCase().includes(q.toLowerCase());
+ const matchT = !filter || p.tags.includes(filter);
+ return matchQ && matchT;
+ });
+ return (
+
+
+
kbpkg registry
+
A package manager for me.
+
Git-based packages for the kBenestad apps. Install anything with one command.
+
+
+ setQ(e.target.value)} placeholder="Search packages…" />
+
+
+ setFilter(null)} active={!filter}>all
+ {tags.map(t => setFilter(t === filter ? null : t)} active={t === filter}>{t} )}
+
+
+
+ {list.length} package{list.length === 1 ? '' : 's'}
+ sorted by recently updated
+
+
+ {list.map(p =>
)}
+ {list.length === 0 &&
No packages match “{q}”.
}
+
+
+ );
+}
+
+function MetaRow({ icon, label, children }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function PackagePage({ pkg, onHome, onOpen }) {
+ const [tab, setTab] = useState('readme');
+ const tabs = [
+ { id: 'readme', label: 'Readme' },
+ { id: 'versions', label: `Versions` },
+ { id: 'deps', label: 'Dependencies' },
+ ];
+ return (
+
+
+
+
+
{pkg.name} v{pkg.version}
+
{pkg.desc}
+
+
published
+
+
+
+
+
+ {tabs.map(t => setTab(t.id)}>{t.label} )}
+
+
+ {tab === 'readme' && (
+
+
{pkg.name}
+
{pkg.readme}
+
Install with kbpkg add {pkg.id} and import what you need. Licensed under {pkg.license}.
+
+ )}
+ {tab === 'versions' && (
+
+ {pkg.versions.map(([v, when], i) => (
+
+ v{v}{i === 0 && latest }
+ {when}
+
+ ))}
+
+ )}
+ {tab === 'deps' && (
+
+ {pkg.dependencies.length === 0 &&
No dependencies — this package is self-contained.
}
+ {pkg.dependencies.map(([d, range]) => {
+ const dp = window.KBPKG_PACKAGES.find(p => p.id === d);
+ return (
+
dp && onOpen(dp)}>
+ {d}
+ {range}
+
+ );
+ })}
+
+ )}
+
+
+
+ } style={{ width: '100%' }}>Install
+
+
v{pkg.version}
+
{pkg.license}
+
{pkg.size}
+
{pkg.deps}
+
{pkg.repo}
+
{pkg.owner}
+
+
+
+
+ );
+}
+
+Object.assign(window, { Header, Registry, PackagePage });
diff --git a/dev/ui_kits/kbpkg/ui.jsx b/dev/ui_kits/kbpkg/ui.jsx
new file mode 100644
index 0000000..a2434cd
--- /dev/null
+++ b/dev/ui_kits/kbpkg/ui.jsx
@@ -0,0 +1,69 @@
+// kbpkg UI primitives — mirror the kBenestad design-system components using the
+// real .kb-* classes from components.css (linked via styles.css). Self-contained
+// so the kit renders anywhere, not only inside the Design System tab.
+const { useState } = React;
+
+const ICONS = {
+ search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z',
+ download: 'M12 3v12m0 0 4-4m-4 4-4-4M5 21h14',
+ copy: 'M9 9h10v10H9zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
+ check: 'M20 6 9 17l-5-5',
+ box: 'M21 8v8a2 2 0 0 1-1 1.7l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 16V8a2 2 0 0 1 1-1.7l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8ZM3.3 7 12 12l8.7-5M12 22V12',
+ branch: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a9 9 0 0 1-9 9',
+ clock: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
+ terminal: 'm4 17 6-6-6-6M12 19h8',
+ chevronR: 'm9 6 6 6-6 6',
+ external: 'M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6',
+ scale: 'M12 3v18M3 7h18M7 7l-3 7a3 3 0 0 0 6 0L7 7Zm10 0-3 7a3 3 0 0 0 6 0l-3-7ZM5 21h14',
+ drive: 'M22 12H2M5.5 6h13l3 6v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-6l3.5-6ZM6 16h.01M10 16h.01',
+ layers: 'm12 2 9 5-9 5-9-5 9-5ZM3 12l9 5 9-5M3 17l9 5 9-5',
+ user: 'M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z',
+ book: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5v15Z',
+ plus: 'M12 5v14M5 12h14',
+};
+
+function Icon({ name, size = 16, strokeWidth = 2, style }) {
+ return (
+
+
+
+ );
+}
+
+function Button({ variant = 'primary', size, iconOnly, leftIcon, className = '', children, ...rest }) {
+ const cls = ['kb-btn', `kb-btn--${variant}`, size && `kb-btn--${size}`, iconOnly && 'kb-btn--icon', className].filter(Boolean).join(' ');
+ return {leftIcon}{children} ;
+}
+
+function Badge({ tone = 'neutral', dot, children }) {
+ return {dot && }{children} ;
+}
+
+function Tag({ children, onClick, active }) {
+ return {children} ;
+}
+
+function Avatar({ name = '', size = 32, square }) {
+ const initials = name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
+ return {initials} ;
+}
+
+// Brand lockup (stack mark + wordmark)
+function Lockup({ onClick }) {
+ return (
+
+
+
+
+
+
+ k Benestad
+
+
+ );
+}
+
+Object.assign(window, { Icon, Button, Badge, Tag, Avatar, Lockup, useState });