diff --git a/DESIGN.md b/DESIGN.md index a021732..408cc95 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,27 +1,55 @@ -# kBenestad — app UI brief +# DESIGN.md — kBenestad web app design contract -**Drop this into a project's `CLAUDE.md` (or paste it to Claude Code) whenever you build or -restyle a kBenestad web app.** It is the shared visual contract: follow it and any new -screen — invoice, timesheet, reimburse, a dashboard, a settings page — will look like it -belongs to the same family. +**This file complements `CLAUDE.md`.** `CLAUDE.md` tells Claude Code *what this app is* — +its purpose, data model, stack, commands. **`DESIGN.md` governs only the surface:** layout, +type, colour, components, copy. Follow it and any new screen — a form, a dashboard, a +settings page, a CLI-style tool — will look like it belongs to the kBenestad family. -This brief tells you *how things should look and behave*. It does **not** redefine what an -app does. Keep each app's existing purpose, data model, and screens intact — only the -surface (layout, type, colour, components, copy) is governed here. +It does **not** redefine what the app does. Keep the app's behaviour, routes, and data model +intact; only the visual surface is governed here. + +This document is **app-agnostic**. The concrete examples it points you at live in the repo +under `dev/` and are exactly that — *examples* to study and fork, never code to ship as-is. --- -## 0. The one-paragraph version +## 0. Where the real design lives — read this first + +Three folders under `dev/` are your source of truth. **Read the relevant ones before +writing any UI.** Prose can drift; these files are the system. + +| Folder | What it is | How to use it | +|--------|-----------|---------------| +| **`dev/theme/`** | The canonical **token stylesheets** (`colors.css`, `typography.css`, `spacing.css`, `elevation.css`, `fonts.css`, `base.css`) **and** the **Forgejo theme** — a worked example of mapping these tokens onto a pre-existing app's variables, with light / dark / auto builds. | **Copy the token CSS into your app and `@import` it** from one stylesheet. Reference `var(--token)` everywhere. Study the Forgejo theme to see how the palette recolours an existing component system without touching its markup. | +| **`dev/ui_kits/`** | Finished, **interactive app mockups** (e.g. `gitxt`, `invoice`, `kbpkg`). The system applied end-to-end — real screens, real components, real states. | **Find the closest kit to what you're building and fork it.** Lift its component structure, class names, and interaction patterns. This is the fastest path to a high-quality, on-brand screen. | +| **`dev/mockups/`** | Reusable **component layers + static reference screens** — notably `kbenestad-forms.css` (the proven `.kb-*` form layer) plus finished `invoice`/`timesheet`/`reimburse` HTML. | For a form-driven app, **lift `kbenestad-forms.css` wholesale** rather than rebuilding inputs/cards/buttons. Use the static HTML as a layout reference. | + +### The 60-second startup ritual for a new screen +1. **Wire tokens.** Copy `dev/theme/`'s token stylesheets in; `@import` them from one app + stylesheet. Confirm `var(--surface-page)`, `var(--accent)`, `var(--font-sans)` resolve. +2. **Find the nearest example.** Form-driven → `dev/mockups/` + the `invoice` kit. + Dashboard/data → study the kits' data screens. CLI/terminal → `gitxt`. Recolouring an + existing third-party app → the **Forgejo theme** in `dev/theme/` is your template. +3. **Fork, don't reinvent.** Start from that example's structure and adapt it to this app's + data. Only build net-new when nothing fits. +4. **Check it against §11 (Do / Don't)** before you call it done. + +> If `dev/theme/` is missing the token files, they come from the kBenestad design system's +> `styles.css` closure — copy that closure in. Never hardcode a hex that a token already names. + +--- + +## 1. The one-paragraph version Nordic-minimal, light-first, calm. A cool off-white page, pure-white cards held by 1px hairline borders, near-black cool ink, and **one** blue accent (`#2f6fed`). Schibsted -Grotesk for everything; JetBrains Mono only for numbers, code, and identifiers. 4px -spacing grid, small deliberate radii (8px workhorse), soft shadows used sparingly. No -gradients, no emoji, no bounce. Sentence case. Borders do the structural work; shadow only -when something genuinely floats. Categorical data is coloured with a muted, config-driven -palette (chip + left border + optional row tint), never neon. +Grotesk for everything; JetBrains Mono only for numbers, code, and identifiers. 4px spacing +grid, small deliberate radii (8px workhorse), soft shadows used sparingly. No gradients, no +emoji, no bounce. Sentence case. Borders do the structural work; shadow only when something +genuinely floats. Categorical data is coloured with a muted, config-driven palette (chip + +left border + optional row tint), never neon. -If you only remember five rules: +**If you only remember five rules:** 1. **One accent.** Blue is the only brand colour. Semantic hues are muted and earn their place. 2. **Borders first, shadow last.** Most surfaces have no shadow. 3. **Forms are row-major** (see §6). Never lay a form out as side-by-side column stacks. @@ -30,36 +58,11 @@ If you only remember five rules: --- -## 1. Setup — wire in the design system - -All tokens are CSS custom properties. Pull them in once, then reference `var(--token)` -everywhere. The canonical source is the kBenestad design system's `styles.css` closure -(`tokens/colors.css`, `typography.css`, `spacing.css`, `elevation.css`, `fonts.css`, -`base.css`). Copy those into the app and `@import` them from a single stylesheet, or copy -the token blocks inline. - -**Fonts** — Schibsted Grotesk (400–800) + JetBrains Mono (400–600). Load from a webfont host -with a `local()` first and a full system fallback stack so the app still renders offline: - -```css ---font-sans: 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; ---font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; -``` - -**Dark mode** — support both. Honour the OS by default and allow a manual override: -`@media (prefers-color-scheme: dark)` *and* `:root[data-theme="dark"]` both flip the same -tokens. Never hardcode a hex that won't invert — always go through a semantic token. - -**For form-driven apps specifically:** `mockups/kbenestad-forms.css` is a finished, -proven component layer (the `.kb-*` classes used below) built on exactly these tokens. Lift -it wholesale rather than rebuilding inputs/cards/buttons from scratch. - ---- - ## 2. Colour tokens -Reference the **semantic aliases**, not the raw ramps. The raw ramps exist so the aliases -have something to point at; your code should almost never name a `--blue-500` directly. +All colour comes from `dev/theme/colors.css`. Reference the **semantic aliases**, not the +raw ramps — the ramps exist so the aliases have something to point at; your code should +almost never name a `--blue-500` directly. **Surfaces** — `--surface-page` (cool off-white ground), `--surface-card` (pure white), `--surface-sunken`, `--surface-raised`, `--surface-hover`, `--surface-active`, @@ -80,20 +83,32 @@ variants as fills, the solid as border/text. **Focus** — `--focus-ring` (a 3px soft-blue ring). Always visible via `:focus-visible`. -**Terminal** (CLI / gitxt only) — `--term-bg`, `--term-fg`, `--term-dim`, `--term-accent`, -`--term-green`, `--term-amber`. Don't use these in normal app UI. +**Terminal** (CLI / `gitxt`-style only) — `--term-bg`, `--term-fg`, `--term-dim`, +`--term-accent`, `--term-green`, `--term-amber`. Don't use these in normal app UI. > Light values you'll see most: page `#f8f9fb`, card `#ffffff`, hairline `#e7eaef`, ink > `#14181e`, accent `#2f6fed` / hover `#1f57cf`. Dark inverts onto `#0d1117` page / > `#161b22` card with a lighter accent for contrast. +**Dark mode** — support both. Honour the OS by default *and* allow a manual override: +`@media (prefers-color-scheme: dark)` and `:root[data-theme="dark"]` flip the same tokens. +The Forgejo theme in `dev/theme/` ships light / dark / auto builds — copy that structure. +Never hardcode a hex that won't invert; always go through a semantic token. + --- ## 3. Typography -- **Families:** `--font-sans` everywhere; `--font-mono` **only** for numbers, code, paths, - package/identifier names, and tabular figures. When showing money, durations, counts, - versions, IDs — mono + `font-variant-numeric: tabular-nums`. +Loaded by `dev/theme/fonts.css`; scale defined in `dev/theme/typography.css`. + +- **Families:** `--font-sans` (Schibsted Grotesk) everywhere; `--font-mono` (JetBrains Mono) + **only** for numbers, code, paths, package/identifier names, and tabular figures. When + showing money, durations, counts, versions, IDs — mono + `font-variant-numeric: tabular-nums`. +- **Load with fallbacks** so the app renders offline: + ```css + --font-sans: 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + ``` - **Scale:** `--text-display` 52px (splash only), `--text-h1` 36, `--text-h2` 28, `--text-h3` 22, `--text-h4` 18, `--text-body-lg` 17, `--text-body` 15 (UI default), `--text-body-sm` 13, `--text-caption` 12, `--text-mono` 14. @@ -109,6 +124,8 @@ variants as fills, the solid as border/text. ## 4. Spacing, shape, elevation, motion +From `dev/theme/spacing.css` and `dev/theme/elevation.css`. + - **Spacing:** 4px base grid via `--space-1`…`--space-16` (4, 8, 12, 16, 24, 32, 40, 48, 64, 96, 128). Card padding ~24px (`--space-5`). Comfortable, not airy. - **Radii:** `--radius-xs` 3, `--radius-sm` 5 (small controls), `--radius-md` 8 (the @@ -144,21 +161,22 @@ The voice is understated, precise, practical. These are working tools, not marke - **No emoji, ever.** Status is a coloured dot, a Lucide icon, or a tinted chip. - **Errors & empty states are honest and helpful** — what happened, then the next step: "Time out must be after time in." / "No expenses yet — add your first line to begin." -- **Speak to the user as "you"**; the maker is "I/me". Avoid corporate "we". +- **Speak to the user as "you"**; keep institutional "we" out of working UI. --- -## 6. Form-driven UIs (invoice, timesheet, reimburse, settings, anything with inputs) +## 6. Form-driven UIs (anything with inputs — forms, settings, editors, document builders) -These apps are the core of the family. The rules below are what make them feel unified. +If your app collects or edits structured input, **start from `dev/mockups/kbenestad-forms.css` +and the `invoice` kit in `dev/ui_kits/`.** The rules below are what those examples encode. ### 6.1 Page shell A centred column (`--container-lg` or narrower), `--surface-page` ground, content in `--surface-card` cards with `--border-subtle` and at most `--shadow-sm`. Optional top **utility bar** (language toggle, text-size A−/A/A+, About) as small segmented controls; -then a **document header** where the *customer's* identity leads (logo tile + org name on -the left, document title + period on the right). kBenestad is the quiet signature in the -footer, never the headline. +then a **document header** where the *subject's* identity leads (logo tile + org name on the +left, title + period on the right). kBenestad is the quiet signature in the footer, never +the headline. ### 6.2 Forms are ROW-MAJOR — this is the load-bearing rule Group fields into **rows**; never build a form as two independent side-by-side column @@ -168,7 +186,8 @@ push down**, while the field beside it stays aligned to the top. This keeps labe controls in register no matter what expands. - Use a `.kb-form` / row container with span helpers (`kb-col-2`, `kb-col-3`, `kb-col-full`) - to control how many columns a field occupies within its row. + to control how many columns a field occupies within its row. (All provided in + `dev/mockups/kbenestad-forms.css`.) - Label above field. Label = eyebrow style (12px, uppercase, tracked, `--text-muted`, 600). - One thought per row; don't cram unrelated fields together to save vertical space. @@ -192,7 +211,7 @@ A header row of eyebrow labels, then data rows on a matching grid. Row hover get rows can be colour-coded (see §8). Trailing add/remove circle buttons. Subtotals and the running total use mono tabular figures. -### 6.6 Totals panel +### 6.6 Totals / summary panel Right-aligned, ~380px. Rows of `label … value` (value mono, tabular). A `grand` row on top of a `--border-strong` rule, bold, with the figure in `--accent` at ~20px. Notes/derived explanations below in muted small text. @@ -200,52 +219,53 @@ explanations below in muted small text. ### 6.7 Validation & feedback (notes / banners) A tinted note block: icon + text, `--*-soft` background with matching `--*-border` and text colour. Four kinds: error (`--danger`), warning (`--warning`), success (`--success`), info -(`--accent`/info). Blocking errors disable the primary action; warnings don't. Mirror -issues inline: tint the offending field's border and show the message next to its row. Keep +(`--accent`). Blocking errors disable the primary action; warnings don't. Mirror issues +inline: tint the offending field's border and show the message next to its row. Keep messages specific ("Description is required for OTH rows"). ### 6.8 Config-driven & white-label Wherever an app has a `config.yml` (org name, logo, accent colour, codes, categories, holidays, UI strings, language), **drive the UI from it** — don't hardcode. The accent is a single recolourable token so a customer brand can replace the kBenestad blue without -touching anything else. Strings come from the config's i18n map; support the configured -languages. +touching anything else — exactly how the Forgejo theme in `dev/theme/` swaps one palette for +another. Strings come from the config's i18n map; support the configured languages. --- ## 7. Dashboards & data-dense screens -Dashboards use the same DNA, just wider (`--container-xl` 1280px) and grid-arranged. +Same DNA, just wider (`--container-xl` 1280px) and grid-arranged. Study the data screens in +the `dev/ui_kits/` mockups for worked examples. -- **Metric / stat cards:** white card, hairline border, no shadow. A small uppercase - eyebrow label, then the figure **large in mono tabular** (`--text-h2`/`h3`), then an - optional delta line. Show change with a small coloured chip or arrow in `--success` / - `--danger` — **muted, not neon**, never a red/green gradient. +- **Metric / stat cards:** white card, hairline border, no shadow. A small uppercase eyebrow + label, then the figure **large in mono tabular** (`--text-h2`/`h3`), then an optional delta + line. Show change with a small coloured chip or arrow in `--success` / `--danger` — + **muted, not neon**, never a red/green gradient. - **Card grid:** lay metric cards on a responsive grid with `--space-4`/`--space-5` gaps. Group related cards under a section header (eyebrow label + thin accent tick). -- **Tables:** the workhorse of dashboards. Eyebrow-label header row, hairline row dividers - (or zebra via `--surface-sunken` at low contrast), generous row height, mono tabular for - any numeric column, right-align numbers. Status/category cells use the chip system (§8). - Sticky header for long tables. Row hover = `--surface-hover`. -- **Charts:** flat fills, no 3D, no drop shadows, no gradients-as-decoration. Use the blue - accent as the primary series; pull additional series from the muted categorical palette - (§8) so chart colours match the chips/legends elsewhere. Thin gridlines in - `--border-subtle`, axis labels in `--text-muted`. A legend reuses the same chip styling. +- **Tables:** the workhorse. Eyebrow-label header row, hairline row dividers (or zebra via + `--surface-sunken` at low contrast), generous row height, mono tabular for any numeric + column, right-align numbers. Status/category cells use the chip system (§8). Sticky header + for long tables. Row hover = `--surface-hover`. +- **Charts:** flat fills, no 3D, no drop shadows, no gradients-as-decoration. Blue accent as + the primary series; additional series from the muted categorical palette (§8) so chart + colours match the chips/legends elsewhere. Thin gridlines in `--border-subtle`, axis labels + in `--text-muted`. Legend reuses the same chip styling. - **Density:** comfortable, not cramped. Don't add stats, sparklines, or icons that aren't answering a real question — less is more. No "data slop." - **Filters / toolbars:** segmented controls and ghost buttons in a row above the content, - separated from it by a hairline. Active segment = `--accent-soft` fill + accent text. + separated by a hairline. Active segment = `--accent-soft` fill + accent text. - **Empty & loading states:** a calm centred message in `--text-muted` with one clear action — never a spinner alone with no context. --- -## 8. Colour-coding categorical data (the timesheet pattern — use it everywhere it fits) +## 8. Colour-coding categorical data (use it everywhere it fits) -This is the pattern from the timesheet that works well: any time data falls into **named -categories** — work codes, expense categories, invoice statuses (draft/sent/paid/overdue), -project tags, leave types, priority levels — give each category a **stable, muted colour -identity** and surface it consistently. It turns a wall of rows into something scannable. +Any time data falls into **named categories** — work codes, expense categories, statuses +(draft/sent/paid/overdue), project tags, leave types, priority levels — give each category a +**stable, muted colour identity** and surface it consistently. It turns a wall of rows into +something scannable. ### 8.1 The colour identity Each category owns a trio, ideally defined in **config/data, not hardcoded**, exposed as CSS @@ -257,47 +277,51 @@ custom properties so they stay configurable: --chip-text : a darker shade of the hue (readable label colour) ``` -You can specify all three for control, or specify just `--chip-border` and derive the tint -with `color-mix(in srgb, var(--chip-border) 16%, var(--surface))`. **Muted, never neon** — -think `#0078d7` blue, `#8cbd18` olive, `#ed616f` coral, `#393939` slate, not pure primaries. +Specify all three for control, or specify just `--chip-border` and derive the tint with +`color-mix(in srgb, var(--chip-border) 16%, var(--surface))`. **Muted, never neon** — think +`#0078d7` blue, `#8cbd18` olive, `#ed616f` coral, `#393939` slate, not pure primaries. ### 8.2 Three ways to surface it (use together) 1. **Chip / pill** — a `--radius-full` pill with a leading filled **dot** in the category - colour, the code + short name. This is the primary, always-legible token. Used in cells, - filters, and legends. + colour, the code + short name. The primary, always-legible token. Used in cells, filters, + and legends. 2. **Left border on the row** — `border-left: 3–4px solid var(--chip-border)`. A quiet, always-on stripe that lets you scan a long table by category at a glance. 3. **Optional muted row tint** — fill the whole row with `--chip-bg` (or a 45% `color-mix` - of it). Make this **toggleable** (the timesheet's `muted-background` option): some users - want the calm border-only view, others want the colour wash. Default to the quieter one. + of it). Make this **toggleable**: some users want the calm border-only view, others want + the colour wash. Default to the quieter one. ### 8.3 Legend Whenever colour carries meaning, show a **legend** of chips mapping each colour to its label. Colour is never the *only* signal — the code/name text is always present, so the system works for colour-blind users and in print/PDF. -### 8.4 Full-day vs partial, and state variants -A category can carry more than one colour for sub-states (the timesheet's PPT uses a -red full-day vs amber partial-day pair). Model these as variant classes/props -(`c-PPT` vs `c-PPTp`) rather than inventing ad-hoc colours at the call site. +### 8.4 Sub-states & variants +A category can carry more than one colour for sub-states (e.g. a full-day vs partial-day +pair). Model these as variant classes/props rather than inventing ad-hoc colours at the call +site. ### 8.5 Dark mode Tints are too dark to read directly inverted. Either define dark-mode `--chip-bg`/`--chip-text` in the dark scope, or in dark mode show the chip as the border colour on a transparent fill (`color-mix` the hue into the surface) and brighten slightly. Always re-check contrast. -### 8.6 Reuse across the family -The *same* category palette should drive chips, row borders, row tints, **and** chart -series — so a "Travel" expense is the same colour in the table, the legend, and the pie -chart. Define the palette once per app and pull from it everywhere. +### 8.6 Reuse across the app +The *same* category palette should drive chips, row borders, row tints, **and** chart series +— so "Travel" is the same colour in the table, the legend, and the pie chart. Define the +palette once per app and pull from it everywhere. --- -## 9. Iconography +## 9. Iconography & app icons - **System:** Lucide — 2px-stroke outline icons. Size `1em`–18px, round caps/joins, `currentColor` so they inherit text colour. Bump stroke to 2.2–2.4 at very small sizes. - **Status** is a small filled dot or a Lucide glyph + colour. **Never emoji.** +- **App icon / favicon:** if this app needs its own mark, follow the family pattern — a + single Nordic-blue squircle (`#2f6fed`, ~25% corner radius) carrying **one** white + Lucide-weight glyph. Keep it to one glyph; fall back to a mono lettermark where a glyph + won't fit. (Reference sets live with the design system's `app-icons`.) - Don't draw bespoke illustrations in SVG; prefer clean icons, real screenshots, or diagrams. No stock photos, no grain, no duotone. @@ -307,16 +331,19 @@ chart. Define the palette once per app and pull from it everywhere. - Every colour goes through a semantic token; nothing hardcoded that can't invert. - Page `#0d1117`, card `#161b22`, raised `#1c232c`; borders lighten, text lightens, accent - shifts lighter (`--blue-400`-ish) for contrast on dark. -- Shadows get heavier/darker (already handled by the dark elevation tokens). + shifts lighter for contrast on dark. +- Shadows get heavier/darker (handled by the dark elevation tokens). - Re-derive category tints (§8.5). Re-check chip and note contrast. - Support both auto (`prefers-color-scheme`) and a manual `data-theme` override, persisted. + The `dev/theme/` Forgejo build is a complete light/dark/auto reference. --- ## 11. Do / Don't **Do** +- Read `dev/theme/`, `dev/ui_kits/`, `dev/mockups/` and **fork the nearest example** before + building from scratch. - Use one blue accent; let borders and whitespace do the work. - Keep forms row-major; keep numbers in mono tabular. - Colour-code categories with the muted chip/border/tint system, with a legend. @@ -325,6 +352,7 @@ chart. Define the palette once per app and pull from it everywhere. - Support light + dark through tokens. **Don't** +- Don't reinvent components the examples already provide. - No gradients, photographic washes, textures, or glassmorphism. - No emoji, no exclamation marks, no hype words, no Title Case. - No neon semantic colours; no red/green gradient deltas. @@ -335,5 +363,6 @@ chart. Define the palette once per app and pull from it everywhere. --- -*This brief governs surface only. When a real codebase or brand guide exists, reconcile -these tokens against it rather than overriding the product.* +*This file governs surface only. The `dev/` examples are the canonical reference; when this +app has its own real brand guide or codebase conventions, reconcile against those rather than +overriding the product.* diff --git a/dev/design_assets/favicons/apple-touch-icon.png b/dev/design_assets/favicons/apple-touch-icon.png new file mode 100644 index 0000000..fd6968a Binary files /dev/null and b/dev/design_assets/favicons/apple-touch-icon.png differ diff --git a/dev/design_assets/favicons/favicon-16.png b/dev/design_assets/favicons/favicon-16.png new file mode 100644 index 0000000..01c442e Binary files /dev/null and b/dev/design_assets/favicons/favicon-16.png differ diff --git a/dev/design_assets/favicons/favicon-32.png b/dev/design_assets/favicons/favicon-32.png new file mode 100644 index 0000000..424cbee Binary files /dev/null and b/dev/design_assets/favicons/favicon-32.png differ diff --git a/dev/design_assets/favicons/favicon-48.png b/dev/design_assets/favicons/favicon-48.png new file mode 100644 index 0000000..7da10f3 Binary files /dev/null and b/dev/design_assets/favicons/favicon-48.png differ diff --git a/dev/design_assets/favicons/favicon.svg b/dev/design_assets/favicons/favicon.svg new file mode 100644 index 0000000..deb7281 --- /dev/null +++ b/dev/design_assets/favicons/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/dev/design_assets/favicons/icon-512.png b/dev/design_assets/favicons/icon-512.png new file mode 100644 index 0000000..176abc9 Binary files /dev/null and b/dev/design_assets/favicons/icon-512.png differ diff --git a/dev/design_assets/favicons/site.webmanifest b/dev/design_assets/favicons/site.webmanifest new file mode 100644 index 0000000..84d7bb5 --- /dev/null +++ b/dev/design_assets/favicons/site.webmanifest @@ -0,0 +1,35 @@ +{ + "name": "Invoice", + "short_name": "invoice", + "theme_color": "#2f6fed", + "background_color": "#2f6fed", + "display": "standalone", + "icons": [ + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "favicon-48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "favicon-32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "favicon-16.png", + "sizes": "16x16", + "type": "image/png" + } + ] +} diff --git a/dev/design_assets/invoice-glyph.svg b/dev/design_assets/invoice-glyph.svg new file mode 100644 index 0000000..3420de4 --- /dev/null +++ b/dev/design_assets/invoice-glyph.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/dev/design_assets/invoice.svg b/dev/design_assets/invoice.svg new file mode 100644 index 0000000..deb7281 --- /dev/null +++ b/dev/design_assets/invoice.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/dev/mockups/invoice.html b/dev/mockups/invoice.html new file mode 100644 index 0000000..c1d1b5f --- /dev/null +++ b/dev/mockups/invoice.html @@ -0,0 +1,168 @@ + + + + + + +Invoice — kBenestad reskin + + + + + +
+ + +
+
+ +
+
+
+ +
+ +
+ + +
+
+ + Nordmann ConsultingOrg. 998 877 665 · Oslo, Norway +
+
+

