Compare commits

..

2 commits

Author SHA1 Message Date
be4b4c463e Added design assets
Some checks are pending
/ mirror (push) Waiting to run
2026-06-08 09:59:37 +07:00
696a19b142
Add design brief for kBenestad web apps
This document serves as a design brief for kBenestad web apps, outlining visual guidelines, typography, color tokens, and UI components.
2026-06-08 08:58:36 +07:00
32 changed files with 5124 additions and 0 deletions

368
DESIGN.md Normal file
View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="reimburse">
<rect width="48" height="48" rx="12" fill="#2f6fed"></rect>
<rect x="7.5" y="14" width="33" height="20" rx="3.2" fill="none" stroke="#fff" stroke-width="2.8"></rect>
<circle cx="24" cy="24" r="4.6" fill="none" stroke="#fff" stroke-width="2.6"></circle>
<path d="M12.7 21.4 V26.6 M35.3 21.4 V26.6" stroke="#fff" stroke-width="2.6" stroke-linecap="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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"
}
]
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" fill="currentColor" role="img" aria-label="reimburse">
<rect x="7.5" y="14" width="33" height="20" rx="3.2" fill="none" stroke="currentColor" stroke-width="2.8"></rect>
<circle cx="24" cy="24" r="4.6" fill="none" stroke="currentColor" stroke-width="2.6"></circle>
<path d="M12.7 21.4 V26.6 M35.3 21.4 V26.6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 475 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="reimburse">
<rect width="48" height="48" rx="12" fill="#2f6fed"></rect>
<rect x="7.5" y="14" width="33" height="20" rx="3.2" fill="none" stroke="#fff" stroke-width="2.8"></rect>
<circle cx="24" cy="24" r="4.6" fill="none" stroke="#fff" stroke-width="2.6"></circle>
<path d="M12.7 21.4 V26.6 M35.3 21.4 V26.6" stroke="#fff" stroke-width="2.6" stroke-linecap="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 493 B

168
dev/mockups/invoice.html Normal file
View file

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no">
<title>Invoice — kBenestad reskin</title>
<link rel="stylesheet" href="kbenestad-forms.css">
<script>
// allow the review page to force a theme: invoice.html?theme=dark
(function(){var p=new URLSearchParams(location.search).get('theme');
if(p)document.documentElement.setAttribute('data-theme',p);})();
</script>
<style>
/* invoice-specific composition (structural only; all colour/type from shared sheet) */
.inv-lines-head { grid-template-columns: 1fr 70px 70px 110px 120px 34px; }
.inv-line { grid-template-columns: 1fr 70px 70px 110px 120px 34px; }
.inv-tax-head { grid-template-columns: 1fr 90px 130px 34px; }
.inv-tax { grid-template-columns: 1fr 90px 130px 34px; }
@media (max-width:680px){
.inv-lines-head{display:none;}
.inv-line,.inv-tax{grid-template-columns:1fr 1fr;gap:6px 10px;background:var(--surface-2);padding:12px;border-radius:var(--radius-sm);}
}
</style>
</head>
<body class="kb">
<div class="kb-wrap">
<!-- utility toolbar -->
<div class="kb-toolbar">
<div class="kb-seg" role="group" aria-label="Language">
<button class="is-active">EN</button><button>DE</button><button>FR</button><button>NO</button>
</div>
<div class="spacer"></div>
<div class="kb-seg" role="group" aria-label="Text size">
<button aria-label="Smaller">A</button><button class="is-active">A</button><button aria-label="Larger">A+</button>
</div>
<button class="kb-iconbtn" aria-label="About">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 11.2H7.1v1.5h1.8v-1.5zm0-8.4H7.1v6.2h1.8V2.8z"/></svg>
</button>
</div>
<!-- header: issuer (customer of kBenestad) leads -->
<header class="kb-header">
<div class="kb-brand">
<span class="logo">NM</span>
<span class="org">Nordmann Consulting<small>Org. 998 877 665 · Oslo, Norway</small></span>
</div>
<div class="kb-doctitle">
<h1>Invoice</h1>
<div class="meta">No. INV-2026-0042 · 6 June 2026</div>
</div>
</header>
<!-- bill-to + details -->
<div class="kb-grid cols-2" style="margin-bottom:16px;">
<section class="kb-card" style="margin:0;">
<h2 class="kb-card__title">Bill to</h2>
<div class="kb-grid">
<div class="kb-field">
<span class="kb-label">Recipient</span>
<select class="kb-select"><option>Acme Corporation</option><option>Example NGO</option><option>Other…</option></select>
</div>
<div class="kb-field">
<span class="kb-label">Address</span>
<input class="kb-input" value="Acme Corporation Ltd., 123 Business Avenue, Suite 400" readonly>
</div>
<div class="kb-grid cols-2" style="gap:14px 16px;">
<div class="kb-field"><span class="kb-label">VAT ID</span><input class="kb-input" value="US-EIN-12-3456789" readonly></div>
<div class="kb-field"><span class="kb-label">Project code</span>
<select class="kb-select"><option>AC-100</option><option>AC-110</option><option>AC-200</option></select>
</div>
</div>
</div>
</section>
<section class="kb-card" style="margin:0;">
<h2 class="kb-card__title">Details</h2>
<div class="kb-grid cols-2" style="gap:14px 16px;">
<div class="kb-field"><span class="kb-label">Issue date</span><input class="kb-input" value="6 June 2026"></div>
<div class="kb-field"><span class="kb-label">Due date</span><input class="kb-input" value="6 July 2026"></div>
<div class="kb-field"><span class="kb-label">Currency</span>
<select class="kb-select"><option>USD — US dollar</option><option>EUR — Euro</option><option>NOK — Norwegian krone</option></select>
</div>
<div class="kb-field"><span class="kb-label">Terms</span><input class="kb-input" value="Net 30"></div>
</div>
</section>
</div>
<!-- line items -->
<section class="kb-card">
<h2 class="kb-card__title">Line items <span class="count">2 lines</span></h2>
<div class="kb-rowhead kb-row inv-lines-head">
<span>Description</span><span class="r">Qty</span><span>Unit</span><span class="r">Unit price</span><span class="r">Amount</span><span></span>
</div>
<div class="kb-row inv-line">
<input class="kb-input" value="Consulting Services">
<input class="kb-input num" value="40">
<select class="kb-select"><option>Hour</option><option>Day</option></select>
<input class="kb-input num" value="100.00">
<span class="r kb-mono">4,000.00</span>
<button class="kb-circbtn kb-circbtn--rm" aria-label="Remove"></button>
</div>
<div class="kb-row inv-line">
<input class="kb-input" value="On-site Training Workshop">
<input class="kb-input num" value="2">
<select class="kb-select"><option>Day</option><option>Hour</option></select>
<input class="kb-input num" value="800.00">
<span class="r kb-mono">1,600.00</span>
<button class="kb-circbtn kb-circbtn--rm" aria-label="Remove"></button>
</div>
<div style="margin-top:12px;">
<button class="kb-btn kb-btn--dashed">+ Add line</button>
</div>
</section>
<!-- tax + totals -->
<div class="kb-grid cols-2">
<section class="kb-card" style="margin:0;">
<h2 class="kb-card__title">Tax</h2>
<div class="kb-rowhead kb-row inv-tax-head">
<span>Type</span><span class="r">Rate %</span><span class="r">Amount</span><span></span>
</div>
<div class="kb-row inv-tax">
<select class="kb-select"><option>VAT</option><option>GST</option><option>Sales Tax</option></select>
<input class="kb-input num" value="25">
<span class="r kb-mono">1,400.00</span>
<button class="kb-circbtn kb-circbtn--rm" aria-label="Remove"></button>
</div>
<div style="margin-top:12px;"><button class="kb-btn kb-btn--dashed">+ Add tax line</button></div>
</section>
<section class="kb-card kb-card--flex" style="margin:0;">
<h2 class="kb-card__title">Summary</h2>
<div class="kb-totals kb-totals--fill">
<div class="row"><span class="lab">Subtotal</span><span class="val">5,600.00</span></div>
<div class="row"><span class="lab">VAT 25%</span><span class="val">1,400.00</span></div>
<div class="grand"><span class="lab">Total due (USD)</span><span class="val">7,000.00</span></div>
</div>
</section>
</div>
<!-- payment info -->
<section class="kb-card">
<h2 class="kb-card__title">Payment information</h2>
<div class="kb-grid cols-3" style="gap:14px 16px;">
<div class="kb-field"><span class="kb-label">Bank</span><input class="kb-input" value="DNB Bank ASA"></div>
<div class="kb-field"><span class="kb-label">IBAN</span><input class="kb-input kb-mono" value="NO93 8601 1117 947"></div>
<div class="kb-field"><span class="kb-label">SWIFT / BIC</span><input class="kb-input kb-mono" value="DNBANOKK"></div>
</div>
</section>
<button class="kb-btn kb-btn--primary kb-btn--lg kb-btn--block" style="margin-top:8px;">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>
Generate Invoice PDF
</button>
</div>
<footer class="kb-footer">
<span class="kb-mark"><span class="glyph"><i></i><i></i></span>kBenestad</span>
<span class="sep">·</span>
<span>© 2026 Kristian Benestad</span>
<span class="sep">·</span>
<a href="https://docs.benestad.net/invoice">docs.benestad.net</a>
<span class="sep">·</span>
<a href="https://github.com/kbenestad/invoice">kbenestad/invoice</a>
</footer>
</body>
</html>

View file

@ -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,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%235F6975' stroke-width='1.6'><path d='M4 6l4 4 4-4'/></svg>");
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: <span class="kb-mark"><svg ></svg>kBenestad</span> */
.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; }
}

326
dev/mockups/reimburse.html Normal file
View file