Invoice

+
No. INV-2026-0042 · 6 June 2026
+
+
+ + +
+
+

Bill to

+
+
+ Recipient + +
+
+ Address + +
+
+
VAT ID
+
Project code + +
+
+
+
+
+

Details

+
+
Issue date
+
Due date
+
Currency + +
+
Terms
+
+
+
+ + +
+

Line items 2 lines

+
+ DescriptionQtyUnitUnit priceAmount +
+
+ + + + + 4,000.00 + +
+
+ + + + + 1,600.00 + +
+
+ +
+
+ + +
+
+

Tax

+
+ TypeRate %Amount +
+
+ + + 1,400.00 + +
+
+
+ +
+

Summary

+
+
Subtotal5,600.00
+
VAT 25%1,400.00
+
Total due (USD)7,000.00
+
+
+
+ + +
+

Payment information

+
+
Bank
+
IBAN
+
SWIFT / BIC
+
+
+ + +
+ + + + diff --git a/dev/mockups/kbenestad-forms.css b/dev/mockups/kbenestad-forms.css new file mode 100644 index 0000000..b8aa98e --- /dev/null +++ b/dev/mockups/kbenestad-forms.css @@ -0,0 +1,356 @@ +/* ============================================================================ + kBenestad — unified forms design language + Shared foundation for invoice · timesheet · reimburse + ---------------------------------------------------------------------------- + Customer-facing white-label apps: the CUSTOMER's identity leads (logo + org + name in the header); kBenestad is the quiet craft signature. + + Configurable in each app's config.yml (sensible defaults shown): + accent-colour: "#2F6FED" → --accent (recolour to the customer brand) + font-size: 1.0 → --font-scale (screen text multiplier) + code colours (timesheet) → per-chip --chip-border / --chip-bg + ---------------------------------------------------------------------------- + Type: Schibsted Grotesk (text) + JetBrains Mono (figures), system fallback + so the forms render fully offline if the webfonts are unavailable. + ========================================================================== */ + +/* ── Fonts: load if present, but the stacks below fall back to system ─────── */ +@font-face { + font-family: "Schibsted Grotesk"; font-style: normal; font-weight: 400 800; + font-display: swap; + src: local("Schibsted Grotesk"), + url("https://fonts.bunny.net/schibsted-grotesk/files/schibsted-grotesk-latin-400-normal.woff2") format("woff2"); +} +@font-face { + font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 600; + font-display: swap; + src: local("JetBrains Mono"), + url("https://fonts.bunny.net/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2") format("woff2"); +} + +/* ── Tokens ───────────────────────────────────────────────────────────────── */ +:root { + --font-sans: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + /* screen type multiplier (config: font-size) — base unit is 16px */ + --font-scale: 1; + --fs-base: calc(15px * var(--font-scale)); + --fs-input: calc(15px * var(--font-scale)); + --fs-label: calc(12px * var(--font-scale)); + --fs-title: calc(13px * var(--font-scale)); + --fs-small: calc(12.5px * var(--font-scale)); + --fs-h1: calc(22px * var(--font-scale)); + + /* accent — single recolourable token (config: accent-colour) */ + --accent: #2F6FED; + --accent-hover: #1F57CF; + --accent-soft: #EEF3FE; + --accent-border: #C7D9FB; + --on-accent: #FFFFFF; + + /* surfaces & ink (light) */ + --bg: #F4F6F9; + --surface: #FFFFFF; + --surface-2: #F8F9FB; + --surface-3: #F1F3F6; + --border: #E3E7EE; + --border-strong:#D3D9E2; + --text: #14181E; + --text-soft: #3A434F; + --text-muted: #5F6975; + --placeholder: #9AA3AF; + + /* semantic */ + --danger: #D64545; --danger-soft: #FBEAEA; --danger-border: #F0C9C9; + --warning: #C9851F; --warning-soft: #FBF1DD; --warning-border: #EED9AD; + --success: #1F9D5F; --success-soft: #E2F3EA; --success-border: #BFE3CF; + --info: #2F6FED; --info-soft: #EEF3FE; --info-border: #C7D9FB; + + /* shape & depth */ + --radius: 8px; + --radius-sm: 6px; + --radius-pill: 999px; + --shadow-sm: 0 1px 2px rgba(20,24,30,.05); + --shadow: 0 6px 22px rgba(20,24,30,.08); + --ring: 0 0 0 3px rgba(47,111,237,.20); + + color-scheme: light; +} + +/* ── Dark — auto by system, or forced via [data-theme="dark"] ─────────────── */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --accent: #5685E9; --accent-hover: #6C98EF; --accent-soft: #16233F; --accent-border: #21386A; --on-accent: #FFFFFF; + --bg: #0D1117; --surface: #161B22; --surface-2: #1C232C; --surface-3: #1C232C; + --border: #232A33; --border-strong: #2D3641; + --text: #EEF1F5; --text-soft: #C2CAD3; --text-muted: #8B95A1; --placeholder: #6F7986; + --danger:#E06464; --danger-soft:#341819; --danger-border:#5A2A2A; + --warning:#D99A3A; --warning-soft:#33270F; --warning-border:#574017; + --success:#3BB97A; --success-soft:#13301F; --success-border:#1F4D33; + --info:#88ABF2; --info-soft:#16233F; --info-border:#21386A; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); --shadow: 0 8px 28px rgba(0,0,0,.5); + --ring: 0 0 0 3px rgba(86,133,233,.32); + color-scheme: dark; + } +} +:root[data-theme="dark"] { + --accent: #5685E9; --accent-hover: #6C98EF; --accent-soft: #16233F; --accent-border: #21386A; --on-accent: #FFFFFF; + --bg: #0D1117; --surface: #161B22; --surface-2: #1C232C; --surface-3: #1C232C; + --border: #232A33; --border-strong: #2D3641; + --text: #EEF1F5; --text-soft: #C2CAD3; --text-muted: #8B95A1; --placeholder: #6F7986; + --danger:#E06464; --danger-soft:#341819; --danger-border:#5A2A2A; + --warning:#D99A3A; --warning-soft:#33270F; --warning-border:#574017; + --success:#3BB97A; --success-soft:#13301F; --success-border:#1F4D33; + --info:#88ABF2; --info-soft:#16233F; --info-border:#21386A; + --shadow-sm: 0 1px 2px rgba(0,0,0,.4); --shadow: 0 8px 28px rgba(0,0,0,.5); + --ring: 0 0 0 3px rgba(86,133,233,.32); + color-scheme: dark; +} + +/* ── Base ─────────────────────────────────────────────────────────────────── */ +* { box-sizing: border-box; } +.kb { + font-family: var(--font-sans); + font-size: var(--fs-base); + line-height: 1.55; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + letter-spacing: -0.006em; +} +.kb-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } + +/* ── Page shell ───────────────────────────────────────────────────────────── */ +.kb-wrap { max-width: 960px; margin: 0 auto; padding: 28px 20px 56px; } + +/* ── Top utility bar (language / text-size / about) ───────────────────────── */ +.kb-toolbar { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + margin-bottom: 16px; +} +.kb-toolbar .spacer { flex: 1; } +.kb-seg { + display: inline-flex; align-items: center; gap: 2px; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 3px; +} +.kb-seg button { + font: 600 var(--fs-small)/1 var(--font-sans); + color: var(--text-muted); background: transparent; border: 0; + white-space: nowrap; + padding: 6px 11px; border-radius: 4px; cursor: pointer; +} +.kb-seg button.is-active { background: var(--accent-soft); color: var(--accent); } +.kb-seg button:hover:not(.is-active) { color: var(--text); } +.kb-iconbtn { + display: inline-grid; place-items: center; width: 34px; height: 34px; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-sm); color: var(--text-muted); cursor: pointer; +} +.kb-iconbtn:hover { color: var(--accent); border-color: var(--accent-border); } + +/* ── Document header: customer leads ──────────────────────────────────────── */ +.kb-header { + display: flex; justify-content: space-between; align-items: flex-start; + gap: 24px; padding-bottom: 20px; margin-bottom: 22px; + border-bottom: 1px solid var(--border); +} +.kb-brand { display: flex; align-items: center; gap: 14px; min-width: 0; } +.kb-brand .logo { + height: 46px; width: 46px; flex: 0 0 46px; border-radius: 10px; + display: grid; place-items: center; background: var(--accent-soft); + color: var(--accent); font-weight: 800; font-size: 18px; overflow: hidden; +} +.kb-brand .logo img { width: 100%; height: 100%; object-fit: contain; } +.kb-brand .org { font-size: 17px; font-weight: 700; color: var(--text); letter-spacing: -0.01em; } +.kb-brand .org small { display: block; font-size: var(--fs-small); font-weight: 500; color: var(--text-muted); letter-spacing: 0; } +.kb-doctitle { text-align: right; } +.kb-doctitle h1 { + margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em; + color: var(--text); +} +.kb-doctitle .meta { margin-top: 4px; font-size: var(--fs-small); color: var(--text-muted); font-family: var(--font-mono); } + +/* ── Cards / sections ─────────────────────────────────────────────────────── */ +.kb-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + padding: 20px 22px; + margin-bottom: 16px; +} +.kb-card__title { + display: flex; align-items: center; gap: 9px; + font-size: var(--fs-title); font-weight: 700; letter-spacing: -0.005em; + color: var(--text-soft); + margin: 0 0 16px; +} +.kb-card__title::before { + content: ""; width: 3px; height: 14px; border-radius: 2px; background: var(--accent); +} +.kb-card__title .count { margin-left: auto; font-weight: 500; color: var(--text-muted); font-size: var(--fs-small); } + +/* ── Fields ───────────────────────────────────────────────────────────────── */ +.kb-grid { display: grid; gap: 14px 16px; } +.kb-grid.cols-2 { grid-template-columns: 1fr 1fr; } +.kb-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; } +.kb-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; } +.kb-field.grow { flex: 1; } +.kb-label { + font-size: var(--fs-label); font-weight: 600; letter-spacing: 0.03em; + text-transform: uppercase; color: var(--text-muted); +} +.kb-input, .kb-select, .kb-textarea { + width: 100%; font: 400 var(--fs-input)/1.4 var(--font-sans); + color: var(--text); background: var(--surface); + border: 1px solid var(--border-strong); border-radius: var(--radius-sm); + padding: 9px 11px; outline: none; transition: border-color .14s, box-shadow .14s; +} +.kb-input::placeholder, .kb-textarea::placeholder { color: var(--placeholder); } +.kb-input:focus, .kb-select:focus, .kb-textarea:focus { + border-color: var(--accent); box-shadow: var(--ring); +} +.kb-input:disabled, .kb-select:disabled, .kb-input[readonly] { + background: var(--surface-3); color: var(--text-muted); cursor: not-allowed; +} +.kb-input.num { font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums; } +.kb-textarea { resize: vertical; min-height: 46px; } +.kb-select { + appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px; +} +.kb-input.is-error, .kb-select.is-error { border-color: var(--danger); } +.kb-input.is-warn { border-color: var(--warning); background: var(--warning-soft); } + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ +.kb-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + white-space: nowrap; + font: 600 var(--fs-input)/1 var(--font-sans); + padding: 10px 16px; border-radius: var(--radius-sm); + border: 1px solid transparent; cursor: pointer; transition: background .14s, border-color .14s, color .14s; +} +.kb-btn svg { width: 16px; height: 16px; } +.kb-btn--primary { background: var(--accent); color: var(--on-accent); } +.kb-btn--primary:hover { background: var(--accent-hover); } +.kb-btn--primary:disabled { opacity: .5; cursor: not-allowed; } +.kb-btn--ghost { background: var(--surface); color: var(--text-soft); border-color: var(--border-strong); } +.kb-btn--ghost:hover { border-color: var(--accent); color: var(--accent); } +.kb-btn--soft { background: var(--accent-soft); color: var(--accent); } +.kb-btn--soft:hover { background: var(--accent); color: var(--on-accent); } +.kb-btn--dashed { background: transparent; color: var(--accent); border: 1px dashed var(--accent-border); } +.kb-btn--dashed:hover { background: var(--accent-soft); border-color: var(--accent); } +.kb-btn--danger-ghost { background: transparent; color: var(--danger); padding: 6px 10px; } +.kb-btn--danger-ghost:hover { background: var(--danger-soft); } +.kb-btn--lg { padding: 13px 26px; font-size: calc(15px * var(--font-scale)); } +.kb-btn--block { width: 100%; } + +/* round add/remove */ +.kb-circbtn { + width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center; + font-size: 15px; line-height: 1; font-weight: 700; cursor: pointer; padding: 0; + background: var(--surface); border: 1px solid var(--accent); color: var(--accent); +} +.kb-circbtn:hover { background: var(--accent); color: var(--on-accent); } +.kb-circbtn--rm { border-color: var(--danger); color: var(--danger); } +.kb-circbtn--rm:hover { background: var(--danger); color: #fff; } + +/* ── Dividers — deliberately simple (no overlap, no doubled rules) ────────── */ +.kb-divider { height: 1px; background: var(--border); border: 0; margin: 18px 0; } +.kb-divider--strong { background: var(--border-strong); } + +/* ── Item / line blocks ───────────────────────────────────────────────────── */ +.kb-block { + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface-2); padding: 16px 18px; margin-bottom: 14px; +} +.kb-block__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.kb-block__head .tag { font-size: var(--fs-label); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--accent); } +.kb-subtotal { font-family: var(--font-mono); font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } + +/* ── Tables / row grids ───────────────────────────────────────────────────── */ +.kb-rowhead, .kb-row { display: grid; align-items: center; gap: 8px; } +.kb-rowhead { + padding: 0 10px 8px; font-size: var(--fs-label); font-weight: 700; + text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); +} +.kb-rowhead .r { text-align: right; } +.kb-row { + padding: 7px 10px; border-radius: var(--radius-sm); + border-left: 3px solid transparent; +} +.kb-row:hover { background: var(--surface-2); } +.kb-row .r { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; } + +/* ── Code chips (timesheet) — colours come from config per code ───────────── */ +.kb-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 10px; border-radius: var(--radius-pill); + font-size: var(--fs-small); font-weight: 600; line-height: 1.3; + white-space: nowrap; + /* per-code overrides set --chip-bg / --chip-border / --chip-text inline */ + background: var(--chip-bg, var(--surface-3)); + border: 1px solid var(--chip-border, var(--border-strong)); + color: var(--chip-text, var(--text-soft)); +} +.kb-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--chip-border, var(--text-muted)); } + +/* a row tinted by its code colour */ +.kb-row.coded { border-left-color: var(--chip-border, transparent); } +.kb-row.coded.tint { background: color-mix(in srgb, var(--chip-bg, transparent) 45%, var(--surface)); } + +/* ── Totals panel ─────────────────────────────────────────────────────────── */ +.kb-totals { margin-left: auto; width: min(380px, 100%); } +.kb-totals--fill { margin-left: 0; width: 100%; } +.kb-card--flex { display: flex; flex-direction: column; } +.kb-card--flex .kb-totals { margin-top: auto; } +.kb-totals .row { display: flex; justify-content: space-between; gap: 16px; padding: 6px 0; font-size: var(--fs-base); } +.kb-totals .row .lab { color: var(--text-muted); } +.kb-totals .row .val { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text); } +.kb-totals .grand { + margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border-strong); + display: flex; justify-content: space-between; align-items: baseline; gap: 16px; +} +.kb-totals .grand .lab { font-weight: 700; color: var(--text); } +.kb-totals .grand .val { font-family: var(--font-mono); font-weight: 700; font-size: calc(20px * var(--font-scale)); color: var(--accent); font-variant-numeric: tabular-nums; } + +/* ── Validation summary ───────────────────────────────────────────────────── */ +.kb-note { + border-radius: var(--radius-sm); padding: 12px 16px; margin-bottom: 16px; + font-size: var(--fs-small); line-height: 1.7; + display: flex; gap: 10px; align-items: flex-start; +} +.kb-note svg { width: 17px; height: 17px; flex: 0 0 17px; margin-top: 1px; } +.kb-note--error { background: var(--danger-soft); border: 1px solid var(--danger-border); color: var(--danger); } +.kb-note--warning { background: var(--warning-soft); border: 1px solid var(--warning-border); color: var(--warning); } +.kb-note--success { background: var(--success-soft); border: 1px solid var(--success-border); color: var(--success); } +.kb-note--info { background: var(--info-soft); border: 1px solid var(--info-border); color: var(--info); } +.kb-note b { font-weight: 700; } + +/* ── Signature ────────────────────────────────────────────────────────────── */ +.kb-sig { border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); height: 96px; } + +/* ── Footer (software credit — stays kBenestad) ───────────────────────────── */ +.kb-footer { + max-width: 960px; margin: 0 auto; padding: 18px 20px 8px; + font-size: var(--fs-small); color: var(--text-muted); + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; +} +.kb-footer a { color: var(--text-muted); text-decoration: none; } +.kb-footer a:hover { color: var(--accent); text-decoration: underline; } +.kb-footer .sep { opacity: .45; } +/* kBenestad mark — two offset rounded squares, upper-right outlined + lower-left solid. + Usage: kBenestad */ +.kb-mark { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-soft); } +.kb-mark svg { width: 18px; height: 18px; flex: 0 0 18px; overflow: visible; } + +@media (max-width: 680px) { + .kb-grid.cols-2, .kb-grid.cols-3 { grid-template-columns: 1fr; } + .kb-header { flex-direction: column; gap: 14px; } + .kb-doctitle { text-align: left; } +} diff --git a/dev/mockups/reimburse.html b/dev/mockups/reimburse.html new file mode 100644 index 0000000..87e1a24 --- /dev/null +++ b/dev/mockups/reimburse.html @@ -0,0 +1,326 @@ + + + + + + +Reimbursement — kBenestad reskin + + + + + + + + + + + +
+ +
+
+
+ +
+ +
+ +
+
+ + Center for Asylum ProtectionExpense reimbursement +
+
+

Reimbursement

+
Claim · 6 June 2026
+
+
+ + +
+

Claimant

+
+
Name
+
Program + +
+
Account code + +
+
Claim currency + +
+
Purpose
+
+
+ + +
+

Expenses 2 items

+ +
+
+ Item 1 · Transport + USD 184.00 +
+
+ DescriptionAmountCurrencyIn USD +
+
+ + + + 184.00 + +
+
+ + receipt-flight-bkk.pdf240 KB +
+ + +
+
+ + Foreign currency — enter exchange rate +
+
+ +
+ 6,440.00 THB + ÷ + 35.00 + = + USD 184.00 +
+
+
+ +
+ + +
+
+ Item 2 · Accommodation + USD 96.00 +
+
+ DescriptionAmountCurrencyIn USD +
+
+ + + + 96.00 + +
+
+ + guesthouse-invoice.jpg1.1 MB +
+
+ + +
+ + +
+
+

Declaration

+
+ + I certify that the above expenses were incurred on official business and are supported by the attached receipts. +
+
Claimant signature
+
+ +
+

Summary

+
+
Transport184.00
+
Accommodation96.00
+
Total claim (USD)280.00
+
+
+
+ + +
+ + +
+ + + + + diff --git a/dev/mockups/review.html b/dev/mockups/review.html new file mode 100644 index 0000000..431d537 --- /dev/null +++ b/dev/mockups/review.html @@ -0,0 +1,96 @@ + + + + + +kBenestad — unified forms review + + + + + + +
+
+ + kBenestad — unified formsinvoice · timesheet · reimburse — review build +
+
+ App +
+ + + +
+ Theme +
+ + + +
+
+ +
+
+
+ + + + invoice.html + Open full ↗ +
+ +
+
+ + + + diff --git a/dev/mockups/timesheet.html b/dev/mockups/timesheet.html new file mode 100644 index 0000000..905d115 --- /dev/null +++ b/dev/mockups/timesheet.html @@ -0,0 +1,180 @@ + + + + + + +Timesheet — kBenestad reskin + + + + + +
+ +
+
+ +
+
+
+ +
+ +
+ +
+
+ + Center for Asylum ProtectionTimesheet · monthly +
+
+

Timesheet

+
1–7 June 2026
+
+
+ + +
+
+
Employee
+
Employee type + +
+
Period
+
+
+ + +
+

Daily entries

+ +
+ REG · Regular + PPT · Paid leave + OTH · Other paid + HOL · Holiday + UNP · Unpaid + NON · Non-working +
+ +
+ DateTime inTime outCodeDescriptionHours +
+ +
+ Mon 1 JunMonday + + + + 8.0 + +
+ +
+ Tue 2 JunTuesday + + + + 8.0 + +
+ +
+ Wed 3 JunWednesday + + + + 4.0 + +
+ +
+ Thu 4 JunThursday + + + + 8.0 + +
+ +
+ Fri 5 JunFriday + + + + 7.0 + +
+ +
+
+ + +
+

Summary

+
+
Total hours35.0
+
Of which paid leave4.0
+
Of which holiday8.0
+
Total (decimal)35.00
+
+
+ + +
+

Signatures

+
+
Employee signature
+
Authorised signature
+
+
+ +
+ +
+ + +
+
+ + + + diff --git a/dev/mockups/tweaks-panel.jsx b/dev/mockups/tweaks-panel.jsx new file mode 100644 index 0000000..bec00c3 --- /dev/null +++ b/dev/mockups/tweaks-panel.jsx @@ -0,0 +1,541 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) + +/* BEGIN USAGE */ +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, +// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "palette": ["#D97757", "#29261b", "#f6f4ef"], +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('palette', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to +// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when +// options are many or long. For color tweaks always curate 3-4 options rather than +// a free picker; an option can also be a whole 2–5 color palette (the stored value +// is the array). The Tweak* controls are a floor, not a ceiling — build custom +// controls inside the panel if a tweak calls for UI they don't cover. +/* END USAGE */ +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} + + .twk-chips{display:flex;gap:6px} + .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px; + padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default; + box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06); + transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s} + .twk-chip:hover{transform:translateY(-1px); + box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)} + .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85), + 0 2px 6px rgba(0,0,0,.15)} + .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%; + display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)} + .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)} + .twk-chip>span>i:first-child{box-shadow:none} + .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px; + filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + // Same-window signal so in-page listeners (deck-stage rail thumbnails) + // can react — the parent message only reaches the host, not peers. + window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits })); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
+ {children} +
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +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) { + // onChange(e.target.value)}> + {options.map((o) => { + const v = typeof o === 'object' ? o.value : o; + const l = typeof o === 'object' ? o.label : o; + return ; + })} + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +// Relative-luminance contrast pick — checkmarks drawn over a swatch need to +// read on both #111 and #fafafa without per-option configuration. Hex input +// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light". +function __twkIsLight(hex) { + const h = String(hex).replace('#', ''); + const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0'); + const n = parseInt(x.slice(0, 6), 16); + if (Number.isNaN(n)) return true; + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return r * 299 + g * 587 + b * 114 > 148000; +} + +const __TwkCheck = ({ light }) => ( + +); + +// TweakColor — curated color/palette picker. Each option is either a single +// hex string or an array of 1-5 hex strings; the card adapts — a lone color +// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the +// rest stacked in a sharp column on the right. onChange emits the +// option in the shape it was passed (string stays string, array stays array). +// Without options it falls back to the native color input for back-compat. +function TweakColor({ label, value, options, onChange }) { + if (!options || !options.length) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); + } + // Native emits lowercase hex per the HTML spec, so + // compare case-insensitively. String() guards JSON.stringify(undefined), + // which returns the primitive undefined (no .toLowerCase). + const key = (o) => String(JSON.stringify(o)).toLowerCase(); + const cur = key(value); + return ( + +
+ {options.map((o, i) => { + const colors = Array.isArray(o) ? o : [o]; + const [hero, ...rest] = colors; + const sup = rest.slice(0, 4); + const on = key(o) === cur; + return ( + + ); + })} +
+
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/dev/theme/README.md b/dev/theme/README.md new file mode 100644 index 0000000..4f61b2a --- /dev/null +++ b/dev/theme/README.md @@ -0,0 +1,159 @@ +# kBenestad theme for Forgejo + +The kBenestad design language as a Forgejo theme — Nordic-minimal, light-first, +one calm blue accent. Ships in **light**, **dark**, and **auto** (follows the +visitor's OS preference). + +| File | Theme name | Notes | +|------|------------|-------| +| `theme-kbenestad-light.css` | `kbenestad-light` | cool paper ground, slate ink | +| `theme-kbenestad-dark.css` | `kbenestad-dark` | deep slate, brightened blue | +| `theme-kbenestad-auto.css` | `kbenestad-auto` | light by day, dark by night | + +## How it works + +Each theme defines a **complete, self-contained** kBenestad variable set — every +`--color-*`, the type stack, and the corner radii — then layers a small set of +**structural overrides** that carry the identity the colors alone can't: Schibsted +Grotesk / JetBrains Mono type, accent-soft topic pills, flat primary buttons, and +hairline 8px cards. It also `@import`s Forgejo's matching shipped theme as a +harmless safety net (it fills any future upstream variables if present, and is +silently ignored if absent), so the theme keeps working across upgrades. + +## Matching the kBenestad mark + +The brand direction is **Stack**. Final SVGs live in the design system under +`assets/logo/`. To dress Forgejo: + +| Forgejo path | Use this file | +|---|---| +| `custom/public/img/logo.svg` | `mark-stack-color.svg` (or `app-icon.svg` for a tiled mark) | +| `custom/public/img/favicon.svg` | `favicon.svg` | + +No restart needed — hard refresh. For the reversed navbar on the dark theme the +mark already inherits `currentColor` where possible; if you want a fixed reverse, +use `mark-stack-white.svg`. + +## Install + +1. Copy the `.css` files into your custom assets CSS directory: + + ``` + /public/assets/css/ + ``` + + On most installs `FORGEJO_CUSTOM` is `/data/gitea` (Docker) or the `custom/` + folder beside your `app.ini`. The files must sit next to the shipped + `theme-forgejo-*.css` so the relative `@import`s resolve. + +2. Register the themes in `app.ini` under `[ui]`: + + ```ini + [ui] + THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-auto,kbenestad-light,kbenestad-dark + DEFAULT_THEME = kbenestad-auto + ``` + + Or via environment variables (Docker): + + ``` + FORGEJO__ui__THEMES=forgejo-auto,forgejo-light,forgejo-dark,kbenestad-auto,kbenestad-light,kbenestad-dark + FORGEJO__ui__DEFAULT_THEME=kbenestad-auto + ``` + +3. A hard refresh is enough — no restart needed for CSS changes. Users can also + pick the theme per-account under **Settings → Appearance**. + +## Fonts + +The themes load **Schibsted Grotesk** and **JetBrains Mono** from Google Fonts via +`@import` at the top of each file, and force the families directly so they apply +the instant the fonts are available. + +### If the type still looks like the system font + +This means the **web font never loaded** — the colors and layout will look right, +but text falls back to your OS sans-serif. The `@import` from `fonts.googleapis.com` +is being blocked. Common causes: + +- A privacy / tracker blocker in the browser (Vivaldi's built-in blocker, uBlock, + Privacy Badger, etc.) blocks Google Fonts domains. +- A reverse-proxy or `[security]` Content-Security-Policy that disallows external + styles/fonts. +- The instance is offline / air-gapped. + +**Fix — self-host the fonts (recommended, bulletproof):** + +1. Download the woff2 files: + - Schibsted Grotesk (400/500/600/700) — + - JetBrains Mono (400/500/600) — +2. Drop them in `custom/public/assets/fonts/`. +3. Delete the `@import url('https://fonts.googleapis.com/…')` line at the top of + each kBenestad theme file and paste a local block in its place, e.g.: + + ```css + @font-face { + font-family: "Schibsted Grotesk"; + font-weight: 400 800; + font-display: swap; + src: url("/assets/fonts/SchibstedGrotesk.woff2") format("woff2"); + } + @font-face { + font-family: "JetBrains Mono"; + font-weight: 400 600; + font-display: swap; + src: url("/assets/fonts/JetBrainsMono.woff2") format("woff2"); + } + ``` + + (Adjust filenames to the files you downloaded. `/assets/fonts/…` is served + directly by Forgejo from `custom/public/assets/fonts/`.) + +The `--fonts-proportional` / `--fonts-monospace` variables already point at the +right family names, so no other change is needed. + +## Troubleshooting — "the theme deployed but it still looks stock" + +Forgejo's compiled `index.css` is the raw Fomantic/Semantic-UI base, which +*hardcodes* legacy colours (`.ui.primary.button{background:#2185d0}`, +`.ui.button{background:#e0e1e2}`, `.ui.label`…). Your `--color-*` variables only +take effect once the theme stylesheet that re-points those components onto the +variables is the one actually loaded. If the page still looks like default +Forgejo, work down this list: + +1. **Stale cache (most common).** Forgejo serves files in + `custom/public/assets/css/` with a 6-hour browser cache and **no `?v=` buster** + (unlike the versioned `index.css?v=9.0.3~gitea-1.22.0`). After editing a theme + file, the browser keeps the old copy. Fix: hard-refresh (Ctrl/Cmd-Shift-R), or + bump `STATIC_CACHE_TIME` down while iterating, or append a throwaway query when + testing. A server restart does **not** clear the *browser's* copy. +2. **Theme not selected.** Confirm `DEFAULT_THEME = kbenestad-light` (or `-auto`) + in `[ui]`, *and* that your account isn't pinned to another theme under + **Settings → Appearance**. A per-user choice overrides the default. +3. **Quick sanity check.** Open dev-tools → inspect `` → Computed → + `--color-primary`. It must read `#2f6fed` (ours), not `#4183c4`/`#2185d0` + (stock). If it's the stock value, the kBenestad file isn't winning the cascade + — that's cause 1 or 2, not the CSS itself. +4. **Right file path.** The `.css` must sit in `custom/public/assets/css/` so it's + served at `/assets/css/theme-kbenestad-light.css` and the relative + `@import "./theme-forgejo-light.css"` resolves next to it. + +## What the theme recolours (and what it can't) + +Pulled onto the brand: primary/positive buttons, repo-header owner/name, repo +tabs (active label + accent underline) and their count pills, topic chips, +dropdown/pagination active states, form-focus rings, checkboxes/toggles and +progress bars. **Language bars** (the Go/Shell/HTML stripe on the repo home) are +**not** themeable — Forgejo emits those segment colours as inline styles from its +per-language colour table, so they stay their canonical hues by design. + +## Tweaking the accent + +The entire accent ramp derives from the Nordic blue `#2f6fed`. To shift it, edit +the `--color-primary*` block (and the matching `rgba(47, 111, 237, …)` alpha +values) in each theme file. + +## Compatibility + +Built against the modern Forgejo CSS-variable theming system (Forgejo v7.0+). +Gitea compatibility is likely but untested. diff --git a/dev/theme/kbenestad.yaml b/dev/theme/kbenestad.yaml new file mode 100644 index 0000000..59defe8 --- /dev/null +++ b/dev/theme/kbenestad.yaml @@ -0,0 +1,101 @@ +# mdcms v0.4 | DO NOT REMOVE THIS COMMENT +# mdcms theme — kBenestad +# Nordic-minimal: cool paper ground, near-black slate ink, one calm blue accent. +# Mirrors the kBenestad design language (Schibsted Grotesk + a single #2F6FED blue). +# +# Install: +# 1. Copy this file into your project root (next to config.yml). +# 2. In config.yml set: theme: kbenestad.yaml +# (or rename this file to theme.yml to replace the default) + +# ────────────────────────────────── +# Colours +# ────────────────────────────────── +light: + accent: "#2F6FED" + background: "#FFFFFF" + nav-background: "#F8F9FB" + text: "#14181E" + text-muted: "#6B7785" + nav-link: "#3A434F" # inactive nav link text + nav-link-active: "#2F6FED" # active nav link text + nav-section-heading: "#6B7785" # nav section label text + nav-sitename: "#14181E" # site name in sidebar header + nav-description: "#6B7785" # site description in sidebar header + nav-toggle: "#6B7785" # dark/light mode toggle + divider: "#E7EAEF" # border/hr colour + +dark: + accent: "#5685E9" + background: "#0D1117" + nav-background: "#161B22" + text: "#EEF1F5" + text-muted: "#8B95A1" + nav-link: "#C2CAD3" # inactive nav link text + nav-link-active: "#5685E9" # active nav link text + nav-section-heading: "#8B95A1" # nav section label text + nav-sitename: "#EEF1F5" # site name in sidebar header + nav-description: "#8B95A1" # site description in sidebar header + nav-toggle: "#8B95A1" # dark/light mode toggle + divider: "#232A33" # border/hr colour + +# ────────────────────────────────── +# Semantic colours +# Used by callout tags (info, warning, success, error). +# colours-semantic applies to both modes; colours-semantic-dark overrides for dark mode. +# ────────────────────────────────── +colours-semantic: + info: "#2F6FED" + warning: "#C9851F" + success: "#1F9D5F" + error: "#D64545" + +colours-semantic-dark: + info: "#88ABF2" + warning: "#D99A3A" + success: "#3BB97A" + error: "#E06464" + +# ────────────────────────────────── +# Callout defaults +# primary-colour matches colours-semantic (light mode). +# ────────────────────────────────── +callouts: + info: + icon: info + primary-colour: "#2F6FED" + background-colour: "#2F6FED" + warning: + icon: warning + primary-colour: "#C9851F" + background-colour: "#C9851F" + success: + icon: success + primary-colour: "#1F9D5F" + background-colour: "#1F9D5F" + error: + icon: error + primary-colour: "#D64545" + background-colour: "#D64545" + +# ────────────────────────────────── +# Typography +# Format: "provider:Font Name:weight" (provider: bunny | google) +# Schibsted Grotesk is the kBenestad type voice — clean Nordic grotesque. +# ────────────────────────────────── +font-body: "bunny:Schibsted Grotesk:400" +font-heading: "bunny:Schibsted Grotesk:700" +font-size: 1.00 # unitless multiplier (1.0 = 16px base) +line-height: 1.70 # unitless multiplier + +# ────────────────────────────────── +# Nav section toggle icons +# ────────────────────────────────── +nav-section-expand-icon: keyboard_arrow_right +nav-section-collapse-icon: keyboard_arrow_down + +# ────────────────────────────────── +# Layout +# ────────────────────────────────── +main-width: 80em +nav-width: 20em diff --git a/dev/theme/preview.html b/dev/theme/preview.html new file mode 100644 index 0000000..f173c5d --- /dev/null +++ b/dev/theme/preview.html @@ -0,0 +1,827 @@ + + + + + +kb / utils · code.kbenestad + + + + + + + + + + + + + + +
+ + +
+
+ +

+ kb/utils +

+ Public +
+ + + +
+
+ + + +
+ + +
+ +
+ +
+ + +
+ Go to file + + +
+ + +
+
+
KB
+ kb + Tighten retry backoff jitter; cap at 30s + a8f3c0e + · 2 days ago +
+ +
+ + + + .forgejo + ci: pin runner image to v2 + 3 weeks ago +
+
+ + + + cmd + cmd/kbu: drop deprecated --raw flag + last month +
+
+ + + + internal + internal/retry: jitter cap 30s + 2 days ago +
+ +
+ + + + testdata + golden: refresh fixtures for v2.4 + 2 weeks ago +
+
+ + + + .editorconfig + chore: standardize 2-space yaml + 3 months ago +
+
+ + + + .gitignore + ignore local kbpkg cache dirs + 6 months ago +
+
+ + + + LICENSE + Initial commit + 2 years ago +
+
+ + + + Makefile + make: split test:unit / test:race + last month +
+ +
+ + + + go.mod + deps: go 1.22; bump x/sync + last month +
+
+ + + + go.sum + deps: go 1.22; bump x/sync + last month +
+
+ + +
+
+ + README.md +
+
+

utils

+

A small, opinionated bundle of Go helpers used across kb/* services — + retry/backoff, slug, env loading, structured errors. Zero non-stdlib runtime + dependencies; everything else is dev-only.

+ +

Install

+
# Go module
+go get code.kbenestad.net/kb/utils@v2.4.0
+
+# Or via kbpkg, our internal package manager
+kbpkg install kb/utils
+
+ +

Usage

+
package main
+
+import (
+    "context"
+    "code.kbenestad.net/kb/utils/retry"
+)
+
+func main() {
+    ctx := context.Background()
+    _ = retry.Do(ctx, retry.Default, func() error {
+        // network call here
+        return nil
+    })
+}
+
+ +

What's inside

+
    +
  • retry — context-aware exponential backoff with full jitter (cap 30s).
  • +
  • slug — Unicode-correct slugification; handles combining marks.
  • +
  • envx — typed env loading with defaults and required-key checks.
  • +
  • errs — structured error wrapping that survives JSON round-trips.
  • +
  • iox — small io helpers (limited readers, atomic file writes).
  • +
+ +

Stability

+

Public API follows semver. Anything under internal/ is fair game and + will change without notice.

+
+
+ +
+ + + +
+ + + +
+ + + + diff --git a/dev/theme/theme-kbenestad-auto.css b/dev/theme/theme-kbenestad-auto.css new file mode 100644 index 0000000..35c91f6 --- /dev/null +++ b/dev/theme/theme-kbenestad-auto.css @@ -0,0 +1,15 @@ +/* ============================================================================ + theme-kbenestad-auto.css + kBenestad — Forgejo theme (AUTO) + Follows the visitor's OS light/dark preference: kBenestad light by day, + kBenestad dark by night. Register as `kbenestad-auto` in app.ini [ui] THEMES. + ---------------------------------------------------------------------------- + Media-conditional @imports load the matching kBenestad theme; the base + forgejo-auto import guarantees a complete variable set as a fallback. + ========================================================================== */ + +@import "./theme-forgejo-auto.css"; +@import "./theme-kbenestad-light.css" (prefers-color-scheme: light); +@import "./theme-kbenestad-dark.css" (prefers-color-scheme: dark); + +:root { color-scheme: light dark; } diff --git a/dev/theme/theme-kbenestad-dark.css b/dev/theme/theme-kbenestad-dark.css new file mode 100644 index 0000000..5a1f670 --- /dev/null +++ b/dev/theme/theme-kbenestad-dark.css @@ -0,0 +1,464 @@ +/* ============================================================================ + theme-kbenestad-dark.css + kBenestad — Forgejo theme (DARK) + Faithful inversion onto deep slate (#0d1117) with a lighter blue for contrast. + + Install: drop this file in custom/public/assets/css/ + then in app.ini [ui] + THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-light,kbenestad-dark + DEFAULT_THEME = kbenestad-dark + ---------------------------------------------------------------------------- + Strategy: a COMPLETE, self-contained variable set followed by the same + structural overrides as the light theme — Schibsted Grotesk type, accent-soft + topic pills, flat buttons, hairline cards. + ========================================================================== */ + +@import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap'); +@import "./theme-forgejo-dark.css"; + +:root { + color-scheme: dark; + --is-dark-theme: true; + + /* ── Type ────────────────────────────────────────────────────────────────── + Forgejo resolves body type as var(--fonts-override, var(--fonts-proportional)), + "Noto Sans", … — so these vars ARE the hook. Marked !important to win even + if the shipped base theme sets them !important first (per Forgejo's docs). */ + --fonts-proportional: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important; + --fonts-regular: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important; + --fonts-monospace: "JetBrains Mono", ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace !important; + + /* ── Corners ───────────────────────────────────────────────────────────── */ + --border-radius: 6px; + --border-radius-medium: 8px; + --border-radius-full: 99999px; + + /* ── Accent — Nordic blue, brightened toward light for dark ground ─────── */ + --color-primary: #2f6fed; + --color-primary-contrast: #ffffff; + --color-primary-dark-1: #4f82ec; + --color-primary-dark-2: #6c98ef; + --color-primary-dark-3: #88abf2; + --color-primary-dark-4: #a6c1f5; + --color-primary-dark-5: #c2d5f8; + --color-primary-dark-6: #dbe7fb; + --color-primary-dark-7: #eef3fe; + --color-primary-light-1: #245ac8; + --color-primary-light-2: #1f57cf; + --color-primary-light-3: #1b46a3; + --color-primary-light-4: #163b88; + --color-primary-light-5: #142e63; + --color-primary-light-6: #102450; + --color-primary-light-7: #0c1c3e; + --color-primary-alpha-10: rgba(86, 133, 233, 0.10); + --color-primary-alpha-20: rgba(86, 133, 233, 0.20); + --color-primary-alpha-30: rgba(86, 133, 233, 0.30); + --color-primary-alpha-40: rgba(86, 133, 233, 0.40); + --color-primary-alpha-50: rgba(86, 133, 233, 0.50); + --color-primary-alpha-60: rgba(86, 133, 233, 0.60); + --color-primary-alpha-70: rgba(86, 133, 233, 0.70); + --color-primary-alpha-80: rgba(86, 133, 233, 0.80); + --color-primary-alpha-90: rgba(86, 133, 233, 0.90); + --color-primary-hover: #4f82ec; + --color-primary-active: #6c98ef; + + /* ── Secondary ─────────────────────────────────────────────────────────── */ + --color-secondary: #2d3641; + --color-secondary-dark-1: #3d4856; + --color-secondary-dark-2: #4a5666; + --color-secondary-dark-3: #5f6975; + --color-secondary-dark-4: #6f7986; + --color-secondary-dark-5: #8b95a1; + --color-secondary-dark-6: #9aa4af; + --color-secondary-dark-7: #aab2bd; + --color-secondary-dark-8: #c2cad3; + --color-secondary-dark-9: #d3d9e0; + --color-secondary-dark-10: #e1e6eb; + --color-secondary-dark-11: #eef1f5; + --color-secondary-dark-12: #f5f7f9; + --color-secondary-dark-13: #ffffff; + --color-secondary-light-1: #262e38; + --color-secondary-light-2: #232a33; + --color-secondary-light-3: #1c232c; + --color-secondary-light-4: #161b22; + --color-secondary-alpha-10: rgba(123, 134, 148, 0.10); + --color-secondary-alpha-20: rgba(123, 134, 148, 0.20); + --color-secondary-alpha-30: rgba(123, 134, 148, 0.30); + --color-secondary-alpha-40: rgba(123, 134, 148, 0.40); + --color-secondary-alpha-50: rgba(123, 134, 148, 0.50); + --color-secondary-alpha-60: rgba(123, 134, 148, 0.60); + --color-secondary-alpha-70: rgba(123, 134, 148, 0.70); + --color-secondary-alpha-80: rgba(123, 134, 148, 0.80); + --color-secondary-alpha-90: rgba(123, 134, 148, 0.90); + --color-secondary-button: #3d4856; + --color-secondary-hover: #1c232c; + --color-secondary-active: #232b35; + + /* ── Text / ink ─────────────────────────────────────────────────────────── */ + --color-text-dark: #ffffff; + --color-text: #eef1f5; + --color-text-light: #c2cad3; + --color-text-light-1: #8b95a1; + --color-text-light-2: #6f7986; + --color-text-light-3: #5f6975; + --color-placeholder-text: #5f6975; + --color-text-focus: #ffffff; + + /* ── Surfaces ──────────────────────────────────────────────────────────── */ + --color-body: #0d1117; /* page ground (deep slate) */ + --color-box-body: #161b22; /* cards, file list, panels */ + --color-box-body-highlight: #1c232c; + --color-box-header: #1c232c; /* segment / table headers */ + --color-nav-bg: #161b22; /* top navbar */ + --color-nav-hover-bg: #1c232c; + --color-secondary-nav-bg: #0d1117; + --color-footer: #161b22; + --color-light: rgba(139, 149, 161, 0.05); + --color-light-mimic-enabled: rgba(255, 255, 255, calc(8 / 255)); + --color-light-border: #232a33; + --color-hover: #1c232c; /* row / item hover */ + --color-active: #232b35; /* active row */ + --color-menu: #161b22; + --color-card: #161b22; + --fancy-card-bg: #161b22; + --fancy-card-border: #232a33; + --color-markup-tab-default: var(--color-box-header); + --color-markup-tab-active: var(--color-box-body); + --color-header-wrapper: #161b22; + --color-header-wrapper-transparent: rgba(22, 27, 34, 0); + + /* ── Inputs ────────────────────────────────────────────────────────────── */ + --color-input-text: #eef1f5; + --color-input-background: #0f141a; + --color-input-toggle-background: #0f141a; + --color-input-border: #2d3641; + --color-input-border-hover: #3d4856; + + /* ── Borders / dividers ────────────────────────────────────────────────── */ + --color-border: #232a33; + --color-secondary-bg: #1c232c; + + /* ── Code / markup ─────────────────────────────────────────────────────── */ + --color-markup-code-block: #0f141a; + --color-markup-code-inline: #1c232c; + --color-markup-table-row: rgba(255, 255, 255, 0.024); + --color-code-bg: #161b22; + --color-code-sidebar-bg: #0f141a; + + /* ── Shadows ───────────────────────────────────────────────────────────── */ + --color-shadow: rgba(0, 0, 0, 0.45); + --color-secondary-shadow: rgba(0, 0, 0, 0.30); + + /* ── Accent line / selection / timeline / caret ────────────────────────── */ + --color-accent: #5685e9; + --color-small-accent: #19243a; + --color-active-line: #16233f; + --color-editor-line-highlight: #16233f; + --color-timeline: #2d3641; + --color-caret: #eef1f5; + --color-highlight-fg: #88abf2; + --color-highlight-bg: rgba(86, 133, 233, 0.16); + --color-selection-bg: #16233f; + --color-selection-fg: #eef1f5; + --color-overlay-backdrop: rgba(0, 0, 0, 0.55); + + /* ── Semantic hues ─────────────────────────────────────────────────────── */ + --color-red: #e06464; + --color-orange: #d99a3a; + --color-yellow: #d99a3a; + --color-olive: #a3a34a; + --color-green: #3bb97a; + --color-teal: #3aa6a6; + --color-blue: #5685e9; + --color-violet: #8579e0; + --color-purple: #a06ce0; + --color-pink: #e06ca6; + --color-brown: #a3866b; + --color-grey: #8b95a1; + --color-gold: #d99a3a; + --color-white: #ffffff; + --color-black: #0d1117; + --color-pure-black: #000000; + + /* light variants */ + --color-red-light: #e88585; + --color-green-light: #5fd28a; + --color-blue-light: #7ba2f0; + --color-grey-light: #aab2bd; + + /* dark-1 variants */ + --color-red-dark-1: #c95252; + --color-green-dark-1: #2fa069; + --color-blue-dark-1: #4f82ec; + + /* Status text / background / border */ + --color-success-text: #5fd28a; + --color-success-bg: #13301f; + --color-success-border: #1f4d33; + --color-error-text: #f08a8a; + --color-error-bg: #341819; + --color-error-bg-active:#4a2222; + --color-error-bg-hover: #3d1e1f; + --color-error-border: #5a2a2a; + --color-warning-text: #e7b35a; + --color-warning-bg: #33270f; + --color-warning-border: #574017; + --color-info-text: #88abf2; + --color-info-bg: #16233f; + --color-info-border: #21386a; + --color-danger-bg: #2a1718; + + /* Diff */ + --color-diff-removed-word-bg: #5a2a2a; + --color-diff-added-word-bg: #1f4d33; + --color-diff-removed-row-bg: #2a1718; + --color-diff-added-row-bg: #122a1d; + --color-diff-removed-row-border: #3d2122; + --color-diff-added-row-border: #1b3a28; + --color-diff-moved-row-bg: #33270f; + --color-diff-moved-row-border:#574017; + --color-diff-inactive: #1c232c; + + /* Labels / reactions / tooltip / nav */ + --color-label-text: #c2cad3; + --color-label-bg: #2d3641; + --color-label-hover-bg: #3d4856; + --color-label-active-bg: #4a5666; + --color-reaction-bg: #1c232c; + --color-reaction-hover-bg: rgba(86, 133, 233, 0.22); + --color-reaction-active-bg: rgba(86, 133, 233, 0.32); + --color-tooltip-text: #14181e; + --color-tooltip-bg: #eef1f5; + --color-button: #3d4856; + --color-expand-button: #2d3641; + + /* badges */ + --color-red-badge: #e06464; + --color-red-badge-bg: rgba(224, 100, 100, 0.16); + --color-red-badge-bg-hover: rgba(224, 100, 100, 0.30); + --color-green-badge: #3bb97a; + --color-green-badge-bg: rgba(59, 185, 122, 0.16); + --color-green-badge-bg-hover: rgba(59, 185, 122, 0.30); + --color-yellow-badge: #d99a3a; + --color-yellow-badge-bg: rgba(217, 154, 58, 0.16); + --color-yellow-badge-bg-hover: rgba(217, 154, 58, 0.30); + --color-orange-badge: #d99a3a; + --color-orange-badge-bg: rgba(217, 154, 58, 0.16); + --color-orange-badge-bg-hover: rgba(217, 154, 58, 0.30); + + /* status indicators */ + --color-indicator-offline: #5f6975; + --color-indicator-idle: #d99a3a; + --color-indicator-active: #3bb97a; + + /* checkerboard (image diff) */ + --checkerboard-color-1: #161b22; + --checkerboard-color-2: #0f141a; + + /* project board */ + --color-project-board-bg: #0d1117; + + /* console */ + --color-console-fg: #e6e9ef; + --color-console-fg-subtle: #8b95a1; + --color-console-bg: #0b0f14; + --color-console-border: #1c232c; + --color-console-hover-bg: rgba(255, 255, 255, 0.06); + --color-console-active-bg: rgba(255, 255, 255, 0.10); + --color-console-menu-bg: #161b22; + --color-console-menu-border:#2d3641; + + accent-color: var(--color-accent); +} + +/* =========================================================================== + STRUCTURAL OVERRIDES — identical identity layer to the light theme. + ========================================================================== */ + +/* ── Type ───────────────────────────────────────────────────────────────── */ +body, +input, button, select, textarea, optgroup, +.ui, .ui.menu, .ui.header, .ui.form, .ui.dropdown, +.ui.dropdown .menu > .item, .ui.input > input, +.markup, h1, h2, h3, h4, h5, h6, +.repo-header, .repository .header-wrapper, .commit-summary, .file-info { + font-family: var(--fonts-proportional) !important; +} +code, pre, tt, kbd, samp, .mono, +.ui.input.mono, .commit-id, .sha, .ui.label.commit-id, +.code-view, .lines-code, .lines-num, .CodeMirror, +.markup code, .markup pre, .markup tt { + font-family: var(--fonts-monospace) !important; +} +body { + letter-spacing: -0.005em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Primary / positive buttons ─────────────────────────────────────────── */ +.ui.primary.button, +.ui.primary.buttons .button, +.ui.positive.button, +.ui.positive.buttons .button { + background: var(--color-primary) !important; + color: #fff !important; + border-color: var(--color-primary) !important; + font-weight: 600; + box-shadow: none !important; +} +.ui.primary.button:hover, +.ui.primary.buttons .button:hover, +.ui.positive.button:hover, +.ui.positive.buttons .button:hover { + background: var(--color-primary-hover) !important; + border-color: var(--color-primary-hover) !important; +} + +/* ── Default / secondary buttons ────────────────────────────────────────── */ +.ui.button { + font-weight: 500; + box-shadow: none; +} +.ui.basic.button, +.ui.basic.buttons .button { + box-shadow: inset 0 0 0 1px var(--color-border) !important; +} + +/* ── Repo tabs — accent active label + accent underline ───────────────────── + The repo tab bar is Forgejo's `.ui.secondary.pointing.menu`; the active item's + colour is set by the full active/hover/focus/dropdown selector group, so we + match it verbatim to actually win the cascade. */ +.ui.secondary.pointing.menu .active.item, +.ui.secondary.pointing.menu .active.item:hover, +.ui.secondary.pointing.menu .active.item:focus, +.ui.secondary.pointing.menu .dropdown.item:hover, +.ui.secondary.pointing.menu .dropdown.item:focus, +.ui.tabular.menu .active.item { + color: var(--color-accent) !important; + border-color: var(--color-primary) !important; + font-weight: 600; +} +.ui.secondary.pointing.menu .item:hover { + color: var(--color-text) !important; +} +/* Tab count pills (12 · 3 · 4) render as plain `.ui.label` inside each item → + accent-tinted instead of flat grey. */ +.ui.secondary.pointing.menu .item .ui.label, +.ui.tabular.menu .item .ui.label { + background: var(--color-primary-alpha-10) !important; + color: var(--color-accent) !important; + border: 1px solid var(--color-primary-alpha-20) !important; +} +.ui.secondary.pointing.menu .active.item .ui.label, +.ui.tabular.menu .active.item .ui.label { + background: var(--color-primary) !important; + color: #fff !important; + border-color: var(--color-primary) !important; +} + +/* ── Repo header title — owner / name carry the accent (branded chrome) ── */ +.repo-header .flex-text-block a, +.repo-header .repo-title a, +.repository .header-wrapper .repo-title a { + color: var(--color-accent) !important; +} +.repo-header .flex-text-block a:hover { text-decoration: underline; } +/* Watch / Star / Fork stay button-coloured — never accent text */ +.repo-header .repo-buttons a, +.repo-header .repo-buttons .ui.button { + color: var(--color-text) !important; +} + +/* ── Repo topics → real accent pills (clearly blue, never grey) ───────────── + Forgejo's topic markup has shifted across versions (#repo-topics > a, + a.repo-topic, a.topic.label …) — match them all so the chips are always + branded, with a visible blue fill + ring instead of the pale wash. */ +#repo-topics a, +#repo-topics .ui.label, +a.repo-topic, +a.repo-topic.ui.label, +.repository-topics a.ui.label, +.topic.ui.label, +a.topic.label { + background: var(--color-primary-alpha-10) !important; + color: var(--color-accent) !important; + border: 1px solid var(--color-primary-alpha-30) !important; + border-radius: var(--border-radius-full) !important; + font-weight: 600 !important; +} +#repo-topics a:hover, +a.repo-topic:hover, +.topic.ui.label:hover { + background: var(--color-primary) !important; + color: #fff !important; + border-color: var(--color-primary) !important; +} + +/* ── Extra interactive coverage — the bits that quietly stay grey ────────── + Dropdowns, pagination, form focus, toggles and progress bars across the rest + of the app, pulled onto the accent so the brand reads everywhere, not just on + the repo home. Trim any rule you don't want. */ + +/* Dropdown / select menus — selected + active item in accent */ +.ui.dropdown .menu > .item.selected, +.ui.dropdown .menu > .item.active, +.ui.selection.dropdown .menu > .item.active.selected { + color: var(--color-accent) !important; + background: var(--color-primary-alpha-10) !important; +} + +/* Pagination — current page in solid accent */ +.ui.pagination.menu .active.item { + background: var(--color-primary) !important; + color: #fff !important; +} + +/* Form fields — accent focus ring instead of the default grey/teal */ +.ui.input input:focus, +.ui.form input:focus, +.ui.form textarea:focus, +.ui.selection.active.dropdown, +.ui.selection.dropdown:focus { + border-color: var(--color-primary) !important; + box-shadow: 0 0 0 2px var(--color-primary-alpha-20) !important; +} + +/* Checkboxes, radios, toggles — checked state in accent */ +.ui.checkbox input:checked ~ .box::before, +.ui.checkbox input:checked ~ label::before, +.ui.radio.checkbox input:checked ~ .box::before, +.ui.radio.checkbox input:checked ~ label::before, +.ui.toggle.checkbox input:checked ~ .box::before, +.ui.toggle.checkbox input:checked ~ label::before { + background: var(--color-primary) !important; + border-color: var(--color-primary) !important; +} + +/* Progress bars (upload, migration, theme-driven stats) */ +.ui.progress .bar { + background: var(--color-primary) !important; +} + +/* ── Cards / segments ───────────────────────────────────────────────────── */ +.ui.segment, +.ui.segments, +.ui.attached.segment, +.repository-summary, +.repo-description-box, +.ui.card, +.ui.cards > .card { + box-shadow: 0 1px 0 var(--color-shadow); +} +.ui.segments, +.ui.card, +.ui.cards > .card { + border-radius: var(--border-radius-medium); +} + +/* ── Links ──────────────────────────────────────────────────────────────── */ +a { text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Octicons ───────────────────────────────────────────────────────────── */ +.svg, .gt-octicon, .octicon { fill: currentColor; } diff --git a/dev/theme/theme-kbenestad-light.css b/dev/theme/theme-kbenestad-light.css new file mode 100644 index 0000000..ff85472 --- /dev/null +++ b/dev/theme/theme-kbenestad-light.css @@ -0,0 +1,472 @@ +/* ============================================================================ + theme-kbenestad-light.css + kBenestad — Forgejo theme (LIGHT) + Nordic minimal: cool paper ground, near-black slate ink, one calm blue. + + Install: drop this file in custom/public/assets/css/ + then in app.ini [ui] + THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-light,kbenestad-dark + DEFAULT_THEME = kbenestad-light + ---------------------------------------------------------------------------- + Strategy: a COMPLETE, self-contained variable set (so the theme renders + correctly even when Forgejo's shipped base theme isn't present) followed by + the structural overrides that give kBenestad its identity — Schibsted Grotesk + type, accent-soft topic pills, flat buttons, hairline cards. + + The forgejo-light import below is a harmless safety net: if the shipped file + exists it fills any future upstream variables; if it's missing it's ignored. + ========================================================================== */ + +@import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap'); +@import "./theme-forgejo-light.css"; + +:root { + color-scheme: light; + --is-dark-theme: false; + + /* ── Type ────────────────────────────────────────────────────────────────── + Forgejo resolves body type as var(--fonts-override, var(--fonts-proportional)), + "Noto Sans", … — so these vars ARE the hook. Marked !important to win even + if the shipped base theme sets them !important first (per Forgejo's docs). */ + --fonts-proportional: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important; + --fonts-regular: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important; + --fonts-monospace: "JetBrains Mono", ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace !important; + + /* ── Corners (kBenestad: 6px workhorse, 8px cards, small + deliberate) ──── */ + --border-radius: 6px; + --border-radius-medium: 8px; + --border-radius-full: 99999px; + + /* ── Accent — Nordic blue #2f6fed and its ramp ──────────────────────────── */ + --color-primary: #2f6fed; + --color-primary-contrast: #ffffff; + --color-primary-dark-1: #1f57cf; + --color-primary-dark-2: #1b46a3; + --color-primary-dark-3: #163b88; + --color-primary-dark-4: #142e63; + --color-primary-dark-5: #102450; + --color-primary-dark-6: #0c1c3e; + --color-primary-dark-7: #08142c; + --color-primary-light-1: #5685e9; + --color-primary-light-2: #88abf2; + --color-primary-light-3: #b7cdf8; + --color-primary-light-4: #d9e4fc; + --color-primary-light-5: #eef3fe; + --color-primary-light-6: #f5f8ff; + --color-primary-light-7: #fafcff; + --color-primary-alpha-10: rgba(47, 111, 237, 0.10); + --color-primary-alpha-20: rgba(47, 111, 237, 0.20); + --color-primary-alpha-30: rgba(47, 111, 237, 0.30); + --color-primary-alpha-40: rgba(47, 111, 237, 0.40); + --color-primary-alpha-50: rgba(47, 111, 237, 0.50); + --color-primary-alpha-60: rgba(47, 111, 237, 0.60); + --color-primary-alpha-70: rgba(47, 111, 237, 0.70); + --color-primary-alpha-80: rgba(47, 111, 237, 0.80); + --color-primary-alpha-90: rgba(47, 111, 237, 0.90); + --color-primary-hover: #1f57cf; + --color-primary-active: #1b46a3; + + /* ── Secondary (neutral fills, borders, secondary buttons) ──────────────── */ + --color-secondary: #e7eaef; + --color-secondary-dark-1: #d8dde4; + --color-secondary-dark-2: #c7cdd6; + --color-secondary-dark-3: #aab2bd; + --color-secondary-dark-4: #97a0ac; + --color-secondary-dark-5: #7b8694; + --color-secondary-dark-6: #6b7785; + --color-secondary-dark-7: #56606d; + --color-secondary-dark-8: #3a434f; + --color-secondary-dark-9: #2c343d; + --color-secondary-dark-10: #232a33; + --color-secondary-dark-11: #1b212a; + --color-secondary-dark-12: #14181e; + --color-secondary-dark-13: #0f141a; + --color-secondary-light-1: #eef0f4; + --color-secondary-light-2: #f1f3f6; + --color-secondary-light-3: #f5f6f9; + --color-secondary-light-4: #f8f9fb; + --color-secondary-alpha-10: rgba(123, 134, 148, 0.10); + --color-secondary-alpha-20: rgba(123, 134, 148, 0.20); + --color-secondary-alpha-30: rgba(123, 134, 148, 0.30); + --color-secondary-alpha-40: rgba(123, 134, 148, 0.40); + --color-secondary-alpha-50: rgba(123, 134, 148, 0.50); + --color-secondary-alpha-60: rgba(123, 134, 148, 0.60); + --color-secondary-alpha-70: rgba(123, 134, 148, 0.70); + --color-secondary-alpha-80: rgba(123, 134, 148, 0.80); + --color-secondary-alpha-90: rgba(123, 134, 148, 0.90); + --color-secondary-button: #d8dde4; + --color-secondary-hover: #f1f3f6; + --color-secondary-active: #e7eaef; + + /* ── Text / ink ─────────────────────────────────────────────────────────── */ + --color-text-dark: #0d1117; + --color-text: #14181e; + --color-text-light: #3a434f; + --color-text-light-1: #56606d; + --color-text-light-2: #7b8694; + --color-text-light-3: #aab2bd; + --color-placeholder-text: #aab2bd; + --color-text-focus: #ffffff; + + /* ── Surfaces ──────────────────────────────────────────────────────────── */ + --color-body: #f8f9fb; /* page ground (cool paper) */ + --color-box-body: #ffffff; /* cards, file list, panels */ + --color-box-body-highlight: #f5f8ff; + --color-box-header: #f8f9fb; /* segment / table headers */ + --color-nav-bg: #ffffff; /* top navbar */ + --color-nav-hover-bg: #f1f3f6; + --color-secondary-nav-bg: #f8f9fb; + --color-footer: #ffffff; + --color-light: rgba(123, 134, 148, 0.04); + --color-light-mimic-enabled: rgba(0, 0, 0, calc(6 / 255)); + --color-light-border: #e7eaef; + --color-hover: #f1f3f6; /* row / item hover */ + --color-active: #e7eaef; /* active row */ + --color-menu: #ffffff; + --color-card: #ffffff; + --fancy-card-bg: #ffffff; + --fancy-card-border: #e7eaef; + --color-markup-tab-default: var(--color-box-header); + --color-markup-tab-active: var(--color-box-body); + --color-header-wrapper: #ffffff; + --color-header-wrapper-transparent: rgba(255, 255, 255, 0); + + /* ── Inputs ────────────────────────────────────────────────────────────── */ + --color-input-text: #14181e; + --color-input-background: #ffffff; + --color-input-toggle-background: #ffffff; + --color-input-border: #d8dde4; + --color-input-border-hover: #aab2bd; + + /* ── Borders / dividers ────────────────────────────────────────────────── */ + --color-border: #e7eaef; + --color-secondary-bg: #f1f3f6; + + /* ── Code / markup ─────────────────────────────────────────────────────── */ + --color-markup-code-block: #f1f3f6; + --color-markup-code-inline: #eef0f4; + --color-markup-table-row: rgba(0, 0, 0, 0.024); + --color-code-bg: #ffffff; + --color-code-sidebar-bg: #f8f9fb; + + /* ── Shadows (soft, cool, low opacity) ─────────────────────────────────── */ + --color-shadow: rgba(20, 24, 30, 0.06); + --color-secondary-shadow: rgba(20, 24, 30, 0.04); + + /* ── Accent line / selection / timeline / caret ────────────────────────── */ + --color-accent: #2f6fed; + --color-small-accent: #eef3fe; + --color-active-line: #eef3fe; + --color-editor-line-highlight: #f5f8ff; + --color-timeline: #e7eaef; + --color-caret: #14181e; + --color-highlight-fg: #1f57cf; + --color-highlight-bg: rgba(47, 111, 237, 0.10); + --color-selection-bg: #d9e4fc; + --color-selection-fg: #14181e; + --color-overlay-backdrop: rgba(20, 24, 30, 0.32); + + /* ── Semantic hues (muted, never neon) ─────────────────────────────────── */ + --color-red: #d64545; + --color-orange: #c9851f; + --color-yellow: #c9851f; + --color-olive: #8a8a2f; + --color-green: #1f9d5f; + --color-teal: #1f8a8a; + --color-blue: #2f6fed; + --color-violet: #6b5fd2; + --color-purple: #8a4fd2; + --color-pink: #cf4f8a; + --color-brown: #8a6b4f; + --color-grey: #7b8694; + --color-gold: #c9851f; + --color-white: #ffffff; + --color-black: #14181e; + --color-pure-black: #000000; + + /* light variants */ + --color-red-light: #e06464; + --color-green-light: #34b677; + --color-blue-light: #5685e9; + --color-grey-light: #aab2bd; + + /* dark-1 variants */ + --color-red-dark-1: #b8332f; + --color-green-dark-1: #178049; + --color-blue-dark-1: #1f57cf; + + /* Status text / background / border */ + --color-success-text: #178049; + --color-success-bg: #d7f0e1; + --color-success-border: #aee0c4; + --color-error-text: #b8332f; + --color-error-bg: #fadcdc; + --color-error-bg-active:#f3bcbc; + --color-error-bg-hover: #fbe6e6; + --color-error-border: #f3bcbc; + --color-warning-text: #a86c14; + --color-warning-bg: #fbeacb; + --color-warning-border: #f3d79a; + --color-info-text: #1b46a3; + --color-info-bg: #eef3fe; + --color-info-border: #b7cdf8; + --color-danger-bg: #fbeeee; + + /* Diff */ + --color-diff-removed-word-bg: #f3bcbc; + --color-diff-added-word-bg: #aee0c4; + --color-diff-removed-row-bg: #fbeeee; + --color-diff-added-row-bg: #e9f7ef; + --color-diff-removed-row-border: #f3d2d2; + --color-diff-added-row-border: #cfeadd; + --color-diff-moved-row-bg: #fbeacb; + --color-diff-moved-row-border:#f3d79a; + --color-diff-inactive: #f1f3f6; + + /* Labels / reactions / tooltip / nav */ + --color-label-text: #3a434f; + --color-label-bg: #e7eaef; + --color-label-hover-bg: #d8dde4; + --color-label-active-bg: #c7cdd6; + --color-reaction-bg: #f1f3f6; + --color-reaction-hover-bg: rgba(47, 111, 237, 0.20); + --color-reaction-active-bg: rgba(47, 111, 237, 0.30); + --color-tooltip-text: #ffffff; + --color-tooltip-bg: #232a33; + --color-button: #d8dde4; + --color-expand-button: #e7eaef; + + /* badges */ + --color-red-badge: #d64545; + --color-red-badge-bg: rgba(214, 69, 69, 0.13); + --color-red-badge-bg-hover: rgba(214, 69, 69, 0.27); + --color-green-badge: #1f9d5f; + --color-green-badge-bg: rgba(31, 157, 95, 0.13); + --color-green-badge-bg-hover: rgba(31, 157, 95, 0.27); + --color-yellow-badge: #c9851f; + --color-yellow-badge-bg: rgba(201, 133, 31, 0.13); + --color-yellow-badge-bg-hover: rgba(201, 133, 31, 0.27); + --color-orange-badge: #c9851f; + --color-orange-badge-bg: rgba(201, 133, 31, 0.13); + --color-orange-badge-bg-hover: rgba(201, 133, 31, 0.27); + + /* status indicators */ + --color-indicator-offline: #aab2bd; + --color-indicator-idle: #c9851f; + --color-indicator-active: #1f9d5f; + + /* checkerboard (image diff) */ + --checkerboard-color-1: #ffffff; + --checkerboard-color-2: #f1f3f6; + + /* project board */ + --color-project-board-bg: #f8f9fb; + + /* console — calm dark surface */ + --color-console-fg: #e6e9ef; + --color-console-fg-subtle: #8b95a1; + --color-console-bg: #161b22; + --color-console-border: #232a33; + --color-console-hover-bg: rgba(255, 255, 255, 0.06); + --color-console-active-bg: rgba(255, 255, 255, 0.10); + --color-console-menu-bg: #1c232c; + --color-console-menu-border:#2d3641; + + accent-color: var(--color-accent); +} + +/* =========================================================================== + STRUCTURAL OVERRIDES — kBenestad identity, mirroring the design mockup. + Scoped, conservative, modeled on real Forgejo / Semantic-UI selectors. + ========================================================================== */ + +/* ── Type: enforce the kBenestad faces everywhere ─────────────────────────── + Setting the variable alone isn't enough when a privacy blocker or CSP stops + the web font from loading on the var fallback. We force the family directly, + so the moment the font is available (web or self-hosted) it renders. */ +body, +input, button, select, textarea, optgroup, +.ui, .ui.menu, .ui.header, .ui.form, .ui.dropdown, +.ui.dropdown .menu > .item, .ui.input > input, +.markup, h1, h2, h3, h4, h5, h6, +.repo-header, .repository .header-wrapper, .commit-summary, .file-info { + font-family: var(--fonts-proportional) !important; +} +code, pre, tt, kbd, samp, .mono, +.ui.input.mono, .commit-id, .sha, .ui.label.commit-id, +.code-view, .lines-code, .lines-num, .CodeMirror, +.markup code, .markup pre, .markup tt { + font-family: var(--fonts-monospace) !important; +} +body { + letter-spacing: -0.005em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Primary / positive buttons — flat accent, no gloss ─────────────────── */ +.ui.primary.button, +.ui.primary.buttons .button, +.ui.positive.button, +.ui.positive.buttons .button { + background: var(--color-primary) !important; + color: #fff !important; + border-color: var(--color-primary) !important; + font-weight: 600; + box-shadow: none !important; +} +.ui.primary.button:hover, +.ui.primary.buttons .button:hover, +.ui.positive.button:hover, +.ui.positive.buttons .button:hover { + background: var(--color-primary-hover) !important; + border-color: var(--color-primary-hover) !important; +} + +/* ── Default / secondary buttons — calm, hairline, no shadow ────────────── */ +.ui.button { + font-weight: 500; + box-shadow: none; +} +.ui.basic.button, +.ui.basic.buttons .button { + box-shadow: inset 0 0 0 1px var(--color-border) !important; +} + +/* ── Repo tabs — accent active label + accent underline ───────────────────── + The repo tab bar is Forgejo's `.ui.secondary.pointing.menu`; the active item's + colour is set by the full active/hover/focus/dropdown selector group, so we + match it verbatim to actually win the cascade. */ +.ui.secondary.pointing.menu .active.item, +.ui.secondary.pointing.menu .active.item:hover, +.ui.secondary.pointing.menu .active.item:focus, +.ui.secondary.pointing.menu .dropdown.item:hover, +.ui.secondary.pointing.menu .dropdown.item:focus, +.ui.tabular.menu .active.item { + color: var(--color-accent) !important; + border-color: var(--color-primary) !important; + font-weight: 600; +} +.ui.secondary.pointing.menu .item:hover { + color: var(--color-text) !important; +} +/* Tab count pills (12 · 3 · 4) render as plain `.ui.label` inside each item → + accent-tinted instead of flat grey. */ +.ui.secondary.pointing.menu .item .ui.label, +.ui.tabular.menu .item .ui.label { + background: var(--color-primary-alpha-10) !important; + color: var(--color-accent) !important; + border: 1px solid var(--color-primary-alpha-20) !important; +} +.ui.secondary.pointing.menu .active.item .ui.label, +.ui.tabular.menu .active.item .ui.label { + background: var(--color-primary) !important; + color: #fff !important; + border-color: var(--color-primary) !important; +} + +/* ── Repo header title — owner / name carry the accent (branded chrome) ── */ +.repo-header .flex-text-block a, +.repo-header .repo-title a, +.repository .header-wrapper .repo-title a { + color: var(--color-accent) !important; +} +.repo-header .flex-text-block a:hover { text-decoration: underline; } +/* Watch / Star / Fork stay button-coloured — never accent text */ +.repo-header .repo-buttons a, +.repo-header .repo-buttons .ui.button { + color: var(--color-text) !important; +} + +/* ── Repo topics → real accent pills (clearly blue, never grey) ───────────── + Forgejo's topic markup has shifted across versions (#repo-topics > a, + a.repo-topic, a.topic.label …) — match them all so the chips are always + branded, with a visible blue fill + ring instead of the pale wash. */ +#repo-topics a, +#repo-topics .ui.label, +a.repo-topic, +a.repo-topic.ui.label, +.repository-topics a.ui.label, +.topic.ui.label, +a.topic.label { + background: var(--color-primary-alpha-10) !important; + color: var(--color-accent) !important; + border: 1px solid var(--color-primary-alpha-30) !important; + border-radius: var(--border-radius-full) !important; + font-weight: 600 !important; +} +#repo-topics a:hover, +a.repo-topic:hover, +.topic.ui.label:hover { + background: var(--color-primary) !important; + color: #fff !important; + border-color: var(--color-primary) !important; +} + +/* ── Extra interactive coverage — the bits that quietly stay grey ────────── + Dropdowns, pagination, form focus, toggles and progress bars across the rest + of the app, pulled onto the accent so the brand reads everywhere, not just on + the repo home. Trim any rule you don't want. */ + +/* Dropdown / select menus — selected + active item in accent */ +.ui.dropdown .menu > .item.selected, +.ui.dropdown .menu > .item.active, +.ui.selection.dropdown .menu > .item.active.selected { + color: var(--color-accent) !important; + background: var(--color-primary-alpha-10) !important; +} + +/* Pagination — current page in solid accent */ +.ui.pagination.menu .active.item { + background: var(--color-primary) !important; + color: #fff !important; +} + +/* Form fields — accent focus ring instead of the default grey/teal */ +.ui.input input:focus, +.ui.form input:focus, +.ui.form textarea:focus, +.ui.selection.active.dropdown, +.ui.selection.dropdown:focus { + border-color: var(--color-primary) !important; + box-shadow: 0 0 0 2px var(--color-primary-alpha-20) !important; +} + +/* Checkboxes, radios, toggles — checked state in accent */ +.ui.checkbox input:checked ~ .box::before, +.ui.checkbox input:checked ~ label::before, +.ui.radio.checkbox input:checked ~ .box::before, +.ui.radio.checkbox input:checked ~ label::before, +.ui.toggle.checkbox input:checked ~ .box::before, +.ui.toggle.checkbox input:checked ~ label::before { + background: var(--color-primary) !important; + border-color: var(--color-primary) !important; +} + +/* Progress bars (upload, migration, theme-driven stats) */ +.ui.progress .bar { + background: var(--color-primary) !important; +} + +/* ── Cards / segments — 8px corners, hairline border, faint cool shadow ─── */ +.ui.segment, +.ui.segments, +.ui.attached.segment, +.repository-summary, +.repo-description-box, +.ui.card, +.ui.cards > .card { + box-shadow: 0 1px 0 var(--color-shadow); +} +.ui.segments, +.ui.card, +.ui.cards > .card { + border-radius: var(--border-radius-medium); +} + +/* ── Links — accent, underline only on hover (matches mockup chrome) ────── */ +a { text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Octicons stay legible on light chrome ──────────────────────────────── */ +.svg, .gt-octicon, .octicon { fill: currentColor; } diff --git a/dev/ui_kits/gitxt/index.html b/dev/ui_kits/gitxt/index.html new file mode 100644 index 0000000..6ec1843 --- /dev/null +++ b/dev/ui_kits/gitxt/index.html @@ -0,0 +1,133 @@ + + + + + + + +gitxt + + + + +
+ + + + + + + diff --git a/dev/ui_kits/gitxt/pages.jsx b/dev/ui_kits/gitxt/pages.jsx new file mode 100644 index 0000000..78755d9 --- /dev/null +++ b/dev/ui_kits/gitxt/pages.jsx @@ -0,0 +1,128 @@ +// gitxt — teletext for git. Pages addressed by 3-digit numbers; navigate by +// typing digits or clicking the coloured FASTEXT bar. Authentic teletext look. +const { useState, useEffect, useRef } = React; + +// colour helpers (teletext palette) +const C = ({ children }) => {children}; // cyan +const Y = ({ children }) => {children}; // yellow +const G = ({ children }) => {children}; // green +const R = ({ children }) => {children}; // red +const M = ({ children }) => {children}; // magenta +const W = ({ children }) => {children}; // white + +const Row = ({ children, center }) => ( +
{children || '\u00A0'}
+); +// double-height title block +const Title = ({ color = '#3fe0e0', children }) => ( +
{children}
+); +const Link = ({ n, go, children }) => ( + go(n)}>{n} {children} +); + +const PAGES = { + 100: { fast: [200, 300, 400, 100], render: (go) => (<> + gitxt + teletext for git · kBenestad + + ━━━━━━━━━━━━━━ index ━━━━━━━━━━━━━━ + + repositories + recent commits + open issues + build status + help & navigation + + type a page number, or use the + coloured buttons below. + ) }, + + 200: { fast: [210, 220, 300, 100], render: (go) => (<> + repositories + page 200 · 8 repos tracked + + kbpkg      v2.4.0 + gitxt      v0.3.0 + mdcms      v0.6.1 + capcms     v0.2.0 + invoice    v1.1.0 + timesheet  v1.0.0 + + select a repo for detail. + ) }, + + 210: { fast: [300, 400, 200, 100], render: (go) => (<> + kbpkg + repo 210 · v2.4.0 · main + + git-based package manager + + branch  main  ↑0 ↓0 + commits 1,284 + open    3 issues + build   ● passing + + last: fix: resolve nested deps + by karl · 1 day ago + ) }, + + 300: { fast: [310, 200, 400, 100], render: (go) => (<> + recent commits + page 300 · all repos + + a3f1 fix: resolve nested deps + kbpkg · 1d + 9c02 feat: number navigation + gitxt · 2d + 1e7d chore: bump cms/md + mdcms · 4d + b840 fix: locale rounding + invoice · 5d + ) }, + + 400: { fast: [200, 300, 500, 100], render: (go) => (<> + open issues + page 400 · 6 open + + #42 resolve circular dep graph + kbpkg · high + #38 page 9xx reserved range + gitxt · low + #31 fr-NO plural forms + mdcms · medium + ) }, + + 500: { fast: [200, 300, 400, 100], render: (go) => (<> + build status + page 500 · last 24h + + ● passing  kbpkg + ● passing  gitxt + ● passing  invoice + ● pending  mdcms + ● failing  capcms + + capcms: test timeout in + case-export suite. + ) }, + + 888: { fast: [100, 200, 300, 100], render: (go) => (<> + help + page 888 + + type any 3-digit page number + to jump straight to it. + + 100 index + 200 repositories + 300 commits + + coloured buttons jump to the + four pages shown at the foot. + ) }, +}; +// alias detail pages +[220, 230, 240, 250, 260, 310].forEach(n => { if (!PAGES[n]) PAGES[n] = PAGES[210]; }); + +Object.assign(window, { PAGES, C, Y, G, R, M, W, Row, Title }); diff --git a/dev/ui_kits/invoice/app.jsx b/dev/ui_kits/invoice/app.jsx new file mode 100644 index 0000000..ee8c3e2 --- /dev/null +++ b/dev/ui_kits/invoice/app.jsx @@ -0,0 +1,152 @@ +// invoice app — sidebar shell, invoice list, invoice detail +const { Icon, Button, StatusBadge, Avatar, kr, useState } = window; + +function Sidebar() { + const nav = [ + { icon: 'file', label: 'Invoices', active: true }, + { icon: 'users', label: 'Clients' }, + { icon: 'chart', label: 'Reports' }, + { icon: 'settings', label: 'Settings' }, + ]; + const apps = [['inv', 'invoice', true], ['ts', 'timesheet', false], ['re', 'reimburse', false]]; + return ( + + ); +} + +function InvoiceList({ onOpen }) { + const { invoices } = window.INVOICE_DATA; + const [q, setQ] = useState(''); + const list = invoices.filter(i => (i.id + i.client).toLowerCase().includes(q.toLowerCase())); + const outstanding = invoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((s, i) => s + i.amount, 0); + const paid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + i.amount, 0); + return ( +
+
+

Invoices

+ +
+
+
Outstandingkr {kr(outstanding)}
+
Paid this periodkr {kr(paid)}
+
Open invoices{invoices.filter(i => i.status !== 'paid' && i.status !== 'draft').length}
+
+
+ + setQ(e.target.value)} placeholder="Search invoices or clients…" /> +
+
+ + + + + + {list.map(inv => ( + onOpen(inv)}> + + + + + + + + ))} + +
InvoiceClientStatusDueAmount
{inv.id}{inv.client}{inv.due}kr {kr(inv.amount)}
+
+
+ ); +} + +function InvoiceDetail({ inv, onBack }) { + const { from } = window.INVOICE_DATA; + const subtotal = inv.items.reduce((s, [, qty, rate]) => s + qty * rate, 0); + const vat = Math.round(subtotal * 0.25); + const total = subtotal + vat; + return ( +
+
+ +
+ {inv.status !== 'paid' && } + + {inv.status === 'draft' + ? + : } +
+
+
+
+
+
{inv.id}
+ +
+
+ {from.org} + {from.name} + {from.email} + {from.orgnr} +
+
+
+
Billed to
{inv.client}
+
+
Issued
{inv.issued}
+
Due
{inv.due}
+
+
+ + + + {inv.items.map(([desc, qty, rate], i) => ( + + ))} + +
DescriptionQtyRateAmount
{desc}{qty}kr {kr(rate)}kr {kr(qty * rate)}
+
+
Subtotalkr {kr(subtotal)}
+
VAT 25%kr {kr(vat)}
+
Totalkr {kr(total)}
+
+
+
+ ); +} + +function App() { + const [inv, setInv] = useState(null); + return ( +
+ +
+ {inv ? setInv(null)} /> : } +
+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/dev/ui_kits/invoice/data.js b/dev/ui_kits/invoice/data.js new file mode 100644 index 0000000..bc5bccd --- /dev/null +++ b/dev/ui_kits/invoice/data.js @@ -0,0 +1,16 @@ +// invoice — mock data for the internal-tools app family +window.INVOICE_DATA = { + invoices: [ + { id: 'INV-0042', client: 'Nordlys Media AS', amount: 18400, currency: 'NOK', status: 'paid', issued: '12 May', due: '26 May', + items: [['Design system audit', 1, 14000], ['Component build', 1, 4400]] }, + { id: 'INV-0041', client: 'Bergen Legal Aid', amount: 9600, currency: 'NOK', status: 'sent', issued: '02 Jun', due: '16 Jun', + items: [['capcms monthly retainer', 1, 9600]] }, + { id: 'INV-0040', client: 'Fjord Software', amount: 26250, currency: 'NOK', status: 'overdue', issued: '18 Apr', due: '02 May', + items: [['kbpkg integration', 35, 750]] }, + { id: 'INV-0039', client: 'Oslo Kommune', amount: 12000, currency: 'NOK', status: 'draft', issued: '—', due: '—', + items: [['mdcms migration', 1, 12000]] }, + { id: 'INV-0038', client: 'Nordlys Media AS', amount: 7200, currency: 'NOK', status: 'paid', issued: '28 Apr', due: '12 May', + items: [['Maintenance', 12, 600]] }, + ], + from: { name: 'Karl Benestad', org: 'kBenestad', email: 'karl@kbenestad.no', orgnr: 'NO 998 877 665' }, +}; diff --git a/dev/ui_kits/invoice/index.html b/dev/ui_kits/invoice/index.html new file mode 100644 index 0000000..1817548 --- /dev/null +++ b/dev/ui_kits/invoice/index.html @@ -0,0 +1,106 @@ + + + + + + + +invoice — kBenestad + + + + +
+ + + + + + + + diff --git a/dev/ui_kits/invoice/ui.jsx b/dev/ui_kits/invoice/ui.jsx new file mode 100644 index 0000000..83ad05e --- /dev/null +++ b/dev/ui_kits/invoice/ui.jsx @@ -0,0 +1,41 @@ +// invoice UI primitives + icons (mirrors design-system components via .kb-* classes) +const { useState } = React; + +const ICONS = { + file: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6ZM14 2v6h6M8 13h8M8 17h8M8 9h2', + users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8', + chart: 'M3 3v18h18M7 16v-5M12 16V8M17 16v-9', + settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2V21a2 2 0 1 1-4 0v-.1A1.7 1.7 0 0 0 6 19.4l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H2a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 6l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9.4A1.7 1.7 0 0 0 11 2.1V2a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9.4a1.7 1.7 0 0 0 2.1 1.6H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z', + plus: 'M12 5v14M5 12h14', + download: 'M12 3v12m0 0 4-4m-4 4-4-4M5 21h14', + send: 'M22 2 11 13M22 2l-7 20-4-9-9-4 20-7Z', + check: 'M20 6 9 17l-5-5', + search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z', + chevron: 'm9 6 6 6-6 6', + back: 'm15 18-6-6 6-6', + clock: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z', +}; + +function Icon({ name, size = 16, strokeWidth = 2, style }) { + return ; +} + +function Button({ variant = 'primary', size, leftIcon, className = '', children, ...rest }) { + const cls = ['kb-btn', `kb-btn--${variant}`, size && `kb-btn--${size}`, className].filter(Boolean).join(' '); + return ; +} + +const STATUS_TONE = { paid: 'success', sent: 'accent', draft: 'neutral', overdue: 'danger' }; +function StatusBadge({ status }) { + return {status}; +} + +function Avatar({ name = '', size = 32, square }) { + const initials = name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase(); + return {initials}; +} + +const kr = (n) => n.toLocaleString('nb-NO'); + +Object.assign(window, { Icon, Button, StatusBadge, Avatar, kr, useState }); diff --git a/dev/ui_kits/kbpkg/data.js b/dev/ui_kits/kbpkg/data.js new file mode 100644 index 0000000..bbd8111 --- /dev/null +++ b/dev/ui_kits/kbpkg/data.js @@ -0,0 +1,57 @@ +// kbpkg — mock registry data +window.KBPKG_PACKAGES = [ + { + id: 'utils/fmt', name: 'utils/fmt', version: '1.3.0', updated: '3 days ago', + desc: 'Deterministic code formatter for kBenestad repos. Zero config, fast, opinionated.', + tags: ['cli', 'formatting', 'typescript'], installs: '1.2k', license: 'MIT', + repo: 'git@kb:utils/fmt', size: '12 kB', deps: 0, owner: 'Karl Benestad', + readme: 'A deterministic formatter. Point it at a directory and it rewrites every file to the canonical style — no options to argue about.', + versions: [['1.3.0','3 days ago'],['1.2.1','3 weeks ago'],['1.2.0','2 months ago'],['1.1.0','5 months ago']], + dependencies: [], + }, + { + id: 'core/log', name: 'core/log', version: '2.4.0', updated: '1 day ago', + desc: 'Structured, leveled logging with pretty terminal output and JSON for production.', + tags: ['logging', 'observability'], installs: '3.8k', license: 'MIT', + repo: 'git@kb:core/log', size: '24 kB', deps: 1, owner: 'Karl Benestad', + readme: 'Leveled logging that looks good in a terminal and parses cleanly in production. Tiny surface area.', + versions: [['2.4.0','1 day ago'],['2.3.0','1 month ago'],['2.0.0','4 months ago']], + dependencies: [['ansi/color','^1.0.0']], + }, + { + id: 'git/sync', name: 'git/sync', version: '0.9.2', updated: '6 days ago', + desc: 'Bidirectional sync between kbpkg registry mirrors over plain git remotes.', + tags: ['git', 'registry', 'sync'], installs: '640', license: 'Apache-2.0', + repo: 'git@kb:git/sync', size: '58 kB', deps: 2, owner: 'kBenestad', + readme: 'Keeps two registry mirrors in lock-step using nothing but git. Resolves conflicts by version precedence.', + versions: [['0.9.2','6 days ago'],['0.9.0','3 weeks ago'],['0.8.0','2 months ago']], + dependencies: [['core/log','^2.0.0'],['utils/fmt','^1.0.0']], + }, + { + id: 'ansi/color', name: 'ansi/color', version: '1.0.0', updated: '2 months ago', + desc: 'Minimal ANSI color + style helpers. No dependencies, tree-shakeable.', + tags: ['cli', 'terminal'], installs: '5.1k', license: 'MIT', + repo: 'git@kb:ansi/color', size: '4 kB', deps: 0, owner: 'Karl Benestad', + readme: 'Just enough ANSI to make terminal output readable. Four functions, no theme system.', + versions: [['1.0.0','2 months ago'],['0.4.0','6 months ago']], + dependencies: [], + }, + { + id: 'cms/md', name: 'cms/md', version: '0.6.1', updated: '2 weeks ago', + desc: 'Markdown + front-matter parser powering mdcms. Multilingual-aware.', + tags: ['markdown', 'cms', 'i18n'], installs: '410', license: 'MIT', + repo: 'git@kb:cms/md', size: '31 kB', deps: 1, owner: 'kBenestad', + readme: 'Parses Markdown with YAML front-matter and a light multilingual convention. Built for mdcms.', + versions: [['0.6.1','2 weeks ago'],['0.6.0','1 month ago']], + dependencies: [['utils/fmt','^1.2.0']], + }, + { + id: 'pdf/invoice', name: 'pdf/invoice', version: '1.1.0', updated: '5 weeks ago', + desc: 'Render invoices to print-ready PDF. Used by the invoice and reimburse apps.', + tags: ['pdf', 'invoicing'], installs: '290', license: 'MIT', + repo: 'git@kb:pdf/invoice', size: '88 kB', deps: 1, owner: 'Karl Benestad', + readme: 'Takes a small JSON document and renders a clean, print-ready PDF invoice. Currency- and locale-aware.', + versions: [['1.1.0','5 weeks ago'],['1.0.0','3 months ago']], + dependencies: [['core/log','^2.0.0']], + }, +]; diff --git a/dev/ui_kits/kbpkg/index.html b/dev/ui_kits/kbpkg/index.html new file mode 100644 index 0000000..91bddfc --- /dev/null +++ b/dev/ui_kits/kbpkg/index.html @@ -0,0 +1,118 @@ + + + + + + + +kbpkg — registry + + + + +
+ + + + + + + + + diff --git a/dev/ui_kits/kbpkg/screens.jsx b/dev/ui_kits/kbpkg/screens.jsx new file mode 100644 index 0000000..f46405c --- /dev/null +++ b/dev/ui_kits/kbpkg/screens.jsx @@ -0,0 +1,179 @@ +// kbpkg screens — Header, install command, registry list, package detail. +const { Icon, Button, Badge, Tag, Avatar, Lockup, useState } = window; + +function Header({ view, onHome }) { + const nav = ['Packages', 'Docs', 'Changelog']; + return ( +
+ + +
+ + +
+
+ ); +} + +function CopyCommand({ id }) { + const [copied, setCopied] = useState(false); + const cmd = `kbpkg add ${id}`; + const copy = () => { setCopied(true); setTimeout(() => setCopied(false), 1400); }; + return ( +
+ $ + {cmd} + +
+ ); +} + +function PackageRow({ pkg, onOpen }) { + return ( +
onOpen(pkg)}> + + + +
+
+ {pkg.name} + v{pkg.version} +
+

{pkg.desc}

+
{pkg.tags.map(t => {t})}
+
+
+ {pkg.installs} + {pkg.updated} +
+
+ ); +} + +function Registry({ onOpen }) { + const all = window.KBPKG_PACKAGES; + const [q, setQ] = useState(''); + const [filter, setFilter] = useState(null); + const tags = [...new Set(all.flatMap(p => p.tags))].slice(0, 8); + const list = all.filter(p => { + const matchQ = !q || (p.name + ' ' + p.desc).toLowerCase().includes(q.toLowerCase()); + const matchT = !filter || p.tags.includes(filter); + return matchQ && matchT; + }); + return ( +
+
+ kbpkg registry +

A package manager for me.

+

Git-based packages for the kBenestad apps. Install anything with one command.

+
+ + setQ(e.target.value)} placeholder="Search packages…" /> +
+
+ setFilter(null)} active={!filter}>all + {tags.map(t => setFilter(t === filter ? null : t)} active={t === filter}>{t})} +
+
+
+ {list.length} package{list.length === 1 ? '' : 's'} + sorted by recently updated +
+
+ {list.map(p => )} + {list.length === 0 &&
No packages match “{q}”.
} +
+
+ ); +} + +function MetaRow({ icon, label, children }) { + return ( +
+ {label} + {children} +
+ ); +} + +function PackagePage({ pkg, onHome, onOpen }) { + const [tab, setTab] = useState('readme'); + const tabs = [ + { id: 'readme', label: 'Readme' }, + { id: 'versions', label: `Versions` }, + { id: 'deps', label: 'Dependencies' }, + ]; + return ( +
+
Packages{pkg.name}
+
+
+

{pkg.name} v{pkg.version}

+

{pkg.desc}

+
+ published +
+ +
+
+
+ {tabs.map(t => )} +
+
+ {tab === 'readme' && ( +
+

{pkg.name}

+

{pkg.readme}

+

Install with kbpkg add {pkg.id} and import what you need. Licensed under {pkg.license}.

+
+ )} + {tab === 'versions' && ( +
+ {pkg.versions.map(([v, when], i) => ( +
+ v{v}{i === 0 && latest} + {when} +
+ ))} +
+ )} + {tab === 'deps' && ( +
+ {pkg.dependencies.length === 0 &&
No dependencies — this package is self-contained.
} + {pkg.dependencies.map(([d, range]) => { + const dp = window.KBPKG_PACKAGES.find(p => p.id === d); + return ( +
dp && onOpen(dp)}> + {d} + {range} +
+ ); + })} +
+ )} +
+
+ +
+
+ ); +} + +Object.assign(window, { Header, Registry, PackagePage }); diff --git a/dev/ui_kits/kbpkg/ui.jsx b/dev/ui_kits/kbpkg/ui.jsx new file mode 100644 index 0000000..a2434cd --- /dev/null +++ b/dev/ui_kits/kbpkg/ui.jsx @@ -0,0 +1,69 @@ +// kbpkg UI primitives — mirror the kBenestad design-system components using the +// real .kb-* classes from components.css (linked via styles.css). Self-contained +// so the kit renders anywhere, not only inside the Design System tab. +const { useState } = React; + +const ICONS = { + search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z', + download: 'M12 3v12m0 0 4-4m-4 4-4-4M5 21h14', + copy: 'M9 9h10v10H9zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1', + check: 'M20 6 9 17l-5-5', + box: 'M21 8v8a2 2 0 0 1-1 1.7l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 16V8a2 2 0 0 1 1-1.7l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8ZM3.3 7 12 12l8.7-5M12 22V12', + branch: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a9 9 0 0 1-9 9', + clock: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z', + terminal: 'm4 17 6-6-6-6M12 19h8', + chevronR: 'm9 6 6 6-6 6', + external: 'M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6', + scale: 'M12 3v18M3 7h18M7 7l-3 7a3 3 0 0 0 6 0L7 7Zm10 0-3 7a3 3 0 0 0 6 0l-3-7ZM5 21h14', + drive: 'M22 12H2M5.5 6h13l3 6v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-6l3.5-6ZM6 16h.01M10 16h.01', + layers: 'm12 2 9 5-9 5-9-5 9-5ZM3 12l9 5 9-5M3 17l9 5 9-5', + user: 'M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z', + book: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5v15Z', + plus: 'M12 5v14M5 12h14', +}; + +function Icon({ name, size = 16, strokeWidth = 2, style }) { + return ( + + + + ); +} + +function Button({ variant = 'primary', size, iconOnly, leftIcon, className = '', children, ...rest }) { + const cls = ['kb-btn', `kb-btn--${variant}`, size && `kb-btn--${size}`, iconOnly && 'kb-btn--icon', className].filter(Boolean).join(' '); + return ; +} + +function Badge({ tone = 'neutral', dot, children }) { + return {dot && }{children}; +} + +function Tag({ children, onClick, active }) { + return {children}; +} + +function Avatar({ name = '', size = 32, square }) { + const initials = name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase(); + return {initials}; +} + +// Brand lockup (stack mark + wordmark) +function Lockup({ onClick }) { + return ( + + + + + + + kBenestad + + + ); +} + +Object.assign(window, { Icon, Button, Badge, Tag, Avatar, Lockup, useState });