@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no">
<title>Reimbursement — kBenestad reskin</title>
<link rel="stylesheet" href="kbenestad-forms.css">
<script>
(function(){var p=new URLSearchParams(location.search).get('theme');
if(p)document.documentElement.setAttribute('data-theme',p);})();
</script>
<!-- React + Babel for the Tweaks panel (mockup-only; not part of the shipped app) -->
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<style>
.rb-line-head, .rb-line { grid-template-columns: 1fr 96px 120px 120px 30px; }
.rb-receipt {
display:flex; align-items:center; gap:9px; padding:8px 12px; margin-top:8px;
background:var(--accent-soft); border:1px solid var(--accent-border);
border-radius:var(--radius-sm); font-size:var(--fs-small); color:var(--text-soft);
}
.rb-receipt svg{ width:16px;height:16px;color:var(--accent);flex:0 0 16px; }
.rb-receipt .name{ flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.rb-receipt .sz{ color:var(--text-muted);font-family:var(--font-mono); }
/* FX conversion sub-panel — opens beneath a line when the line's currency
differs from the claim currency. Mirrors the original app's behaviour:
a calculation widget revealed on foreign-currency selection. */
.rb-fx {
margin-top: 10px; padding: 12px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius-sm);
}
.rb-fx__head {
display:flex; align-items:center; gap:8px;
font-size: var(--fs-small); font-weight: 600;
text-transform: uppercase; letter-spacing: .04em;
color: var(--text-muted);
margin-bottom: 10px;
}
.rb-fx__head svg { width:14px; height:14px; color: var(--accent); flex: 0 0 14px; }
.rb-fx__body {
display:flex; align-items:center; gap: 18px; flex-wrap: wrap;
font-size: var(--fs-base);
}
.rb-fx__rate {
display:flex; align-items:center; gap:8px;
padding: 4px 8px 4px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.rb-fx__rate .lab { color: var(--text-muted); font-size: var(--fs-small); white-space: nowrap; }
.rb-fx__rate input {
width: 96px; padding: 4px 8px;
border: 1px solid var(--border); border-radius: 4px;
background: var(--surface); color: var(--text);
font-family: var(--font-mono); text-align: right;
font-size: var(--fs-input);
}
.rb-fx__rate input:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
.rb-fx__calc {
display:flex; align-items:center; gap:10px; flex-wrap: wrap;
color: var(--text-muted); font-size: var(--fs-small);
}
.rb-fx__calc .kb-mono { color: var(--text-soft); }
.rb-fx__calc .op { color: var(--text-muted); font-family: var(--font-mono); }
.rb-fx__calc .total {
color: var(--accent); font-weight: 700;
padding-left: 10px; margin-left: 2px;
border-left: 1px solid var(--border);
}
@media (max-width:680px){
.rb-line-head{display:none;}
.rb-line{grid-template-columns:1fr 1fr;gap:8px;}
}
</style>
</head>
<body class="kb">
<div class="kb-wrap">
<div class="kb-toolbar">
<div class="spacer"></div>
<div class="kb-seg" role="group" aria-label="Text size">
<button>A</button><button class="is-active">A</button><button>A+</button>
</div>
<button class="kb-iconbtn" aria-label="About">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 11.2H7.1v1.5h1.8v-1.5zm0-8.4H7.1v6.2h1.8V2.8z"/></svg>
</button>
</div>
<header class="kb-header">
<div class="kb-brand">
<span class="logo">CA</span>
<span class="org">Center for Asylum Protection<small>Expense reimbursement</small></span>
</div>
<div class="kb-doctitle">
<h1>Reimbursement</h1>
<div class="meta">Claim · 6 June 2026</div>
</div>
</header>
<!-- claimant -->
<section class="kb-card">
<h2 class="kb-card__title">Claimant</h2>
<div class="kb-grid cols-3" style="gap:14px 16px;">
<div class="kb-field"><span class="kb-label">Name</span><input class="kb-input" value="Mai Nguyen"></div>
<div class="kb-field"><span class="kb-label">Program</span>
<select class="kb-select"><option>Legal Aid Program</option><option>Protection Program</option><option>General Operations</option><option>Other…</option></select>
</div>
<div class="kb-field"><span class="kb-label">Account code</span>
<select class="kb-select"><option>2000 — Travel &amp; Transport</option><option>3000 — Office Supplies</option><option>4000 — Professional Services</option></select>
</div>
<div class="kb-field"><span class="kb-label">Claim currency</span>
<select class="kb-select"><option>USD — US dollar</option><option>THB — Thai baht</option><option>EUR — Euro</option></select>
</div>
<div class="kb-field grow" style="grid-column:span 2;"><span class="kb-label">Purpose</span><input class="kb-input" value="Field visit — refugee status interviews, Mae Sot"></div>
</div>
</section>
<!-- expense item 1 -->
<section class="kb-card">
<h2 class="kb-card__title">Expenses <span class="count">2 items</span></h2>
<div class="kb-block">
<div class="kb-block__head">
<span class="tag">Item 1 · Transport</span>
<span class="kb-subtotal">USD 184.00</span>
</div>
<div class="kb-rowhead kb-row rb-line-head">
<span>Description</span><span class="r">Amount</span><span>Currency</span><span class="r">In USD</span><span></span>
</div>
<div class="kb-row rb-line">
<input class="kb-input" value="Return flight BKKMae Sot">
<input class="kb-input num" value="6,440.00">
<select class="kb-select"><option>THB</option><option>USD</option></select>
<span class="r kb-mono">184.00</span>
<button class="kb-circbtn kb-circbtn--rm"></button>
</div>
<div class="rb-receipt">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 1.5A1.5 1.5 0 0 1 4.5 0h4.7c.4 0 .8.16 1.06.44l2.3 2.3c.28.27.44.66.44 1.06v8.7A1.5 1.5 0 0 1 11.5 16h-7A1.5 1.5 0 0 1 3 14.5zM9 1.5V4h2.5z"/></svg>
<span class="name">receipt-flight-bkk.pdf</span><span class="sz">240 KB</span>
</div>
<!-- FX panel: opens beneath the line because its currency (THB) ≠ claim currency (USD) -->
<div class="rb-fx" role="group" aria-label="FX conversion">
<div class="rb-fx__head">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M2 4a1 1 0 0 1 1-1h8.6L10.3 1.7a1 1 0 0 1 1.4-1.4l3 3a1 1 0 0 1 0 1.4l-3 3a1 1 0 0 1-1.4-1.4L11.6 5H3a1 1 0 0 1-1-1zm12 8a1 1 0 0 1-1 1H4.4l1.3 1.3a1 1 0 0 1-1.4 1.4l-3-3a1 1 0 0 1 0-1.4l3-3a1 1 0 0 1 1.4 1.4L4.4 11H13a1 1 0 0 1 1 1z"/></svg>
<span>Foreign currency — enter exchange rate</span>
</div>
<div class="rb-fx__body">
<label class="rb-fx__rate">
<span class="lab">1 USD =</span>
<input value="35.00000" aria-label="THB per 1 USD">
<span class="lab">THB</span>
</label>
<div class="rb-fx__calc">
<span class="kb-mono">6,440.00 THB</span>
<span class="op">÷</span>
<span class="kb-mono">35.00</span>
<span class="op">=</span>
<span class="kb-mono total">USD 184.00</span>
</div>
</div>
</div>
<!-- /FX panel -->
</div>
<!-- expense item 2 -->
<div class="kb-block">
<div class="kb-block__head">
<span class="tag">Item 2 · Accommodation</span>
<span class="kb-subtotal">USD 96.00</span>
</div>
<div class="kb-rowhead kb-row rb-line-head">
<span>Description</span><span class="r">Amount</span><span>Currency</span><span class="r">In USD</span><span></span>
</div>
<div class="kb-row rb-line">
<input class="kb-input" value="Guesthouse — 2 nights">
<input class="kb-input num" value="96.00">
<select class="kb-select"><option>USD</option><option>THB</option></select>
<span class="r kb-mono">96.00</span>
<button class="kb-circbtn kb-circbtn--rm"></button>
</div>
<div class="rb-receipt">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 1.5A1.5 1.5 0 0 1 4.5 0h4.7c.4 0 .8.16 1.06.44l2.3 2.3c.28.27.44.66.44 1.06v8.7A1.5 1.5 0 0 1 11.5 16h-7A1.5 1.5 0 0 1 3 14.5zM9 1.5V4h2.5z"/></svg>
<span class="name">guesthouse-invoice.jpg</span><span class="sz">1.1 MB</span>
</div>
</div>
<button class="kb-btn kb-btn--dashed">+ Add expense item</button>
</section>
<!-- totals + declaration -->
<div class="kb-grid cols-2">
<section class="kb-card" style="margin:0;">
<h2 class="kb-card__title">Declaration</h2>
<div class="kb-note kb-note--info">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 6.8H7.1v5.4h1.8V6.8zM8 3.3a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2z"/></svg>
<span>I certify that the above expenses were incurred on official business and are supported by the attached receipts.</span>
</div>
<div class="kb-field"><span class="kb-label">Claimant signature</span><div class="kb-sig"></div></div>
</section>
<section class="kb-card kb-card--flex" style="margin:0;">
<h2 class="kb-card__title">Summary</h2>
<div class="kb-totals kb-totals--fill">
<div class="row"><span class="lab">Transport</span><span class="val">184.00</span></div>
<div class="row"><span class="lab">Accommodation</span><span class="val">96.00</span></div>
<div class="grand"><span class="lab">Total claim (USD)</span><span class="val">280.00</span></div>
</div>
</section>
</div>
<button class="kb-btn kb-btn--primary kb-btn--lg kb-btn--block" style="margin-top:8px;">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>
Generate Reimbursement PDF
</button>
</div>
<!-- Tweaks panel mount (hidden until the host toggles Tweaks on) -->
<div id="tweak-root"></div>
<script type="text/babel">
const { useTweaks, TweaksPanel, TweakSection, TweakSlider, TweakRadio, TweakColor } = window;
// Defaults reflect the shipping baseline. Each accent palette is
// [--accent, --accent-hover, --accent-soft, --accent-border] so the
// panel can recolour the whole form by writing 4 vars in one shot.
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "auto",
"accent": [
"#2F6FED",
"#1F57CF",
"#EEF3FE",
"#C7D9FB"
],
"fontScale": 1,
"radius": "sharp"
}/*EDITMODE-END*/;
const ACCENT_PALETTES = [
["#2F6FED","#1F57CF","#EEF3FE","#C7D9FB"], // kBenestad blue
["#1F8A5B","#136B45","#E2F3EA","#A7D7BA"], // forest (health / charity)
["#B33A3A","#8E2C2C","#FBEAEA","#EBBCBC"], // crimson (legal)
["#6B4FBB","#523795","#EEEAFB","#C6BCEF"] // plum (consulting)
];
const RADIUS_PRESETS = {
sharp: { r: "2px", rs: "2px" },
"default":{ r: "8px", rs: "6px" },
rounded: { r: "14px", rs: "10px" }
};
function applyTweaks(t) {
const root = document.documentElement;
// Theme: 'auto' clears the attr so the CSS @media (prefers-color-scheme) wins.
if (t.theme === "auto") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", t.theme);
// Accent palette
const [a, h, s, b] = t.accent;
root.style.setProperty("--accent", a);
root.style.setProperty("--accent-hover", h);
root.style.setProperty("--accent-soft", s);
root.style.setProperty("--accent-border", b);
// Font scale (addresses the "text feels small" feedback)
root.style.setProperty("--font-scale", String(t.fontScale));
// Corner radius
const rp = RADIUS_PRESETS[t.radius] || RADIUS_PRESETS["default"];
root.style.setProperty("--radius", rp.r);
root.style.setProperty("--radius-sm", rp.rs);
}
function ReimburseTweaks() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
React.useEffect(() => { applyTweaks(t); }, [t]);
return (
<TweaksPanel title="Reimburse">
<TweakSection label="Theme" />
<TweakRadio
label="Mode" value={t.theme}
options={["auto","light","dark"]}
onChange={(v) => setTweak("theme", v)} />
<TweakColor
label="Accent palette" value={t.accent}
options={ACCENT_PALETTES}
onChange={(v) => setTweak("accent", v)} />
<TweakSection label="Density &amp; rhythm" />
<TweakSlider
label="Text scale" value={t.fontScale}
min={0.95} max={1.30} step={0.05} unit="×"
onChange={(v) => setTweak("fontScale", v)} />
<TweakRadio
label="Corner radius" value={t.radius}
options={["sharp","default","rounded"]}
onChange={(v) => setTweak("radius", v)} />
</TweaksPanel>
);
}
// Wait for tweaks-panel.jsx + the rest to finish loading, then mount.
function mount() {
if (!window.TweaksPanel) return requestAnimationFrame(mount);
ReactDOM.createRoot(document.getElementById("tweak-root"))
.render(<ReimburseTweaks />);
}
mount();
</script>
<footer class="kb-footer">
<span class="kb-mark"><span class="glyph"><i></i><i></i></span>kBenestad</span>
<span class="sep">·</span>
<span>© 2026 Kristian Benestad</span>
<span class="sep">·</span>
<a href="https://docs.benestad.net/invoice">docs.benestad.net</a>
<span class="sep">·</span>
<a href="https://github.com/kbenestad/reimburse">kbenestad/reimburse</a>
</footer>
</body>
</html>

96
dev/mockups/review.html Normal file
View file

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>kBenestad — unified forms review</title>
<link rel="stylesheet" href="kbenestad-forms.css">
<style>
html,body{height:100%;}
body.kb{display:flex;flex-direction:column;min-height:100vh;background:var(--bg);}
.rv-bar{
position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:14px;flex-wrap:wrap;
padding:12px 20px;background:var(--surface);border-bottom:1px solid var(--border);
}
.rv-title{display:flex;align-items:center;gap:11px;font-weight:700;font-size:15px;color:var(--text);letter-spacing:-.01em;}
.rv-title small{display:block;font-weight:500;font-size:12px;color:var(--text-muted);letter-spacing:0;}
.rv-bar .spacer{flex:1;}
.rv-bar .lbl{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);margin-right:-6px;}
.rv-stage{flex:1;padding:22px;display:flex;justify-content:center;}
.rv-framewrap{
width:100%;max-width:1000px;background:var(--surface);border:1px solid var(--border);
border-radius:14px;box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;
}
.rv-frametop{display:flex;align-items:center;gap:7px;padding:9px 14px;border-bottom:1px solid var(--border);background:var(--surface-2);}
.rv-dot{width:11px;height:11px;border-radius:50%;}
.rv-frameurl{margin-left:10px;font-family:var(--font-mono);font-size:12px;color:var(--text-muted);}
.rv-open{margin-left:auto;font-size:12px;font-weight:600;color:var(--accent);text-decoration:none;}
.rv-open:hover{text-decoration:underline;}
iframe{width:100%;height:1120px;border:0;background:var(--surface);display:block;}
@media (max-width:680px){ iframe{height:1500px;} }
</style>
<script>
(function(){var p=new URLSearchParams(location.search).get('theme');
if(p&&p!=='auto')document.documentElement.setAttribute('data-theme',p);})();
</script>
</head>
<body class="kb">
<div class="rv-bar">
<div class="rv-title">
<span class="kb-mark"><span class="glyph"><i></i><i></i></span></span>
<span>kBenestad — unified forms<small>invoice · timesheet · reimburse — review build</small></span>
</div>
<div class="spacer"></div>
<span class="lbl">App</span>
<div class="kb-seg" id="appSeg">
<button data-app="invoice.html" class="is-active">Invoice</button>
<button data-app="timesheet.html">Timesheet</button>
<button data-app="reimburse.html">Reimburse</button>
</div>
<span class="lbl">Theme</span>
<div class="kb-seg" id="themeSeg">
<button data-theme="auto" class="is-active">Auto</button>
<button data-theme="light">Light</button>
<button data-theme="dark">Dark</button>
</div>
</div>
<div class="rv-stage">
<div class="rv-framewrap">
<div class="rv-frametop">
<span class="rv-dot" style="background:#e0625b;"></span>
<span class="rv-dot" style="background:#e2b23a;"></span>
<span class="rv-dot" style="background:#3bab63;"></span>
<span class="rv-frameurl" id="frameUrl">invoice.html</span>
<a class="rv-open" id="openLink" href="invoice.html" target="_blank" rel="noopener">Open full ↗</a>
</div>
<iframe id="frame" src="invoice.html" title="App preview"></iframe>
</div>
</div>
<script>
var app = 'invoice.html', theme = 'auto';
var frame = document.getElementById('frame');
var frameUrl = document.getElementById('frameUrl');
var openLink = document.getElementById('openLink');
function src(){ return app + (theme==='auto' ? '' : '?theme='+theme); }
function render(){
frame.src = src();
frameUrl.textContent = src();
openLink.href = src();
}
function wire(segId, set){
var seg = document.getElementById(segId);
seg.addEventListener('click', function(e){
var b = e.target.closest('button'); if(!b) return;
[].forEach.call(seg.children, function(c){c.classList.remove('is-active');});
b.classList.add('is-active'); set(b); render();
});
}
wire('appSeg', function(b){ app = b.dataset.app; });
wire('themeSeg', function(b){ theme = b.dataset.theme; });
</script>
</body>
</html>

180
dev/mockups/timesheet.html Normal file
View file

@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no">
<title>Timesheet — kBenestad reskin</title>
<link rel="stylesheet" href="kbenestad-forms.css">
<script>
(function(){var p=new URLSearchParams(location.search).get('theme');
if(p)document.documentElement.setAttribute('data-theme',p);})();
</script>
<style>
/* code colours come straight from timesheet-config.yml (codes[].colour-*) —
here as per-chip CSS vars so they stay fully configurable. */
.c-NON { --chip-border:#393939; --chip-bg:#d7d7d7; --chip-text:#393939; }
.c-REG { --chip-border:#0078d7; --chip-bg:#cce4f7; --chip-text:#0a4c87; }
.c-PPT { --chip-border:#ed616f; --chip-bg:#ffd9d9; --chip-text:#a8323d; }
.c-PPTp{ --chip-border:#ffb900; --chip-bg:#fff1cc; --chip-text:#8a6300; }
.c-OTH { --chip-border:#a4252c; --chip-bg:#edd4d5; --chip-text:#8a1f25; }
.c-UNP { --chip-border:#393939; --chip-bg:#d7d7d7; --chip-text:#393939; }
.c-HOL { --chip-border:#8cbd18; --chip-bg:#e8f2d1; --chip-text:#4f6b0d; }
:root[data-theme="dark"] .kb-chip { color: var(--chip-border); filter: saturate(1.1) brightness(1.15); }
.ts-head, .ts-row2 { grid-template-columns: 110px 102px 102px 110px 1fr 64px 30px; }
.ts-row2 input[type=time]{ font-family:var(--font-mono); }
.ts-date { font-weight:600; font-size:var(--fs-small); color:var(--text); white-space:nowrap; }
.ts-date .dow { display:block; font-weight:500; font-size:11px; color:var(--text-muted); }
.ts-legend { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:6px; }
@media (max-width:680px){
.ts-head{display:none;}
.ts-row2{grid-template-columns:1fr 1fr;gap:8px;background:var(--surface-2);padding:12px;border-radius:var(--radius-sm);border-left-width:4px;}
}
</style>
</head>
<body class="kb">
<div class="kb-wrap">
<div class="kb-toolbar">
<div class="kb-seg" role="group" aria-label="Language">
<button class="is-active">EN</button><button>Tiếng Việt</button>
</div>
<div class="spacer"></div>
<div class="kb-seg" role="group" aria-label="Text size">
<button>A</button><button class="is-active">A</button><button>A+</button>
</div>
<button class="kb-iconbtn" aria-label="About">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 11.2H7.1v1.5h1.8v-1.5zm0-8.4H7.1v6.2h1.8V2.8z"/></svg>
</button>
</div>
<header class="kb-header">
<div class="kb-brand">
<span class="logo">CA</span>
<span class="org">Center for Asylum Protection<small>Timesheet · monthly</small></span>
</div>
<div class="kb-doctitle">
<h1>Timesheet</h1>
<div class="meta">17 June 2026</div>
</div>
</header>
<!-- employee meta -->
<section class="kb-card">
<div class="kb-grid cols-3" style="gap:14px 16px;">
<div class="kb-field"><span class="kb-label">Employee</span><input class="kb-input" value="Linh Tran"></div>
<div class="kb-field"><span class="kb-label">Employee type</span>
<select class="kb-select"><option>Monthly</option><option>Hourly</option><option>Freelance</option></select>
</div>
<div class="kb-field"><span class="kb-label">Period</span><input class="kb-input" value="1 Jun 2026 to 7 Jun 2026"></div>
</div>
</section>
<!-- entries -->
<section class="kb-card">
<h2 class="kb-card__title">Daily entries</h2>
<div class="ts-legend">
<span class="kb-chip c-REG"><span class="dot"></span>REG · Regular</span>
<span class="kb-chip c-PPT"><span class="dot"></span>PPT · Paid leave</span>
<span class="kb-chip c-OTH"><span class="dot"></span>OTH · Other paid</span>
<span class="kb-chip c-HOL"><span class="dot"></span>HOL · Holiday</span>
<span class="kb-chip c-UNP"><span class="dot"></span>UNP · Unpaid</span>
<span class="kb-chip c-NON"><span class="dot"></span>NON · Non-working</span>
</div>
<div class="kb-rowhead kb-row ts-head">
<span>Date</span><span>Time in</span><span>Time out</span><span>Code</span><span>Description</span><span class="r">Hours</span><span></span>
</div>
<div class="kb-row ts-row2 coded c-REG">
<span class="ts-date">Mon 1 Jun<span class="dow">Monday</span></span>
<input class="kb-input" type="time" value="09:00"><input class="kb-input" type="time" value="17:00">
<select class="kb-select"><option>REG</option></select>
<input class="kb-input" value="Case intake & client interviews">
<span class="r kb-mono">8.0</span>
<button class="kb-circbtn kb-circbtn--rm"></button>
</div>
<div class="kb-row ts-row2 coded c-REG">
<span class="ts-date">Tue 2 Jun<span class="dow">Tuesday</span></span>
<input class="kb-input" type="time" value="09:00"><input class="kb-input" type="time" value="17:00">
<select class="kb-select"><option>REG</option></select>
<input class="kb-input" value="Protection assessment reports">
<span class="r kb-mono">8.0</span>
<button class="kb-circbtn kb-circbtn--rm"></button>
</div>
<div class="kb-row ts-row2 coded c-PPTp">
<span class="ts-date">Wed 3 Jun<span class="dow">Wednesday</span></span>
<input class="kb-input" type="time" value="09:00"><input class="kb-input" type="time" value="13:00">
<select class="kb-select"><option>PPT</option></select>
<input class="kb-input" value="Medical appointment (half day)">
<span class="r kb-mono">4.0</span>
<button class="kb-circbtn kb-circbtn--rm"></button>
</div>
<div class="kb-row ts-row2 coded c-HOL">
<span class="ts-date">Thu 4 Jun<span class="dow">Thursday</span></span>
<input class="kb-input" type="time" value="—" disabled><input class="kb-input" type="time" value="—" disabled>
<select class="kb-select" disabled><option>HOL</option></select>
<input class="kb-input" value="Public holiday" readonly>
<span class="r kb-mono">8.0</span>
<button class="kb-circbtn kb-circbtn--rm"></button>
</div>
<div class="kb-row ts-row2 coded c-REG">
<span class="ts-date">Fri 5 Jun<span class="dow">Friday</span></span>
<input class="kb-input" type="time" value="09:00"><input class="kb-input" type="time" value="16:00">
<select class="kb-select"><option>REG</option></select>
<input class="kb-input" value="Team sync & documentation">
<span class="r kb-mono">7.0</span>
<button class="kb-circbtn kb-circbtn--rm"></button>
</div>
<div style="margin-top:12px;"><button class="kb-btn kb-btn--dashed">+ Add row</button></div>
</section>
<!-- summary -->
<section class="kb-card">
<h2 class="kb-card__title">Summary</h2>
<div class="kb-totals kb-totals--fill">
<div class="row"><span class="lab">Total hours</span><span class="val">35.0</span></div>
<div class="row"><span class="lab">Of which paid leave</span><span class="val">4.0</span></div>
<div class="row"><span class="lab">Of which holiday</span><span class="val">8.0</span></div>
<div class="grand"><span class="lab">Total (decimal)</span><span class="val">35.00</span></div>
</div>
</section>
<!-- signatures -->
<section class="kb-card">
<h2 class="kb-card__title">Signatures</h2>
<div class="kb-grid cols-2" style="gap:18px;">
<div class="kb-field"><span class="kb-label">Employee signature</span><div class="kb-sig"></div></div>
<div class="kb-field"><span class="kb-label">Authorised signature</span><div class="kb-sig"></div></div>
</div>
</section>
<div style="display:flex;gap:12px;align-items:center;margin-top:8px;flex-wrap:wrap;">
<button class="kb-btn kb-btn--ghost">New timesheet</button>
<div style="flex:1;"></div>
<button class="kb-btn kb-btn--soft kb-btn--lg">Validate</button>
<button class="kb-btn kb-btn--primary kb-btn--lg">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>
Generate Timesheet
</button>
</div>
</div>
<footer class="kb-footer">
<span class="kb-mark"><span class="glyph"><i></i><i></i></span>kBenestad</span>
<span class="sep">·</span>
<span>© 2026 Kristian Benestad</span>
<span class="sep">·</span>
<a href="https://docs.benestad.net">docs.benestad.net</a>
<span class="sep">·</span>
<a href="https://github.com/kbenestad/timesheet">kbenestad/timesheet</a>
</footer>
</body>
</html>

View file

@ -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 <input type="range">, 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 (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 23 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 25 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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
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 (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// Layout helpers
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// Controls
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
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) {
// <select> 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 <TweakSelect label={label} value={value} options={options}
onChange={(s) => 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 (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
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 (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// 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 }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// 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 (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> 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 (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{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 (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});

159
dev/theme/README.md Normal file
View file

@ -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:
```
<FORGEJO_CUSTOM>/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) — <https://fonts.google.com/specimen/Schibsted+Grotesk>
- JetBrains Mono (400/500/600) — <https://fonts.google.com/specimen/JetBrains+Mono>
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 `<body>` → 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.

101
dev/theme/kbenestad.yaml Normal file
View file

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

827
dev/theme/preview.html Normal file
View file

@ -0,0 +1,827 @@
<!-- @dsCard group="Brand" name="Forgejo theme preview" subtitle="Repo page mock — light + dark" viewport="1280x900" -->
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<title>kb / utils · code.kbenestad</title>
<meta name="viewport" content="width=1280">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* ───────────────────────────────────────────────────────────────
kBenestad → Forgejo · preview palette
Single file: this mirrors what theme-kbenestad-{light,dark}.css
produce on a real Forgejo install, scoped to [data-theme].
─────────────────────────────────────────────────────────────── */
:root[data-theme="light"] {
--bg: #f8f9fb;
--box: #ffffff;
--box-header: #f8f9fb;
--border: #e7eaef;
--border-soft: #eef0f4;
--text: #14181e;
--text-soft: #3a434f;
--text-mute: #6b7785;
--text-faint: #97a0ac;
--accent: #2f6fed;
--accent-hover: #1f57cf;
--accent-soft: #eef3fe;
--accent-ring: rgba(47,111,237,.18);
--code-bg: #f1f3f6;
--green: #1f9d5f;
--green-soft: #d7f0e1;
--hover: #f1f3f6;
--shadow: 0 1px 0 rgba(20,24,30,.02);
}
:root[data-theme="dark"] {
--bg: #0d1117;
--box: #161b22;
--box-header: #1c232c;
--border: #232a33;
--border-soft: #1c232c;
--text: #eef1f5;
--text-soft: #c2cad3;
--text-mute: #8b95a1;
--text-faint: #6f7986;
--accent: #2f6fed;
--accent-hover: #4f82ec;
--accent-soft: #16233f;
--accent-ring: rgba(86,133,233,.28);
--code-bg: #0f141a;
--green: #3bb97a;
--green-soft: #13301f;
--hover: #1c232c;
--shadow: 0 1px 0 rgba(0,0,0,.25);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
letter-spacing: -.005em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
code, pre, .mono { font-family: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 1216px; margin: 0 auto; padding: 0 32px; }
/* ── Top navbar ─────────────────────────────────────────────── */
.nav {
height: 56px;
background: var(--box);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
}
.nav-inner { width: 100%; display: flex; align-items: center; gap: 24px; }
.brand {
display: flex; align-items: center; gap: 10px;
color: var(--text); font-weight: 700; font-size: 15px;
letter-spacing: -.01em;
}
.brand-mark {
position: relative; width: 22px; height: 22px;
}
.brand-mark span {
position: absolute; width: 14px; height: 14px;
border-radius: 4px; background: var(--accent);
}
.brand-mark span:nth-child(1) { left: 0; top: 0; opacity: .55; }
.brand-mark span:nth-child(2) { right: 0; bottom: 0; background: var(--text); }
.brand-dot { color: var(--text-mute); font-weight: 500; }
.nav-links { display: flex; gap: 4px; flex: 1; }
.nav-links a {
color: var(--text-soft); font-weight: 500; font-size: 14px;
padding: 6px 10px; border-radius: 6px;
}
.nav-links a:hover { background: var(--hover); text-decoration: none; color: var(--text); }
.nav-right { display: flex; align-items: center; gap: 12px; }
.search {
display: flex; align-items: center; gap: 8px;
height: 32px; padding: 0 12px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text-mute); font-size: 13px; min-width: 240px;
}
.search .kbd {
margin-left: auto; padding: 1px 6px;
border: 1px solid var(--border); border-radius: 4px;
font-family: "JetBrains Mono", monospace; font-size: 11px;
color: var(--text-faint); background: var(--box);
}
.icon-btn {
width: 32px; height: 32px; display: grid; place-items: center;
border-radius: 6px; color: var(--text-soft); cursor: pointer;
background: transparent; border: 0;
}
.icon-btn:hover { background: var(--hover); color: var(--text); }
.avatar {
width: 28px; height: 28px; border-radius: 99px;
background: linear-gradient(135deg, #2f6fed, #6b5fd2);
display: grid; place-items: center;
color: #fff; font-weight: 700; font-size: 11px;
border: 1px solid var(--border);
}
/* ── Theme toggle (preview chrome, not part of Forgejo) ─────── */
.theme-toggle {
position: fixed; top: 14px; right: 18px; z-index: 50;
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px; height: 30px;
background: var(--box); color: var(--text-soft);
border: 1px solid var(--border); border-radius: 99px;
font: 600 12px/1 "Schibsted Grotesk", system-ui;
cursor: pointer; box-shadow: 0 2px 12px rgba(20,24,30,.10);
}
.theme-toggle:hover { color: var(--text); }
.theme-toggle svg { width: 14px; height: 14px; }
/* ── Repo header ─────────────────────────────────────────────── */
.repo-header { padding: 24px 0 0; }
.repo-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.repo-title .repo-icon { color: var(--text-mute); }
.repo-title h1 {
margin: 0; font-size: 20px; font-weight: 500;
display: flex; align-items: center; gap: 6px;
}
.repo-title h1 .owner { color: var(--accent); font-weight: 500; }
.repo-title h1 .slash { color: var(--text-faint); font-weight: 400; }
.repo-title h1 .name { color: var(--accent); font-weight: 700; }
.badge {
font-size: 11px; font-weight: 600; padding: 2px 8px;
border: 1px solid var(--border); border-radius: 99px;
color: var(--text-soft);
text-transform: none;
}
.repo-actions { margin-left: auto; display: flex; gap: 8px; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px;
background: var(--box); color: var(--text); font-weight: 500; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px; cursor: pointer;
}
.btn:hover { background: var(--hover); }
.btn .count {
margin-left: 4px; padding: 1px 7px;
background: var(--bg); border: 1px solid var(--border);
border-radius: 99px; font-size: 11px; color: var(--text-soft);
}
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-primary .count {
background: rgba(255,255,255,.18); border-color: transparent; color: rgba(255,255,255,.9);
}
.btn svg { width: 14px; height: 14px; }
/* ── Tabs ────────────────────────────────────────────────────── */
.tabs {
margin-top: 16px;
border-bottom: 1px solid var(--border);
display: flex; gap: 4px;
}
.tab {
position: relative; display: inline-flex; align-items: center; gap: 8px;
padding: 10px 14px; color: var(--text-soft); font-size: 14px; font-weight: 500;
border-radius: 6px 6px 0 0; cursor: pointer; margin-bottom: -1px;
}
.tab:hover { background: var(--hover); color: var(--text); text-decoration: none; }
.tab svg { width: 14px; height: 14px; color: var(--text-mute); }
.tab.active { color: var(--accent); font-weight: 600; }
.tab.active svg { color: var(--accent); }
.tab.active::after {
content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px;
background: var(--accent); border-radius: 2px;
}
.tab .count {
background: var(--accent-soft); border: 1px solid var(--accent-ring);
border-radius: 99px; padding: 0 7px; font-size: 11px; font-weight: 600;
color: var(--accent);
}
.tab.active .count {
background: var(--accent); border-color: var(--accent); color: #fff;
}
/* ── Main grid ──────────────────────────────────────────────── */
.main {
display: grid; grid-template-columns: 1fr 296px; gap: 28px;
margin: 24px 0 64px;
}
/* ── Subbar (branch + code button) ──────────────────────────── */
.subbar {
display: flex; align-items: center; gap: 10px; margin-bottom: 16px;
}
.branch-select {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 10px;
border: 1px solid var(--border); border-radius: 6px;
background: var(--box); color: var(--text); font-weight: 500; font-size: 13px;
cursor: pointer;
}
.branch-select svg { width: 14px; height: 14px; color: var(--text-mute); }
.ref-counts {
display: flex; gap: 14px; color: var(--text-soft); font-size: 13px;
}
.ref-counts a { color: var(--text-soft); }
.ref-counts strong { color: var(--text); font-weight: 600; }
.subbar .spacer { flex: 1; }
.go-file {
display: inline-flex; align-items: center; white-space: nowrap;
height: 30px; padding: 0 12px;
border: 1px solid var(--border); background: var(--box);
border-radius: 6px; color: var(--text-soft); font-size: 13px;
font-family: "JetBrains Mono", monospace;
}
/* ── Box (cards / tables) ───────────────────────────────────── */
.box {
background: var(--box);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
overflow: hidden;
}
/* ── File table ─────────────────────────────────────────────── */
.commit-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px;
background: var(--box-header);
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.commit-row .avatar { width: 22px; height: 22px; font-size: 10px; }
.commit-row .author { font-weight: 600; color: var(--text); }
.commit-row .msg { color: var(--text-soft); }
.commit-row .sha {
margin-left: auto; color: var(--text-mute);
font-family: "JetBrains Mono", monospace; font-size: 12px;
}
.commit-row .when { color: var(--text-mute); font-size: 12px; }
.file-row {
display: grid; grid-template-columns: 24px 1fr 2fr auto;
align-items: center; gap: 12px;
padding: 10px 16px;
border-top: 1px solid var(--border-soft);
font-size: 14px;
}
.file-row:first-of-type { border-top: 0; }
.file-row:hover { background: var(--hover); }
.file-row .icon { color: var(--text-mute); display: grid; place-items: center; }
.file-row .icon.folder { color: var(--accent); }
.file-row .name { color: var(--text); font-weight: 500; }
.file-row .name a { color: inherit; }
.file-row .msg {
color: var(--text-mute); font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-row .msg a { color: var(--text-mute); }
.file-row .msg a:hover { color: var(--accent); }
.file-row .when {
color: var(--text-mute); font-size: 12px; white-space: nowrap;
}
/* ── README card ─────────────────────────────────────────────── */
.readme { margin-top: 20px; }
.readme-header {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px;
background: var(--box-header);
border-bottom: 1px solid var(--border);
font-size: 13px; font-weight: 600; color: var(--text-soft);
}
.readme-header svg { width: 14px; height: 14px; color: var(--text-mute); }
.readme-body { padding: 28px 36px 36px; }
.readme-body h1 {
margin: 0 0 4px; font-size: 28px; font-weight: 700; letter-spacing: -.02em;
padding-bottom: 10px; border-bottom: 1px solid var(--border);
}
.readme-body h2 {
margin: 28px 0 10px; font-size: 19px; font-weight: 600; letter-spacing: -.01em;
padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
.readme-body p { margin: 0 0 12px; color: var(--text-soft); }
.readme-body ul { padding-left: 22px; margin: 0 0 12px; color: var(--text-soft); }
.readme-body ul li { margin: 4px 0; }
.readme-body ul li code { color: var(--text); }
.readme-body code {
background: var(--code-bg); padding: 1px 6px; border-radius: 4px;
font-size: 12.5px; color: var(--text);
}
.readme-body pre {
background: var(--code-bg); padding: 14px 16px;
border-radius: 8px; margin: 0 0 16px;
overflow-x: auto; font-size: 13px;
color: var(--text);
}
.readme-body pre code { background: transparent; padding: 0; font-size: 13px; }
.readme-body .tag { color: var(--accent); }
.readme-body .com { color: var(--text-faint); }
/* ── Sidebar ────────────────────────────────────────────────── */
.side-section { margin-bottom: 24px; }
.side-section h3 {
margin: 0 0 12px; font-size: 13px; font-weight: 600;
color: var(--text); letter-spacing: -.005em;
display: flex; align-items: center; gap: 8px;
}
.side-section h3 .edit {
margin-left: auto; color: var(--text-mute); cursor: pointer;
}
.side-section .desc { color: var(--text-soft); margin: 0 0 12px; }
.side-meta { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.side-meta li {
display: flex; align-items: center; gap: 8px;
color: var(--text-soft); font-size: 13px; white-space: nowrap;
}
.side-meta li svg { width: 14px; height: 14px; color: var(--text-mute); flex: 0 0 14px; }
.side-meta li a { color: var(--text-soft); }
.side-meta li a:hover { color: var(--accent); }
.topics { display: flex; flex-wrap: wrap; gap: 6px; }
.topic {
display: inline-block;
padding: 2px 10px;
background: var(--accent-soft);
color: var(--accent);
border-radius: 99px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--accent-ring);
}
.topic:hover { background: var(--accent); color: #fff; text-decoration: none; }
.divider { height: 1px; background: var(--border); margin: 20px 0; border: 0; }
.release {
display: flex; gap: 10px; align-items: flex-start;
}
.release .tag-icon {
width: 28px; height: 28px;
background: var(--green-soft); color: var(--green);
border-radius: 6px; display: grid; place-items: center;
flex: 0 0 28px;
}
.release-meta { flex: 1; min-width: 0; }
.release-title {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
font-size: 14px; font-weight: 600; color: var(--text);
}
.pill {
font-size: 10px; font-weight: 700; padding: 1px 7px;
background: var(--green); color: #fff;
border-radius: 99px; text-transform: uppercase; letter-spacing: .03em;
}
.release-when { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.lang-bar {
display: flex; height: 8px; border-radius: 99px; overflow: hidden;
background: var(--border); margin-bottom: 12px;
}
.lang-key {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px;
font-size: 12px; color: var(--text-soft);
}
.lang-key .dot {
display: inline-block; width: 10px; height: 10px;
border-radius: 99px; margin-right: 6px; vertical-align: -1px;
}
.lang-key .pct { color: var(--text-mute); margin-left: 2px; }
/* ── Footer ─────────────────────────────────────────────────── */
.footer {
border-top: 1px solid var(--border);
color: var(--text-mute); font-size: 12px;
padding: 16px 0 32px;
display: flex; gap: 18px;
}
.footer a { color: var(--text-mute); }
.footer a:hover { color: var(--accent); }
.footer .spacer { flex: 1; }
</style>
</head>
<body>
<!-- preview chrome: theme toggle ───────────────────────────────── -->
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">
<svg viewBox="0 0 16 16" fill="currentColor" id="themeIcon" aria-hidden="true">
<path d="M8 12.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zM8 0a.5.5 0 0 1 .5.5v1.5a.5.5 0 0 1-1 0V.5A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5V15a.5.5 0 0 1-1 0v-1.5A.5.5 0 0 1 8 13zM3.05 2.343a.5.5 0 0 1 .707 0l1.06 1.06a.5.5 0 1 1-.707.708L3.05 3.05a.5.5 0 0 1 0-.707zm8.486 8.486a.5.5 0 0 1 .707 0l1.06 1.06a.5.5 0 1 1-.707.708l-1.06-1.06a.5.5 0 0 1 0-.708zM0 8a.5.5 0 0 1 .5-.5H2a.5.5 0 0 1 0 1H.5A.5.5 0 0 1 0 8zm13 0a.5.5 0 0 1 .5-.5H15a.5.5 0 0 1 0 1h-1.5A.5.5 0 0 1 13 8zM2.343 12.95a.5.5 0 0 1 0-.707l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.5.5 0 0 1-.708 0zm8.486-8.486a.5.5 0 0 1 0-.707l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.5.5 0 0 1-.708 0z"/>
</svg>
<span id="themeLabel">Light</span>
</button>
<!-- ── Nav ─────────────────────────────────────────────────────── -->
<nav class="nav">
<div class="container nav-inner">
<a class="brand" href="#">
<span class="brand-mark" aria-hidden="true"><span></span><span></span></span>
<span>code<span class="brand-dot">.kbenestad</span></span>
</a>
<div class="nav-links">
<a href="#">Dashboard</a>
<a href="#">Issues</a>
<a href="#">Pull&nbsp;Requests</a>
<a href="#">Milestones</a>
<a href="#">Explore</a>
</div>
<div class="nav-right">
<div class="search">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M10.68 11.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04zM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0z"/></svg>
<span>Search…</span>
<span class="kbd">⌘K</span>
</div>
<button class="icon-btn" aria-label="New">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8.75 1a.75.75 0 0 0-1.5 0v6.25H1a.75.75 0 0 0 0 1.5h6.25V15a.75.75 0 0 0 1.5 0V8.75H15a.75.75 0 0 0 0-1.5H8.75V1z"/></svg>
</button>
<button class="icon-btn" aria-label="Notifications">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5a.75.75 0 0 1 .75.75v.554a4.752 4.752 0 0 1 3.5 4.586v1.79l1.058 2.116A.75.75 0 0 1 12.63 12.4H10.2a2.25 2.25 0 0 1-4.4 0H3.37a.75.75 0 0 1-.67-1.085l1.05-2.1V7.39A4.752 4.752 0 0 1 7.25 2.804v-.554A.75.75 0 0 1 8 1.5zM7.2 12.4a.75.75 0 0 0 1.6 0H7.2z"/></svg>
</button>
<div class="avatar" title="kb">KB</div>
</div>
</div>
</nav>
<div class="container">
<!-- ── Repo header ───────────────────────────────────────────── -->
<header class="repo-header">
<div class="repo-title">
<svg class="repo-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5zm10.5-1H4.5a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8zM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2z"/></svg>
<h1>
<span class="owner">kb</span><span class="slash">/</span><span class="name">utils</span>
</h1>
<span class="badge">Public</span>
<div class="repo-actions">
<button class="btn">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.67-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2zM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717zM8 10a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>
Watch<span class="count">7</span>
</button>
<button class="btn">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25z"/></svg>
Star<span class="count">142</span>
</button>
<button class="btn">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0zM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0z"/></svg>
Fork<span class="count">23</span>
</button>
</div>
</div>
<!-- ── Tabs ───────────────────────────────────────────────── -->
<div class="tabs">
<a class="tab active" href="#">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4.72 3.22a.75.75 0 0 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06zm6.56 0a.75.75 0 0 0-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06l4.25-4.25a.75.75 0 0 0 0-1.06z"/></svg>
Code
</a>
<a class="tab" href="#">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0z"/></svg>
Issues <span class="count">12</span>
</a>
<a class="tab" href="#">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354z"/></svg>
Pull&nbsp;Requests <span class="count">3</span>
</a>
<a class="tab" href="#">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25z"/></svg>
Actions
</a>
<a class="tab" href="#">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="m8.878.392 5.25 3.045a2 2 0 0 1 .872 1.617v7.642a2 2 0 0 1-.872 1.617l-5.25 3.045a2 2 0 0 1-2.066 0L1.872 14.32a2 2 0 0 1-.872-1.617V5.054a2 2 0 0 1 .872-1.617L7.122.392a2 2 0 0 1 2.066 0z"/></svg>
Packages
</a>
<a class="tab" href="#">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775zM6 5a1 1 0 1 0-2 0 1 1 0 0 0 2 0z"/></svg>
Releases <span class="count">4</span>
</a>
<a class="tab" href="#">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75z"/></svg>
Wiki
</a>
<a class="tab" href="#">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C5.91.645 6.457.095 7.2.031A8.2 8.2 0 0 1 8 0zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"/></svg>
Settings
</a>
</div>
</header>
<!-- ── Main content + sidebar ────────────────────────────────── -->
<div class="main">
<section>
<!-- subbar ------------------------------------------------- -->
<div class="subbar">
<button class="branch-select">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.49 2.49 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25z"/></svg>
<strong>main</strong>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="opacity:.7"><path d="M12.78 6.22a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.22 7.28a.75.75 0 0 1 1.06-1.06L8 9.94l3.72-3.72a.75.75 0 0 1 1.06 0z"/></svg>
</button>
<div class="ref-counts">
<a href="#"><strong>12</strong> Branches</a>
<a href="#"><strong>8</strong> Tags</a>
</div>
<div class="spacer"></div>
<span class="go-file">Go to file</span>
<button class="btn">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8.75 1a.75.75 0 0 0-1.5 0v6.25H1a.75.75 0 0 0 0 1.5h6.25V15a.75.75 0 0 0 1.5 0V8.75H15a.75.75 0 0 0 0-1.5H8.75V1z"/></svg>
Add file
</button>
<button class="btn btn-primary">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4.72 3.22a.75.75 0 0 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06zm6.56 0a.75.75 0 0 0-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06l4.25-4.25a.75.75 0 0 0 0-1.06z"/></svg>
Code
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="margin-left:2px;width:12px;height:12px"><path d="M12.78 6.22a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.22 7.28a.75.75 0 0 1 1.06-1.06L8 9.94l3.72-3.72a.75.75 0 0 1 1.06 0z"/></svg>
</button>
</div>
<!-- file table -------------------------------------------- -->
<div class="box">
<div class="commit-row">
<div class="avatar" style="background:linear-gradient(135deg,#1f9d5f,#2f6fed);">KB</div>
<span class="author">kb</span>
<span class="msg">Tighten retry backoff jitter; cap at 30s</span>
<span class="sha">a8f3c0e</span>
<span class="when">· 2 days ago</span>
</div>
<div class="file-row">
<span class="icon folder">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
</span>
<span class="name"><a href="#">.forgejo</a></span>
<span class="msg"><a href="#">ci: pin runner image to v2</a></span>
<span class="when">3 weeks ago</span>
</div>
<div class="file-row">
<span class="icon folder">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
</span>
<span class="name"><a href="#">cmd</a></span>
<span class="msg"><a href="#">cmd/kbu: drop deprecated --raw flag</a></span>
<span class="when">last month</span>
</div>
<div class="file-row">
<span class="icon folder">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
</span>
<span class="name"><a href="#">internal</a></span>
<span class="msg"><a href="#">internal/retry: jitter cap 30s</a></span>
<span class="when">2 days ago</span>
</div>
<div class="file-row">
<span class="icon folder">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
</span>
<span class="name"><a href="#">pkg</a></span>
<span class="msg"><a href="#">pkg/slug: handle combining marks correctly</a></span>
<span class="when">last week</span>
</div>
<div class="file-row">
<span class="icon folder">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
</span>
<span class="name"><a href="#">testdata</a></span>
<span class="msg"><a href="#">golden: refresh fixtures for v2.4</a></span>
<span class="when">2 weeks ago</span>
</div>
<div class="file-row">
<span class="icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
</span>
<span class="name"><a href="#">.editorconfig</a></span>
<span class="msg"><a href="#">chore: standardize 2-space yaml</a></span>
<span class="when">3 months ago</span>
</div>
<div class="file-row">
<span class="icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
</span>
<span class="name"><a href="#">.gitignore</a></span>
<span class="msg"><a href="#">ignore local kbpkg cache dirs</a></span>
<span class="when">6 months ago</span>
</div>
<div class="file-row">
<span class="icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
</span>
<span class="name"><a href="#">LICENSE</a></span>
<span class="msg"><a href="#">Initial commit</a></span>
<span class="when">2 years ago</span>
</div>
<div class="file-row">
<span class="icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
</span>
<span class="name"><a href="#">Makefile</a></span>
<span class="msg"><a href="#">make: split test:unit / test:race</a></span>
<span class="when">last month</span>
</div>
<div class="file-row">
<span class="icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
</span>
<span class="name"><a href="#">README.md</a></span>
<span class="msg"><a href="#">docs: usage examples for retry helper</a></span>
<span class="when">2 days ago</span>
</div>
<div class="file-row">
<span class="icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
</span>
<span class="name"><a href="#">go.mod</a></span>
<span class="msg"><a href="#">deps: go 1.22; bump x/sync</a></span>
<span class="when">last month</span>
</div>
<div class="file-row">
<span class="icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
</span>
<span class="name"><a href="#">go.sum</a></span>
<span class="msg"><a href="#">deps: go 1.22; bump x/sync</a></span>
<span class="when">last month</span>
</div>
</div>
<!-- README ------------------------------------------------ -->
<div class="box readme">
<div class="readme-header">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.744 3.744 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75z"/></svg>
README.md
</div>
<div class="readme-body">
<h1>utils</h1>
<p>A small, opinionated bundle of Go helpers used across <code>kb/*</code> services —
retry/backoff, slug, env loading, structured errors. Zero non-stdlib runtime
dependencies; everything else is dev-only.</p>
<h2>Install</h2>
<pre><code><span class="com"># Go module</span>
go get code.kbenestad.net/kb/utils@v2.4.0
<span class="com"># Or via kbpkg, our internal package manager</span>
kbpkg install kb/utils
</code></pre>
<h2>Usage</h2>
<pre><code><span class="tag">package</span> main
<span class="tag">import</span> (
"context"
"code.kbenestad.net/kb/utils/retry"
)
<span class="tag">func</span> main() {
ctx := context.Background()
_ = retry.Do(ctx, retry.Default, <span class="tag">func</span>() <span class="tag">error</span> {
<span class="com">// network call here</span>
<span class="tag">return</span> nil
})
}
</code></pre>
<h2>What's inside</h2>
<ul>
<li><code>retry</code> — context-aware exponential backoff with full jitter (cap 30s).</li>
<li><code>slug</code> — Unicode-correct slugification; handles combining marks.</li>
<li><code>envx</code> — typed env loading with defaults and required-key checks.</li>
<li><code>errs</code> — structured error wrapping that survives JSON round-trips.</li>
<li><code>iox</code> — small io helpers (limited readers, atomic file writes).</li>
</ul>
<h2>Stability</h2>
<p>Public API follows semver. Anything under <code>internal/</code> is fair game and
will change without notice.</p>
</div>
</div>
</section>
<!-- ── Sidebar ───────────────────────────────────────────── -->
<aside>
<section class="side-section">
<h3>About <span class="edit" title="Edit">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M.18 13.86A1.75 1.75 0 0 0 0 14.629V15.5a.5.5 0 0 0 .5.5h.871a1.75 1.75 0 0 0 .77-.18l9.55-4.775a.5.5 0 0 0 .224-.224l1.286-2.572a.5.5 0 0 0-.09-.567L9.318.18a.5.5 0 0 0-.567-.09L6.18 1.378a.5.5 0 0 0-.224.224L1.18 11.151a1.75 1.75 0 0 0-.18.77z"/></svg>
</span></h3>
<p class="desc">Small Go helpers shared across kBenestad services — retry, slug, env, structured errors.</p>
<div class="topics">
<a class="topic" href="#">go</a>
<a class="topic" href="#">utilities</a>
<a class="topic" href="#">retry</a>
<a class="topic" href="#">backoff</a>
<a class="topic" href="#">kbenestad</a>
</div>
<ul class="side-meta" style="margin-top:14px">
<li>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.744 3.744 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75z"/></svg>
Readme
</li>
<li>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C13.928 10.86 13.144 11 12.5 11s-1.428-.14-2.072-.46a4.07 4.07 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L11.628 4.5h-.539c-.166 0-.331-.027-.49-.078L9.293 4.94c-.279.156-.594.236-.913.234V13h2.25a.75.75 0 0 1 0 1.5h-6a.75.75 0 0 1 0-1.5h2.25V5.173l-1-.572a.51.51 0 0 0-.123-.033H4.91l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C5.428 10.86 4.644 11 4 11s-1.428-.14-2.072-.46a4.07 4.07 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L3.128 4.5H2.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.868-.23h.985V.75a.75.75 0 0 1 1.5 0z"/></svg>
<a href="#">MIT License</a>
</li>
<li>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm0 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zm.75 3.25v3.5h2.5a.75.75 0 0 1 0 1.5h-3.25a.75.75 0 0 1-.75-.75V4.75a.75.75 0 0 1 1.5 0z"/></svg>
<a href="#">Activity</a>
</li>
<li>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.67-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2zM8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5zM8 6a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>
<a href="#">7 watching</a>
</li>
<li>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25z"/></svg>
<a href="#">142 stars</a>
</li>
<li>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0z"/></svg>
<a href="#">23 forks</a>
</li>
</ul>
</section>
<hr class="divider">
<section class="side-section">
<h3>Releases <span style="color:var(--text-mute);font-weight:500;margin-left:auto">4</span></h3>
<div class="release">
<div class="tag-icon">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775zM6 5a1 1 0 1 0-2 0 1 1 0 0 0 2 0z"/></svg>
</div>
<div class="release-meta">
<div class="release-title">
<a href="#">v2.4.0</a>
<span class="pill">Latest</span>
</div>
<div class="release-when">2 days ago</div>
</div>
</div>
<p style="margin:12px 0 0;color:var(--text-mute);font-size:13px">
<a href="#">+ 3 releases</a>
</p>
</section>
<hr class="divider">
<section class="side-section">
<h3>Languages</h3>
<div class="lang-bar" aria-label="Language breakdown">
<span style="width:78%;background:#00ADD8"></span>
<span style="width:14%;background:var(--text-mute)"></span>
<span style="width:8%;background:var(--green)"></span>
</div>
<div class="lang-key">
<span><span class="dot" style="background:#00ADD8"></span>Go<span class="pct"> 78.2%</span></span>
<span><span class="dot" style="background:var(--text-mute)"></span>Shell<span class="pct"> 13.7%</span></span>
<span><span class="dot" style="background:var(--green)"></span>Makefile<span class="pct"> 8.1%</span></span>
</div>
</section>
</aside>
</div>
<!-- ── Footer ─────────────────────────────────────────────────── -->
<footer class="footer">
<span>Powered by Forgejo · kBenestad theme</span>
<span class="spacer"></span>
<a href="#">English</a>
<a href="#">Licenses</a>
<a href="#">API</a>
<a href="#">Source</a>
</footer>
</div>
<script>
// Theme toggle ↔ localStorage. Order: stored → OS → light.
(function () {
var root = document.documentElement;
var btn = document.getElementById('themeToggle');
var lbl = document.getElementById('themeLabel');
var ico = document.getElementById('themeIcon');
var SUN = '<path d="M8 12.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zM8 0a.5.5 0 0 1 .5.5v1.5a.5.5 0 0 1-1 0V.5A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5V15a.5.5 0 0 1-1 0v-1.5A.5.5 0 0 1 8 13zM3.05 2.343a.5.5 0 0 1 .707 0l1.06 1.06a.5.5 0 1 1-.707.708L3.05 3.05a.5.5 0 0 1 0-.707zm8.486 8.486a.5.5 0 0 1 .707 0l1.06 1.06a.5.5 0 1 1-.707.708l-1.06-1.06a.5.5 0 0 1 0-.708zM0 8a.5.5 0 0 1 .5-.5H2a.5.5 0 0 1 0 1H.5A.5.5 0 0 1 0 8zm13 0a.5.5 0 0 1 .5-.5H15a.5.5 0 0 1 0 1h-1.5A.5.5 0 0 1 13 8zM2.343 12.95a.5.5 0 0 1 0-.707l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.5.5 0 0 1-.708 0zm8.486-8.486a.5.5 0 0 1 0-.707l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.5.5 0 0 1-.708 0z"/>';
var MOON = '<path d="M6 .278a.77.77 0 0 1 .08.858 7.2 7.2 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.78.78 0 0 1 .81.316.73.73 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.78.78 0 0 1 6 .278z"/>';
function apply(t) {
root.setAttribute('data-theme', t);
lbl.textContent = t === 'dark' ? 'Dark' : 'Light';
ico.innerHTML = t === 'dark' ? MOON : SUN;
}
var stored = null;
try { stored = localStorage.getItem('kb-theme'); } catch (e) {}
var initial = stored || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
apply(initial);
btn.addEventListener('click', function () {
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
apply(next);
try { localStorage.setItem('kb-theme', next); } catch (e) {}
});
})();
</script>
</body>
</html>

View file

@ -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; }

View file

@ -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; }

View file

@ -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; }

View file

@ -0,0 +1,133 @@
<!-- @dsCard group="gitxt" viewport="900x620" name="gitxt teletext" subtitle="Teletext-for-git — type a page number or use the FASTEXT bar" -->
<!-- @startingPoint section="gitxt" subtitle="Teletext terminal with 3-digit page navigation" viewport="900x620" -->
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>gitxt</title>
<link rel="stylesheet" href="../../styles.css">
<style>
html, body { height: 100%; }
body { background: #07090c; display: grid; place-items: center; padding: 28px; }
.tx-stage { width: min(720px, 100%); }
.tx-screen {
background: #000; border-radius: 10px; padding: 26px 30px 22px;
font-family: var(--font-mono); font-weight: 500;
box-shadow: 0 0 0 2px #1a1f26, 0 30px 80px rgba(0,0,0,.7);
position: relative; overflow: hidden;
}
/* faint scanlines */
.tx-screen::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(to bottom, rgba(255,255,255,.025) 0 1px, transparent 1px 3px);
mix-blend-mode: overlay;
}
.tx-statusbar {
display: flex; justify-content: space-between; align-items: center;
font-size: 16px; color: #e8ecf0; letter-spacing: .5px; margin-bottom: 18px;
}
.tx-statusbar .pg { color: #3fe0e0; font-weight: 600; }
.tx-statusbar .svc { color: #f2d44a; }
.tx-statusbar .clk { color: #5fd28a; font-variant-numeric: tabular-nums; }
.tx-body { min-height: 340px; }
.tx-row { font-size: 19px; line-height: 1.5; letter-spacing: .4px; color: #e8ecf0; white-space: pre-wrap; }
.tx-title { font-size: 40px; font-weight: 800; line-height: 1.05; letter-spacing: 1px; margin: 2px 0 8px; text-transform: lowercase; }
.tx-link { cursor: pointer; }
.tx-link:hover { background: rgba(255,255,255,.12); }
.tx-notfound { color: #ff6b6b; }
.tx-fastext { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 18px; }
.tx-fx { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 5px;
cursor: pointer; color: #000; font-size: 14px; }
.tx-fx b { font-size: 13px; font-weight: 700; }
.tx-fx span { font-size: 12px; opacity: .8; }
.tx-fx:hover { filter: brightness(1.12); }
.tx-fx--red { background: #ff6b6b; }
.tx-fx--green { background: #5fd28a; }
.tx-fx--yellow { background: #f2d44a; }
.tx-fx--cyan { background: #3fe0e0; }
.tx-hint { text-align: center; margin-top: 16px; font-family: var(--font-mono); font-size: 12px; color: #5a6470; letter-spacing: .5px; }
.tx-hint kbd { color: #aab2bd; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="pages.jsx"></script>
<script type="text/babel">
const { PAGES, useState, useEffect } = window;
const FX_COLORS = ['red', 'green', 'yellow', 'cyan'];
const FX_WORDS = { 100: 'index', 200: 'repos', 300: 'commits', 400: 'issues', 500: 'builds', 888: 'help', 210: 'kbpkg', 220: 'gitxt', 310: 'log' };
function Clock() {
const [t, setT] = useState('');
useEffect(() => {
const fmt = () => {
const d = new Date();
const day = d.toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' });
const tm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
setT(day + ' ' + tm);
};
fmt(); const id = setInterval(fmt, 1000); return () => clearInterval(id);
}, []);
return <span className="clk">{t}</span>;
}
function App() {
const [page, setPage] = useState(100);
const [buf, setBuf] = useState('');
const [notFound, setNotFound] = useState(false);
const go = (n) => { if (PAGES[n]) { setPage(n); setBuf(''); setNotFound(false); } else { setNotFound(true); setTimeout(() => setNotFound(false), 1200); } };
useEffect(() => {
const onKey = (e) => {
if (/^[0-9]$/.test(e.key)) {
setBuf(prev => {
const next = (prev + e.key).slice(0, 3);
if (next.length === 3) { const n = parseInt(next, 10); setTimeout(() => go(n), 250); }
return next;
});
} else if (e.key === 'Backspace') { setBuf(prev => prev.slice(0, -1)); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const pageDisp = buf ? ('P' + (buf + '___').slice(0, 3)) : ('P' + page);
const fast = (PAGES[page].fast) || [100, 200, 300, 100];
return (
<div className="tx-stage">
<div className="tx-screen">
<div className="tx-statusbar">
<span className="pg">{pageDisp}</span>
<span className="svc">gitxt</span>
<Clock />
</div>
<div className="tx-body">
{notFound ? <div className="tx-row tx-notfound">page not found — try 100</div> : PAGES[page].render(go)}
</div>
<div className="tx-fastext">
{fast.map((n, i) => (
<div key={i} className={'tx-fx tx-fx--' + FX_COLORS[i]} onClick={() => go(n)}>
<b>{n}</b><span>{FX_WORDS[n] || 'page'}</span>
</div>
))}
</div>
</div>
<div className="tx-hint">type a page number (e.g. <kbd>300</kbd>) or click a coloured button · <kbd>100</kbd> for index</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

128
dev/ui_kits/gitxt/pages.jsx Normal file
View file

@ -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 }) => <span style={{ color: '#3fe0e0' }}>{children}</span>; // cyan
const Y = ({ children }) => <span style={{ color: '#f2d44a' }}>{children}</span>; // yellow
const G = ({ children }) => <span style={{ color: '#5fd28a' }}>{children}</span>; // green
const R = ({ children }) => <span style={{ color: '#ff6b6b' }}>{children}</span>; // red
const M = ({ children }) => <span style={{ color: '#e07ad0' }}>{children}</span>; // magenta
const W = ({ children }) => <span style={{ color: '#e8ecf0' }}>{children}</span>; // white
const Row = ({ children, center }) => (
<div className="tx-row" style={center ? { textAlign: 'center' } : null}>{children || '\u00A0'}</div>
);
// double-height title block
const Title = ({ color = '#3fe0e0', children }) => (
<div className="tx-title" style={{ color }}>{children}</div>
);
const Link = ({ n, go, children }) => (
<span className="tx-link" onClick={() => go(n)}><G>{n}</G> <W>{children}</W></span>
);
const PAGES = {
100: { fast: [200, 300, 400, 100], render: (go) => (<>
<Title>gitxt</Title>
<Row><W>teletext for git</W> · <C>kBenestad</C></Row>
<Row />
<Row><Y> index </Y></Row>
<Row />
<Row><Link n={200} go={go}>repositories</Link></Row>
<Row><Link n={300} go={go}>recent commits</Link></Row>
<Row><Link n={400} go={go}>open issues</Link></Row>
<Row><Link n={500} go={go}>build status</Link></Row>
<Row><Link n={888} go={go}>help &amp; navigation</Link></Row>
<Row />
<Row><W>type a page number, or use the</W></Row>
<Row><W>coloured buttons below.</W></Row>
</>) },
200: { fast: [210, 220, 300, 100], render: (go) => (<>
<Title color="#f2d44a">repositories</Title>
<Row><C>page 200</C> · <W>8 repos tracked</W></Row>
<Row />
<Row><Link n={210} go={go}>kbpkg&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</Link> <G>v2.4.0</G></Row>
<Row><Link n={220} go={go}>gitxt&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</Link> <G>v0.3.0</G></Row>
<Row><Link n={230} go={go}>mdcms&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</Link> <Y>v0.6.1</Y></Row>
<Row><Link n={240} go={go}>capcms&nbsp;&nbsp;&nbsp;&nbsp;</Link> <Y>v0.2.0</Y></Row>
<Row><Link n={250} go={go}>invoice&nbsp;&nbsp;&nbsp;</Link> <G>v1.1.0</G></Row>
<Row><Link n={260} go={go}>timesheet&nbsp;</Link> <G>v1.0.0</G></Row>
<Row />
<Row><W>select a repo for detail.</W></Row>
</>) },
210: { fast: [300, 400, 200, 100], render: (go) => (<>
<Title>kbpkg</Title>
<Row><C>repo 210</C> · <G>v2.4.0</G> · <W>main</W></Row>
<Row />
<Row><Y>git-based package manager</Y></Row>
<Row />
<Row><W>branch&nbsp;&nbsp;</W><G>main</G><W>&nbsp;&nbsp;0 0</W></Row>
<Row><W>commits&nbsp;</W><C>1,284</C></Row>
<Row><W>open&nbsp;&nbsp;&nbsp;&nbsp;</W><R>3 issues</R></Row>
<Row><W>build&nbsp;&nbsp;&nbsp;</W><G> passing</G></Row>
<Row />
<Row><M>last:</M> <W>fix: resolve nested deps</W></Row>
<Row><W>by karl · 1 day ago</W></Row>
</>) },
300: { fast: [310, 200, 400, 100], render: (go) => (<>
<Title color="#5fd28a">recent commits</Title>
<Row><C>page 300</C> · <W>all repos</W></Row>
<Row />
<Row><Y>a3f1</Y> <W>fix: resolve nested deps</W></Row>
<Row><W>kbpkg · 1d</W></Row>
<Row><Y>9c02</Y> <W>feat: number navigation</W></Row>
<Row><W>gitxt · 2d</W></Row>
<Row><Y>1e7d</Y> <W>chore: bump cms/md</W></Row>
<Row><W>mdcms · 4d</W></Row>
<Row><Y>b840</Y> <W>fix: locale rounding</W></Row>
<Row><W>invoice · 5d</W></Row>
</>) },
400: { fast: [200, 300, 500, 100], render: (go) => (<>
<Title color="#ff6b6b">open issues</Title>
<Row><C>page 400</C> · <W>6 open</W></Row>
<Row />
<Row><R>#42</R> <W>resolve circular dep graph</W></Row>
<Row><W>kbpkg · high</W></Row>
<Row><R>#38</R> <W>page 9xx reserved range</W></Row>
<Row><W>gitxt · low</W></Row>
<Row><R>#31</R> <W>fr-NO plural forms</W></Row>
<Row><W>mdcms · medium</W></Row>
</>) },
500: { fast: [200, 300, 400, 100], render: (go) => (<>
<Title color="#5fd28a">build status</Title>
<Row><C>page 500</C> · <W>last 24h</W></Row>
<Row />
<Row><G> passing</G><W>&nbsp;&nbsp;kbpkg</W></Row>
<Row><G> passing</G><W>&nbsp;&nbsp;gitxt</W></Row>
<Row><G> passing</G><W>&nbsp;&nbsp;invoice</W></Row>
<Row><Y> pending</Y><W>&nbsp;&nbsp;mdcms</W></Row>
<Row><R> failing</R><W>&nbsp;&nbsp;capcms</W></Row>
<Row />
<Row><W>capcms: test timeout in</W></Row>
<Row><W>case-export suite.</W></Row>
</>) },
888: { fast: [100, 200, 300, 100], render: (go) => (<>
<Title color="#e07ad0">help</Title>
<Row><C>page 888</C></Row>
<Row />
<Row><W>type any 3-digit page number</W></Row>
<Row><W>to jump straight to it.</W></Row>
<Row />
<Row><G>100</G> <W>index</W></Row>
<Row><G>200</G> <W>repositories</W></Row>
<Row><G>300</G> <W>commits</W></Row>
<Row />
<Row><W>coloured buttons jump to the</W></Row>
<Row><W>four pages shown at the foot.</W></Row>
</>) },
};
// 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 });

152
dev/ui_kits/invoice/app.jsx Normal file
View file

@ -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 (
<aside className="iv-side">
<div className="iv-brand">
<svg width="24" height="24" viewBox="0 0 48 48" fill="none">
<rect x="16" y="8" width="24" height="24" rx="6" stroke="var(--accent)" strokeWidth="3" opacity=".34" />
<rect x="8" y="16" width="24" height="24" rx="6" fill="var(--accent)" />
</svg>
<span><span style={{ color: 'var(--accent)' }}>k</span>Benestad</span>
</div>
<div className="iv-appname">invoice</div>
<nav className="iv-nav">
{nav.map(n => (
<a key={n.label} className={'iv-navitem' + (n.active ? ' is-active' : '')}>
<Icon name={n.icon} size={17} /> {n.label}
</a>
))}
</nav>
<div className="iv-switch">
<div className="iv-switch__label">kBenestad apps</div>
<div className="iv-switch__tiles">
{apps.map(([k, label, on]) => (
<div key={k} className={'iv-tile' + (on ? ' is-on' : '')} title={label}>{k}</div>
))}
</div>
</div>
</aside>
);
}
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 (
<div className="iv-content">
<div className="iv-topbar">
<h1>Invoices</h1>
<Button leftIcon={<Icon name="plus" size={16} />}>New invoice</Button>
</div>
<div className="iv-stats">
<div className="iv-stat"><span className="iv-stat__k">Outstanding</span><span className="iv-stat__v">kr {kr(outstanding)}</span></div>
<div className="iv-stat"><span className="iv-stat__k">Paid this period</span><span className="iv-stat__v">kr {kr(paid)}</span></div>
<div className="iv-stat"><span className="iv-stat__k">Open invoices</span><span className="iv-stat__v">{invoices.filter(i => i.status !== 'paid' && i.status !== 'draft').length}</span></div>
</div>
<div className="iv-search">
<Icon name="search" size={16} style={{ color: 'var(--text-muted)' }} />
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search invoices or clients…" />
</div>
<div className="kb-card" style={{ overflow: 'hidden' }}>
<table className="iv-table">
<thead>
<tr><th>Invoice</th><th>Client</th><th>Status</th><th>Due</th><th className="num">Amount</th><th></th></tr>
</thead>
<tbody>
{list.map(inv => (
<tr key={inv.id} onClick={() => onOpen(inv)}>
<td className="mono">{inv.id}</td>
<td>{inv.client}</td>
<td><StatusBadge status={inv.status} /></td>
<td className="muted">{inv.due}</td>
<td className="num mono">kr {kr(inv.amount)}</td>
<td className="chev"><Icon name="chevron" size={15} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
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 (
<div className="iv-content">
<div className="iv-topbar">
<button className="iv-back" onClick={onBack}><Icon name="back" size={16} /> Invoices</button>
<div style={{ display: 'flex', gap: 10 }}>
{inv.status !== 'paid' && <Button variant="secondary" leftIcon={<Icon name="check" size={16} />}>Mark paid</Button>}
<Button variant="secondary" leftIcon={<Icon name="download" size={16} />}>PDF</Button>
{inv.status === 'draft'
? <Button leftIcon={<Icon name="send" size={16} />}>Send</Button>
: <Button leftIcon={<Icon name="send" size={16} />}>Resend</Button>}
</div>
</div>
<div className="iv-sheet kb-card kb-card--raised">
<div className="iv-sheet__head">
<div>
<div className="iv-sheet__no">{inv.id}</div>
<StatusBadge status={inv.status} />
</div>
<div className="iv-sheet__from">
<strong>{from.org}</strong>
<span>{from.name}</span>
<span>{from.email}</span>
<span className="muted">{from.orgnr}</span>
</div>
</div>
<div className="iv-sheet__parties">
<div><span className="kb-eyebrow">Billed to</span><div className="iv-party">{inv.client}</div></div>
<div className="iv-dates">
<div><span className="kb-eyebrow">Issued</span><div>{inv.issued}</div></div>
<div><span className="kb-eyebrow">Due</span><div>{inv.due}</div></div>
</div>
</div>
<table className="iv-lines">
<thead><tr><th>Description</th><th className="num">Qty</th><th className="num">Rate</th><th className="num">Amount</th></tr></thead>
<tbody>
{inv.items.map(([desc, qty, rate], i) => (
<tr key={i}><td>{desc}</td><td className="num mono">{qty}</td><td className="num mono">kr {kr(rate)}</td><td className="num mono">kr {kr(qty * rate)}</td></tr>
))}
</tbody>
</table>
<div className="iv-totals">
<div className="iv-totrow"><span>Subtotal</span><span className="mono">kr {kr(subtotal)}</span></div>
<div className="iv-totrow"><span>VAT 25%</span><span className="mono">kr {kr(vat)}</span></div>
<div className="iv-totrow iv-totrow--grand"><span>Total</span><span className="mono">kr {kr(total)}</span></div>
</div>
</div>
</div>
);
}
function App() {
const [inv, setInv] = useState(null);
return (
<div className="iv-app">
<Sidebar />
<main className="iv-main">
{inv ? <InvoiceDetail inv={inv} onBack={() => setInv(null)} /> : <InvoiceList onOpen={setInv} />}
</main>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

View file

@ -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' },
};

View file

@ -0,0 +1,106 @@
<!-- @dsCard group="invoice" viewport="1280x820" name="invoice app" subtitle="Internal-tools family — invoice list & detail (shared shell for timesheet, reimburse, capcms, mdcms)" -->
<!-- @startingPoint section="Internal tools" subtitle="Sidebar app shell with list + record detail" viewport="1280x820" -->
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>invoice — kBenestad</title>
<link rel="stylesheet" href="../../styles.css">
<style>
html, body { height: 100%; }
body { background: var(--surface-page); }
.iv-app { display: grid; grid-template-columns: 248px 1fr; height: 100vh; }
/* Sidebar */
.iv-side { background: var(--surface-card); border-right: var(--border-thin) solid var(--border-subtle);
display: flex; flex-direction: column; padding: 20px 16px; }
.iv-brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 17px; letter-spacing: -0.02em;
color: var(--text-strong); padding: 0 6px; }
.iv-appname { font-family: var(--font-mono); font-size: var(--text-body-sm); color: var(--text-muted);
padding: 2px 6px 0 41px; margin-bottom: 26px; }
.iv-nav { display: flex; flex-direction: column; gap: 2px; }
.iv-navitem { display: flex; align-items: center; gap: 11px; padding: 9px 11px; border-radius: var(--radius-md);
color: var(--text-body); font-weight: var(--weight-medium); cursor: pointer; font-size: var(--text-body); }
.iv-navitem:hover { background: var(--surface-hover); text-decoration: none; }
.iv-navitem.is-active { background: var(--accent-soft); color: var(--accent-soft-text); }
.iv-switch { margin-top: auto; }
.iv-switch__label { font-size: var(--text-caption); letter-spacing: var(--tracking-caps); text-transform: uppercase;
color: var(--text-subtle); font-weight: 600; padding: 0 6px 10px; }
.iv-switch__tiles { display: flex; gap: 8px; padding: 0 4px; }
.iv-tile { width: 38px; height: 38px; border-radius: var(--radius-md); display: grid; place-items: center;
font-family: var(--font-mono); font-size: 12px; font-weight: 600; background: var(--surface-sunken);
color: var(--text-muted); border: var(--border-thin) solid var(--border-subtle); cursor: pointer; }
.iv-tile.is-on { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Main */
.iv-main { overflow-y: auto; }
.iv-content { max-width: 920px; margin: 0 auto; padding: 32px 36px 60px; }
.iv-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 26px; }
.iv-topbar h1 { font-size: var(--text-h1); font-weight: var(--weight-bold); }
.iv-back { display: inline-flex; align-items: center; gap: 6px; background: none; border: none; cursor: pointer;
font-family: var(--font-sans); font-size: var(--text-body); font-weight: var(--weight-medium); color: var(--text-muted); padding: 0; }
.iv-back:hover { color: var(--text-strong); }
/* Stats */
.iv-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 22px; }
.iv-stat { background: var(--surface-card); border: var(--border-thin) solid var(--border-subtle); border-radius: var(--radius-lg);
padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; }
.iv-stat__k { font-size: var(--text-body-sm); color: var(--text-muted); }
.iv-stat__v { font-size: var(--text-h3); font-weight: var(--weight-bold); color: var(--text-strong); font-variant-numeric: tabular-nums; }
/* Search */
.iv-search { display: flex; align-items: center; gap: 10px; padding: 0 14px; height: 42px; margin-bottom: 18px;
background: var(--surface-card); border: var(--border-thin) solid var(--border-default); border-radius: var(--radius-md); }
.iv-search:focus-within { border-color: var(--border-focus); box-shadow: var(--focus-ring); }
.iv-search input { flex: 1; border: none; outline: none; background: none; font-family: var(--font-sans); font-size: var(--text-body); color: var(--text-strong); }
/* Table */
.iv-table { width: 100%; border-collapse: collapse; }
.iv-table th { text-align: left; font-size: var(--text-caption); letter-spacing: var(--tracking-caps); text-transform: uppercase;
color: var(--text-muted); font-weight: 600; padding: 12px 16px; border-bottom: var(--border-thin) solid var(--border-subtle); }
.iv-table td { padding: 14px 16px; border-bottom: var(--border-thin) solid var(--border-subtle); font-size: var(--text-body); color: var(--text-body); }
.iv-table tbody tr { cursor: pointer; }
.iv-table tbody tr:hover { background: var(--surface-hover); }
.iv-table tbody tr:last-child td { border-bottom: none; }
.iv-table .num { text-align: right; }
.iv-table .mono { font-family: var(--font-mono); color: var(--text-strong); }
.iv-table .muted { color: var(--text-muted); }
.iv-table .chev { color: var(--text-subtle); width: 24px; }
/* Invoice sheet */
.iv-sheet { padding: 36px 40px; }
.iv-sheet__head { display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 26px; border-bottom: var(--border-thin) solid var(--border-subtle); }
.iv-sheet__no { font-family: var(--font-mono); font-size: var(--text-h2); font-weight: var(--weight-bold); color: var(--text-strong); margin-bottom: 10px; }
.iv-sheet__from { display: flex; flex-direction: column; gap: 2px; text-align: right; font-size: var(--text-body-sm); color: var(--text-body); }
.iv-sheet__from strong { color: var(--text-strong); font-size: var(--text-body); }
.iv-sheet__parties { display: flex; justify-content: space-between; padding: 24px 0; }
.iv-party { font-size: var(--text-body-lg); font-weight: var(--weight-semibold); color: var(--text-strong); margin-top: 6px; }
.iv-dates { display: flex; gap: 40px; text-align: right; }
.iv-dates > div > div { color: var(--text-strong); font-weight: var(--weight-medium); margin-top: 6px; }
.muted { color: var(--text-muted); }
.iv-lines { width: 100%; border-collapse: collapse; margin-top: 8px; }
.iv-lines th { text-align: left; font-size: var(--text-caption); letter-spacing: var(--tracking-caps); text-transform: uppercase;
color: var(--text-muted); font-weight: 600; padding: 10px 0; border-bottom: var(--border-medium) solid var(--border-default); }
.iv-lines td { padding: 13px 0; border-bottom: var(--border-thin) solid var(--border-subtle); font-size: var(--text-body); color: var(--text-body); }
.iv-lines .num { text-align: right; }
.iv-lines .mono { font-family: var(--font-mono); }
.iv-totals { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; margin-left: auto; width: 260px; }
.iv-totrow { display: flex; justify-content: space-between; font-size: var(--text-body); color: var(--text-body); }
.iv-totrow--grand { border-top: var(--border-medium) solid var(--border-default); padding-top: 12px; margin-top: 4px;
font-size: var(--text-h4); font-weight: var(--weight-bold); color: var(--text-strong); }
@media (max-width: 760px) { .iv-app { grid-template-columns: 1fr; } .iv-side { display: none; } }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.js"></script>
<script type="text/babel" src="ui.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

View file

@ -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 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, ...style }}><path d={ICONS[name]} /></svg>;
}
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 <button className={cls} {...rest}>{leftIcon}{children}</button>;
}
const STATUS_TONE = { paid: 'success', sent: 'accent', draft: 'neutral', overdue: 'danger' };
function StatusBadge({ status }) {
return <span className={`kb-badge kb-badge--${STATUS_TONE[status]}`}><span className="kb-badge__dot" />{status}</span>;
}
function Avatar({ name = '', size = 32, square }) {
const initials = name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
return <span className={'kb-avatar' + (square ? ' kb-avatar--square' : '')} style={{ width: size, height: size, fontSize: size * 0.38 }}>{initials}</span>;
}
const kr = (n) => n.toLocaleString('nb-NO');
Object.assign(window, { Icon, Button, StatusBadge, Avatar, kr, useState });

57
dev/ui_kits/kbpkg/data.js Normal file
View file

@ -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']],
},
];

View file

@ -0,0 +1,118 @@
<!-- @dsCard group="kbpkg" viewport="1280x860" name="kbpkg registry" subtitle="Flagship — git-based package registry & package pages" -->
<!-- @startingPoint section="kbpkg" subtitle="Package registry home with search & package detail" viewport="1280x860" -->
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>kbpkg — registry</title>
<link rel="stylesheet" href="../../styles.css">
<style>
body { background: var(--surface-page); color: var(--text-body); }
/* Header */
.kb-header { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 32px;
height: 60px; padding: 0 28px; background: color-mix(in srgb, var(--surface-card) 88%, transparent);
backdrop-filter: saturate(1.1) blur(8px); border-bottom: var(--border-thin) solid var(--border-subtle); }
.kb-nav { display: flex; gap: 4px; flex: 1; }
.kb-nav__item { font-size: var(--text-body); font-weight: var(--weight-medium); color: var(--text-muted);
padding: 6px 10px; border-radius: var(--radius-sm); cursor: pointer; }
.kb-nav__item:hover { color: var(--text-strong); background: var(--surface-hover); text-decoration: none; }
.kb-nav__item.is-active { color: var(--text-strong); }
/* Page shell */
.kb-page { max-width: 920px; margin: 0 auto; padding: 40px 28px 80px; }
/* Hero */
.kb-hero { margin-bottom: 36px; }
.kb-hero__title { font-size: var(--text-h1); font-weight: var(--weight-bold); margin: 10px 0 8px; }
.kb-hero__sub { font-size: var(--text-body-lg); color: var(--text-muted); max-width: 52ch; }
.kb-search { display: flex; align-items: center; gap: 10px; margin-top: 24px; padding: 0 16px; height: 48px;
background: var(--surface-card); border: var(--border-thin) solid var(--border-default);
border-radius: var(--radius-lg); transition: border-color var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) var(--ease-out); }
.kb-search:focus-within { border-color: var(--border-focus); box-shadow: var(--focus-ring); }
.kb-search input { flex: 1; border: none; outline: none; background: none; font-family: var(--font-sans);
font-size: var(--text-body-lg); color: var(--text-strong); }
.kb-search input::placeholder { color: var(--text-subtle); }
.kb-filters { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
/* List */
.kb-listhead { display: flex; justify-content: space-between; font-size: var(--text-body-sm);
color: var(--text-muted); padding: 0 4px 12px; border-bottom: var(--border-thin) solid var(--border-subtle); }
.kb-list { display: flex; flex-direction: column; }
.kb-pkg { display: flex; gap: 16px; padding: 20px 16px; border-bottom: var(--border-thin) solid var(--border-subtle);
cursor: pointer; border-radius: var(--radius-md); transition: background var(--duration-fast) var(--ease-out); margin: 0 -16px; }
.kb-pkg:hover { background: var(--surface-card); }
.kb-pkg__body { flex: 1; min-width: 0; }
.kb-pkg__top { display: flex; align-items: center; gap: 10px; }
.kb-pkg__name { font-family: var(--font-mono); font-weight: var(--weight-semibold); font-size: var(--text-body-lg); color: var(--text-strong); }
.kb-pkg__desc { font-size: var(--text-body); color: var(--text-body); margin: 4px 0 10px; }
.kb-pkg__tags { display: flex; gap: 7px; flex-wrap: wrap; }
.kb-pkg__meta { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; font-size: var(--text-body-sm); color: var(--text-muted); white-space: nowrap; }
.kb-pkg__meta span { display: inline-flex; align-items: center; gap: 5px; }
.kb-empty { padding: 40px 0; text-align: center; color: var(--text-muted); }
/* Install command */
.kb-cmd { display: flex; align-items: center; gap: 12px; background: var(--term-bg); border-radius: var(--radius-md);
padding: 0 8px 0 18px; height: 50px; margin: 22px 0 28px; }
.kb-cmd__prompt { color: var(--term-green); font-family: var(--font-mono); }
.kb-cmd__text { flex: 1; color: var(--term-fg); font-family: var(--font-mono); font-size: var(--text-mono); }
.kb-cmd__copy { display: inline-flex; align-items: center; gap: 6px; background: rgba(255,255,255,.08); color: var(--term-fg);
border: none; border-radius: var(--radius-sm); padding: 8px 12px; font-family: var(--font-sans); font-size: var(--text-body-sm);
font-weight: var(--weight-medium); cursor: pointer; }
.kb-cmd__copy:hover { background: rgba(255,255,255,.16); }
/* Package detail */
.kb-crumb { display: flex; align-items: center; gap: 8px; font-size: var(--text-body-sm); color: var(--text-muted); margin-bottom: 20px; }
.kb-crumb a { color: var(--text-muted); cursor: pointer; }
.kb-crumb a:hover { color: var(--text-strong); text-decoration: none; }
.kb-pkghead { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; }
.kb-pkghead__name { font-family: var(--font-mono); font-size: var(--text-h2); font-weight: var(--weight-bold); display: flex; align-items: center; gap: 12px; }
.kb-pkghead__desc { font-size: var(--text-body-lg); color: var(--text-muted); margin-top: 8px; max-width: 56ch; }
.kb-cols { display: grid; grid-template-columns: 1fr 280px; gap: 36px; }
.kb-main { min-width: 0; }
.kb-tabpanel { padding-top: 24px; }
.kb-prose h3 { font-family: var(--font-mono); font-size: var(--text-h4); margin-bottom: 12px; }
.kb-prose p { color: var(--text-body); line-height: var(--leading-relaxed); margin-bottom: 14px; max-width: 64ch; }
.kb-versions { display: flex; flex-direction: column; }
.kb-version { display: flex; justify-content: space-between; align-items: center; padding: 13px 6px;
border-bottom: var(--border-thin) solid var(--border-subtle); }
.kb-version__v { font-weight: var(--weight-semibold); color: var(--text-strong); display: inline-flex; align-items: center; gap: 10px; }
.kb-version__when { font-size: var(--text-body-sm); color: var(--text-muted); }
.kb-side { display: flex; flex-direction: column; gap: 18px; }
.kb-meta { display: flex; flex-direction: column; gap: 0; border: var(--border-thin) solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; }
.kb-meta__row { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; font-size: var(--text-body-sm);
border-bottom: var(--border-thin) solid var(--border-subtle); }
.kb-meta__row:last-child { border-bottom: none; }
.kb-meta__label { display: inline-flex; align-items: center; gap: 8px; color: var(--text-muted); }
.kb-meta__val { color: var(--text-strong); font-weight: var(--weight-medium); white-space: nowrap; }
.kb-link { color: var(--text-link); display: inline-flex; align-items: center; gap: 5px; }
@media (max-width: 760px) { .kb-cols { grid-template-columns: 1fr; } .kb-nav { display: none; } }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.js"></script>
<script type="text/babel" src="ui.jsx"></script>
<script type="text/babel" src="screens.jsx"></script>
<script type="text/babel">
const { Header, Registry, PackagePage, useState } = window;
function App() {
const [pkg, setPkg] = useState(null);
const open = (p) => { setPkg(p); window.scrollTo(0, 0); };
const home = () => { setPkg(null); window.scrollTo(0, 0); };
return (
<div>
<Header onHome={home} />
{pkg ? <PackagePage pkg={pkg} onHome={home} onOpen={open} /> : <Registry onOpen={open} />}
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

View file

@ -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 (
<header className="kb-header">
<Lockup onClick={onHome} />
<nav className="kb-nav">
{nav.map((n, i) => (
<a key={n} className={'kb-nav__item' + (i === 0 ? ' is-active' : '')} onClick={onHome}>{n}</a>
))}
</nav>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button variant="secondary" size="sm" leftIcon={<Icon name="plus" size={15} />}>Publish</Button>
<Avatar name="Karl Benestad" size={30} />
</div>
</header>
);
}
function CopyCommand({ id }) {
const [copied, setCopied] = useState(false);
const cmd = `kbpkg add ${id}`;
const copy = () => { setCopied(true); setTimeout(() => setCopied(false), 1400); };
return (
<div className="kb-cmd">
<span className="kb-cmd__prompt">$</span>
<code className="kb-cmd__text">{cmd}</code>
<button className="kb-cmd__copy" onClick={copy} aria-label="Copy command">
<Icon name={copied ? 'check' : 'copy'} size={15} />
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
);
}
function PackageRow({ pkg, onOpen }) {
return (
<div className="kb-pkg" onClick={() => onOpen(pkg)}>
<span className="kb-avatar kb-avatar--square" style={{ width: 40, height: 40, background: 'var(--accent-soft)', color: 'var(--accent)' }}>
<Icon name="box" size={20} />
</span>
<div className="kb-pkg__body">
<div className="kb-pkg__top">
<span className="kb-pkg__name">{pkg.name}</span>
<Badge tone="accent">v{pkg.version}</Badge>
</div>
<p className="kb-pkg__desc">{pkg.desc}</p>
<div className="kb-pkg__tags">{pkg.tags.map(t => <Tag key={t}>{t}</Tag>)}</div>
</div>
<div className="kb-pkg__meta">
<span><Icon name="download" size={14} /> {pkg.installs}</span>
<span><Icon name="clock" size={14} /> {pkg.updated}</span>
</div>
</div>
);
}
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 (
<div className="kb-page">
<div className="kb-hero">
<span className="kb-eyebrow">kbpkg registry</span>
<h1 className="kb-hero__title">A package manager for me.</h1>
<p className="kb-hero__sub">Git-based packages for the kBenestad apps. Install anything with one command.</p>
<div className="kb-search">
<Icon name="search" size={18} style={{ color: 'var(--text-muted)' }} />
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search packages…" />
</div>
<div className="kb-filters">
<Tag onClick={() => setFilter(null)} active={!filter}>all</Tag>
{tags.map(t => <Tag key={t} onClick={() => setFilter(t === filter ? null : t)} active={t === filter}>{t}</Tag>)}
</div>
</div>
<div className="kb-listhead">
<span>{list.length} package{list.length === 1 ? '' : 's'}</span>
<span>sorted by recently updated</span>
</div>
<div className="kb-list">
{list.map(p => <PackageRow key={p.id} pkg={p} onOpen={onOpen} />)}
{list.length === 0 && <div className="kb-empty">No packages match {q}.</div>}
</div>
</div>
);
}
function MetaRow({ icon, label, children }) {
return (
<div className="kb-meta__row">
<span className="kb-meta__label"><Icon name={icon} size={15} /> {label}</span>
<span className="kb-meta__val">{children}</span>
</div>
);
}
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 (
<div className="kb-page">
<div className="kb-crumb"><a onClick={onHome}>Packages</a><Icon name="chevronR" size={14} /><span>{pkg.name}</span></div>
<div className="kb-pkghead">
<div>
<h1 className="kb-pkghead__name">{pkg.name} <Badge tone="accent">v{pkg.version}</Badge></h1>
<p className="kb-pkghead__desc">{pkg.desc}</p>
</div>
<Badge tone="success" dot>published</Badge>
</div>
<CopyCommand id={pkg.id} />
<div className="kb-cols">
<div className="kb-main">
<div className="kb-tabs">
{tabs.map(t => <button key={t.id} className="kb-tab" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}>{t.label}</button>)}
</div>
<div className="kb-tabpanel">
{tab === 'readme' && (
<div className="kb-prose">
<h3>{pkg.name}</h3>
<p>{pkg.readme}</p>
<p>Install with <code>kbpkg add {pkg.id}</code> and import what you need. Licensed under {pkg.license}.</p>
</div>
)}
{tab === 'versions' && (
<div className="kb-versions">
{pkg.versions.map(([v, when], i) => (
<div key={v} className="kb-version">
<span className="kb-version__v">v{v}{i === 0 && <Badge tone="success">latest</Badge>}</span>
<span className="kb-version__when">{when}</span>
</div>
))}
</div>
)}
{tab === 'deps' && (
<div className="kb-versions">
{pkg.dependencies.length === 0 && <div className="kb-empty" style={{ padding: '20px 0' }}>No dependencies this package is self-contained.</div>}
{pkg.dependencies.map(([d, range]) => {
const dp = window.KBPKG_PACKAGES.find(p => p.id === d);
return (
<div key={d} className="kb-version" style={{ cursor: dp ? 'pointer' : 'default' }} onClick={() => dp && onOpen(dp)}>
<span className="kb-version__v" style={{ fontFamily: 'var(--font-mono)' }}>{d}</span>
<span className="kb-version__when" style={{ fontFamily: 'var(--font-mono)' }}>{range}</span>
</div>
);
})}
</div>
)}
</div>
</div>
<aside className="kb-side">
<Button variant="primary" leftIcon={<Icon name="download" size={16} />} style={{ width: '100%' }}>Install</Button>
<div className="kb-meta">
<MetaRow icon="box" label="Version">v{pkg.version}</MetaRow>
<MetaRow icon="scale" label="License">{pkg.license}</MetaRow>
<MetaRow icon="drive" label="Size">{pkg.size}</MetaRow>
<MetaRow icon="layers" label="Dependencies">{pkg.deps}</MetaRow>
<MetaRow icon="branch" label="Repository"><a className="kb-link" href="#">{pkg.repo} <Icon name="external" size={13} /></a></MetaRow>
<MetaRow icon="user" label="Owner"><span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><Avatar name={pkg.owner} size={22} /> {pkg.owner}</span></MetaRow>
</div>
</aside>
</div>
</div>
);
}
Object.assign(window, { Header, Registry, PackagePage });

69
dev/ui_kits/kbpkg/ui.jsx Normal file
View file

@ -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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, ...style }}>
<path d={ICONS[name]} />
</svg>
);
}
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 <button className={cls} {...rest}>{leftIcon}{children}</button>;
}
function Badge({ tone = 'neutral', dot, children }) {
return <span className={`kb-badge kb-badge--${tone}`}>{dot && <span className="kb-badge__dot" />}{children}</span>;
}
function Tag({ children, onClick, active }) {
return <span className="kb-tag" onClick={onClick}
style={onClick ? { cursor: 'pointer', borderColor: active ? 'var(--accent)' : undefined, color: active ? 'var(--accent-soft-text)' : undefined, background: active ? 'var(--accent-soft)' : undefined } : undefined}>{children}</span>;
}
function Avatar({ name = '', size = 32, square }) {
const initials = name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
return <span className={'kb-avatar' + (square ? ' kb-avatar--square' : '')} style={{ width: size, height: size, fontSize: size * 0.4 }}>{initials}</span>;
}
// Brand lockup (stack mark + wordmark)
function Lockup({ onClick }) {
return (
<span onClick={onClick} style={{ display: 'inline-flex', alignItems: 'center', gap: 9, cursor: 'pointer' }}>
<svg width="26" height="26" viewBox="0 0 48 48" fill="none">
<rect x="16" y="8" width="24" height="24" rx="6" stroke="var(--accent)" strokeWidth="3" opacity=".34" />
<rect x="8" y="16" width="24" height="24" rx="6" fill="var(--accent)" />
</svg>
<span style={{ fontWeight: 700, fontSize: 19, letterSpacing: '-0.02em', color: 'var(--text-strong)' }}>
<span style={{ color: 'var(--accent)' }}>k</span>Benestad
</span>
</span>
);
}
Object.assign(window, { Icon, Button, Badge, Tag, Avatar, Lockup, useState });