From be4b4c463eb2966fe1b5472a6a1342b7087f339d Mon Sep 17 00:00:00 2001 From: kbenestad Date: Mon, 8 Jun 2026 09:59:37 +0700 Subject: [PATCH] Added design assets --- DESIGN.md | 227 ++--- .../favicons/apple-touch-icon.png | Bin 0 -> 4221 bytes dev/design_assets/favicons/favicon-16.png | Bin 0 -> 474 bytes dev/design_assets/favicons/favicon-32.png | Bin 0 -> 893 bytes dev/design_assets/favicons/favicon-48.png | Bin 0 -> 1361 bytes dev/design_assets/favicons/favicon.svg | 6 + dev/design_assets/favicons/icon-512.png | Bin 0 -> 16264 bytes dev/design_assets/favicons/site.webmanifest | 35 + dev/design_assets/reimburse-glyph.svg | 5 + dev/design_assets/reimburse.svg | 6 + dev/mockups/invoice.html | 168 ++++ dev/mockups/kbenestad-forms.css | 356 ++++++++ dev/mockups/reimburse.html | 326 +++++++ dev/mockups/review.html | 96 ++ dev/mockups/timesheet.html | 180 ++++ dev/mockups/tweaks-panel.jsx | 541 ++++++++++++ dev/theme/README.md | 159 ++++ dev/theme/kbenestad.yaml | 101 +++ dev/theme/preview.html | 827 ++++++++++++++++++ dev/theme/theme-kbenestad-auto.css | 15 + dev/theme/theme-kbenestad-dark.css | 464 ++++++++++ dev/theme/theme-kbenestad-light.css | 472 ++++++++++ dev/ui_kits/gitxt/index.html | 133 +++ dev/ui_kits/gitxt/pages.jsx | 128 +++ dev/ui_kits/invoice/app.jsx | 152 ++++ dev/ui_kits/invoice/data.js | 16 + dev/ui_kits/invoice/index.html | 106 +++ dev/ui_kits/invoice/ui.jsx | 41 + dev/ui_kits/kbpkg/data.js | 57 ++ dev/ui_kits/kbpkg/index.html | 118 +++ dev/ui_kits/kbpkg/screens.jsx | 179 ++++ dev/ui_kits/kbpkg/ui.jsx | 69 ++ 32 files changed, 4884 insertions(+), 99 deletions(-) create mode 100644 dev/design_assets/favicons/apple-touch-icon.png create mode 100644 dev/design_assets/favicons/favicon-16.png create mode 100644 dev/design_assets/favicons/favicon-32.png create mode 100644 dev/design_assets/favicons/favicon-48.png create mode 100644 dev/design_assets/favicons/favicon.svg create mode 100644 dev/design_assets/favicons/icon-512.png create mode 100644 dev/design_assets/favicons/site.webmanifest create mode 100644 dev/design_assets/reimburse-glyph.svg create mode 100644 dev/design_assets/reimburse.svg create mode 100644 dev/mockups/invoice.html create mode 100644 dev/mockups/kbenestad-forms.css create mode 100644 dev/mockups/reimburse.html create mode 100644 dev/mockups/review.html create mode 100644 dev/mockups/timesheet.html create mode 100644 dev/mockups/tweaks-panel.jsx create mode 100644 dev/theme/README.md create mode 100644 dev/theme/kbenestad.yaml create mode 100644 dev/theme/preview.html create mode 100644 dev/theme/theme-kbenestad-auto.css create mode 100644 dev/theme/theme-kbenestad-dark.css create mode 100644 dev/theme/theme-kbenestad-light.css create mode 100644 dev/ui_kits/gitxt/index.html create mode 100644 dev/ui_kits/gitxt/pages.jsx create mode 100644 dev/ui_kits/invoice/app.jsx create mode 100644 dev/ui_kits/invoice/data.js create mode 100644 dev/ui_kits/invoice/index.html create mode 100644 dev/ui_kits/invoice/ui.jsx create mode 100644 dev/ui_kits/kbpkg/data.js create mode 100644 dev/ui_kits/kbpkg/index.html create mode 100644 dev/ui_kits/kbpkg/screens.jsx create mode 100644 dev/ui_kits/kbpkg/ui.jsx 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 0000000000000000000000000000000000000000..b85a5fbdfeb3d164626f8c260aef4aa1d0161c9a GIT binary patch literal 4221 zcmaJ_XIN9swhkqfPz31#K?uD`FF}ydk)|L;ij+h|q=c%VAOw*vy~9V8Cej1~(rZGI z{(%Tc?@f9W5bpMT&w1|sanAj*_dIK6tu^o7Gi%MeCgPr=y{<$f|L5Qq+>tATj%f@sTzGLGpRQ&W{BdzDUjB(r$@-+~bt8rNgAV%jQ2@82(p zjC5KscGe{DMMhdlVDlC*4x`~(cv<@EU}XBo`-^7ryw1E@RI(0rxzy4UR3!Rm7i>j2 ze_c+W9C@GYd=L2OchE4~up_>j^>k)u`ReO*LEu=I7Io!^;Jfp8c;dK81=pzKdyF*% zoj%<(p}04c{vnZYZ>VlqRRxldJ!tcC`;>?EQuq0=}p|5BUXaHlpH*6C`Hz{(zRYm;#)6!1S*ocvKMr5cv>73Bd~0HsBFjS_)lx@8Wa%C*xl`WGiG51B>0+9!K@4b;cdbkc?3ECh_dQ?4 z_XiRB6D@|!StFvcHp9Z^hHgn*S6D;ZbKlafCa;(+$%2gkV@2}3OOXT9OHs}C)$}1a zpzma3X}H&FxiNxf|H!piovBrKSsOI(lpWhRQdU*_+KWOOPNZ$;aj0b%xA4)3NyX)@OA&| zOov1bw24-R?g{h;5pNBkCY@e%pe}hO>kzLe@)bGwq@!=8#i%ilmDm)Cd`Sh0c!-c34@RV<{6A3u-DL{yEwG_{QB(PQ}YJU%#{JMHn3#r>BvQ^g?I2jRIPib5@3HFPcC!_-p?rk&uu;9FLhKxsN(C<>nyzR$5CV1;B%;`UYmx!cgLD)*dx`4O zkyMN(I#~b-L%bDx9xCstD^5vACFwJfKOtN1{&+4IPem3Cq?aJaV4lpYsr9yQpLOmtznKLd-x_M zBNn|zEAiNg%P5!PGA{m29R8If#JD+O?TglsRm{hvg0o0feP6a4F<1{7{CZVukS3Wt zTr=t)lOI}c-NS=g(r-iFbh;htJVQAQ zX0}6$hU{)J!KH0&wH5VPtg8=Xp{#G{j2i0H9|a0rn8>G5aqY^^jh%q&xV3F_Dab{H zc|fx5uP&@74hmPzHStJ>&h=Ren?}2soqe@TXbSgkezknh{Y_~qUq)P%=F~vZLn{a9 zSc^ivgHj}!?QYRtC`Wv8r<{=-F#J>bd5Oo-i=72(@L0=C?)=MnhGtn#T%Gf)#{_-? zM*JGS!PT}D(zyGJGueg}R_g9rX7BmghZg+P!SE&9?>+OeI!CQ9#Yd(6vh{s`??hmH zJ!Je*yM8XBl)L-`va*#nr&`fB!!oQxUZGsGFyao3*D4n1i#~`C_CHq<4;aRKsf8OF zP%lMhsn-r}F+%FYC;gYr9dV@*S0NraNt_}}a{9Uf1U_9%Gsqv7eop0%;$fQWq z1pId3_#chbGJY%Ao-~sFk?XT_RFi{WH2a$caXki-I-b>9uZo^K0M^Z>k4ui8&kEt_vmS_fpKT;edk^k>J~>`Svd z>)Wm>UTRHSWsN5tOr2gV&2uAOL*F8R6)09ZX<)>Kk)|}}wHP7ClIG(Z5qNgQ>Ap@^ zRWGl$5jopWoYqi4K)o0KuCy(j>s7P%q>|6xF6)R`p*+pHP7F-?Rcord14&3XjUw&V z+rVLme1eKM54|B^CStZBF5j|wfd0h%#b@&zb!l5)y+X`Aqgeg$^QEZ~R07Y@7aAK} z*2=QF@Q8nElHqZ67YXtm=>4=@h?Q)#=H|Q!t&k zot?z@S}x=Fj+%SvL~q%VSoH}V)8k^Y;;iDb9WR!@gjA^A&J>@qfH~25e+$u^m5HJ? z?-=3CQD}ee6E@SdJ#V;Jr=aeUa^Sc(zb;1HHlSRs8>muO@$ZN&AZlNFAlw;NEcf+7 z;P;h4#xEr=>bnHJ4}ao6F)Xm+qlv#idAU1F(>SapurG(hL^bE(n25R}RPFQmmNkr_ z^BXgePo89Su^e}U6$2``2%F`cKd;-Z9)qLHAB_v?rEVG`>sM2s0S-y>UgoT)L{ype z9z`iF7kwY=aecj!fGmHS%63wI#0X!Z&YwQjNxgk+^!s7#d;ET86h~ZCjzO9v1#WH%BpXA2EE_;V6=iy%l8Ka>X*0to-GyJ@huu;N4llS zxn4pe&bplv$Q@whe-7JI{_(m2u3HjKy?*K!_pNr%nMPQ3$E}3cc!NB;6gF$#RdN)0 zptM|`t4y5$#O}D0P{pFHTY^+3N3Lmz;-lNDO~nlo^05X(o-P&d2=okvRaw&(fphGV zbRsDrHs8C5u$wH;CShfkVssp3lcDH&MR4;RLo)XN?caTwH0Yh zi7vU@_%nLVlQhp&;rO&MbXaT%9fY-u&nfFJzNiCM{>X+-ex}=gB1k)LYGgP}a$FVv z&za};ayt{?e3|ph&tiTka_cw7cU0QP9;l@LE-X(7c|B(4G_J+f`DeTA`Fusd03Hde z?Thi?A|1%gwf`{{GE4ogjvkAh4oA;AlNxicWe68bqV{QtpUz9jAr={vObRzv^$aTV z-$xqdzK7YhzmJg9myR;feO7;-_hYe4p`5hKE%#jCvAk|ekDS<&_C2K&tgHmdAangv z!!M*a(6%_<6?PGbz&q;E5~0ayW$CLkhEDe@BTR>S&DKMVVL6pz= zGbl_DC78f%pWprS>+R_GX!6EY(e9_JoXB zqGs6oU4+^}SwWeK0`io#m~b48We`8^*nZ1_N#=TcNbw6<*O+Q1DaQxN|4V<7Uj?>0 zoQb@u#e5OBuFxULg9T5C^0H}(Zs$;lt(C--Tn;MuB4Cr%*L)%jxXdtF6Ccz;Q|ctc zexg5x=n;!G-BBzh@ovlJDoKW^uo`aAK%o*ath@AAIQD)w`;L2D)Xwo0mUX=1F%=Q| zk}K>y@6%oVA;GmC^Y1p<>L^Xw3 zeAB_wo~$_(+C5Ny{@1$r?To|TJKkSgW@s*X^`Agq8;65x8#?FdVl$@AS7NV;6l2>A zcF5uzayC+gzE}4m%yB*WCnd?Vw}-{9U6pF1r(dt{AGN1&Qnymp5^2vtvVt4Hv|)4C zvr?*(ZH5Sh*Hbn3nOjC-Ud+$GK0YZ+&H>niz@Y0BJ$kY^(y1UAz;+`QMNfP`k=nRP zu+9R-m(#f-3am-7WICithnCg@0_dSbVl@TWfF1?ZLU0rBN>)e)w3`v2oCRwr$q=xC zes~Z8P-9N)8YKscwQHSkl$n9nN}tn&D;fk=W@R|G09sc`KXn7;r~{!j(aMa)48b*y z#2muk|5A!H2CLwXtu^pJ^+RgH|D8E2vFY9ygN*| z4iu9~bZs&pfb#7fnYO?+wxQ;#4tUv={rOTcu9tr~6K-iA0A^?PZuKM+VK1>KT z?hbHjTry5_-5BthAGf!;YT*n-uZbQF1JxhzWU>RgVt}f1k-dXGkVHDLor*=8K^Q|Z zS$$Dz;`>x^{VEGjR^Up2|Ib|jZI(JIk2(N?`Y!w~31A1cu%nF!(up=1-;I2t;42~! l=<2F=RDT7qAp2uM0y5Z4C7&F$sQ`LFx|)U>WomYq{{iPE!2bXM literal 0 HcmV?d00001 diff --git a/dev/design_assets/favicons/favicon-16.png b/dev/design_assets/favicons/favicon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..919374ba75ba288480be09b2e19ad2564ffdd495 GIT binary patch literal 474 zcmV<00VV#4P)sQv*;ia$WgWRZx}z`$UU2qLKg ziKv03NhgB{L+Mv~Kb+@HuB6ND$IW@}dCq&@-*ZlmAo=+tpM!zu_vygOS3%0Cp#plr zNR$MOu!&p4Fs5X|B~@#<&z)C0=4s^J1FWZFbkxq$^(yaAs+>~l~&?52aL z$5I>Nb{g{8&0|~$AVvPCgROM+T&DG-yrnMFC7KC3nL&y~i8P&Pq}M^$FDhj}G*(Zy zLc5C{EO@hilUBJG%zpq36!6+Ds1* z`4)0+WE8=QbfWW7H}p}Nb|&e1f{N$`1NIL90RR7m-&iOB000I_L_t&o0Iw3;VdSw` QIRF3v07*qoM6N<$f|b_T`v3p{ literal 0 HcmV?d00001 diff --git a/dev/design_assets/favicons/favicon-32.png b/dev/design_assets/favicons/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..3a90aebb948714fc5096cf28fce6a35d017b2235 GIT binary patch literal 893 zcmV-@1A_dCP)9n(F4`nyWKf$Hu`k+w>MBB&PCgg@x0^VfIoygSa!n;8h7PrmcM@1A?_Ip2HlK5$Ir{MC1b z8|u!5gLPfuami6Za+!!)T0v^(*deO{F$yM<7?wo=BvC85RIuDAwX_1%rbnh=x&fIO zkcog`r6XW@DIe8<5J4HW2;7Pm%M)h3ymN7N>IW zj(}R!q;{5>3~UYT*;+0%aQkHhfm<_v5dZADDwC(g@mDkWz-dwbSW;qYtt$s9YE=z9?wD_>D(!{j~b2xH!N`W;w z&gq4G+}+3hXs%10Db45Rq79Umq?YT<5u5q;D~|p*^BgL$CdWBN$fYFDUZHJKv;o`d z6(EW>5Kp8Wb>?VfJc>>0OVHlrebOMC!MXQ(9#{Hj9Randsr55*%!Gk28HBr~c=KPfA$MIf+>#hF zO#^o`C|65*n;&jekBDnX;C6)QZPIn@W_5?;L4jK_Xn8LEI2Gc6xV!NrLY9}I~ih>$is#fuXimk>%gI4s5C9$=Mu}#c#cf7wlxwDhqok`Yg#>p}#_uO;N-2d*a&nJkCVzIs zdB(D=Thtb1mLU+jecBc@q0lf9ZP4`4b?nM)f`v@z{aOwt}%tV`- z#BtjwtfEI^nptc*(Kkuay7&fRhTXO@Q+LM~Gn@=|JL_cEg_p%Q3%jj;FjmDEF`Nu` zZsO(PWwFjKr-9I?PeN6!y(lSnUR@z~T@#I=g(ljwe0||1w_jNy-A~uZYmY6JwtFjO z>pjsJT4cB7gjq_2&4*OrU8^uGYlTN!MGVwxR3I?Qcm9 zge`e-T|%e%rUBLy-ydS+2$@>^`C8c;m@mkGJ-=)X{L=6KSX{L-CFdwYcWt9#;a$g>q)N#J)y=^t}@}t$-@QIP6ru)zY?hJeVvoXE)#|I0Wn^BlI zDXD?p->2lZjv>{+Xx=jJbjusV0-!q+0U9%Na@3f)e|%~0s9yW0WA0Y;=feDw8VFm6 z9NVB}=V@u(_gBfr8<#}(PK*|s2G-AFVrC3%VAp}%ljf#| zv^;sonUTE{qlKn{^s|_l83P;OiL-Ujh#Viv{hP*|6xE0}46p=g1~$M6AAL8jZ^8$k zKPBz&MDsq(dpBBW@&JGVmLQ{n+-5IQl~EVq(63YS!8g&o5A)uQ7Mf@mLGp((G;qQn zS!DhR+i{#DiwxSsh26F-`+yf&WDe}MlSsa|$hbXR*zF&mTYA(H=PfU~NF119=Oz{_ zk#1Mqp-&@~pZl@!m6>T~_kZf?M7^#*vhcd)QGgX@R?}Air>E=eot9Wn;m6rvgw-&5 z4G`$*s_nEy9#VcBw^If-7!{v)%xHjUkGc|)NY!(bFpq<27R995@_-5&AkY3f+;z?d z)yO55+E(fZ<5LyyR~E&UL8Bi{v|)e+CZ%6+{0jg8|NonNss#W500v1!K~w_(RuM&X TpN9N000000NkvXXu0mjf%Dt68 literal 0 HcmV?d00001 diff --git a/dev/design_assets/favicons/favicon.svg b/dev/design_assets/favicons/favicon.svg new file mode 100644 index 0000000..9fc741b --- /dev/null +++ b/dev/design_assets/favicons/favicon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/dev/design_assets/favicons/icon-512.png b/dev/design_assets/favicons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..ad7cda2ab26530b436191d9f97e352488d286f44 GIT binary patch literal 16264 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 z
  • 9h&zB#EJont5MK-E?^#b7dqOKl*As)^04@i8 z?DT{Vum2-4kVsBmc@%~?{U?r7a5w;aJ2c4Q!N8(f1Rl~svW386P!7SN6#@lKU{U{pMLl`>>GCN! z8i1?U8_e-wX#l2?p@YIc9A^l8+bDz<&lv)vK9QV)lK}Y0p)v3TYz|(fBpckLJJqrn zl=|>kg@B$xh%+IWHXVU6`VXtStG8gy5;Bpa(mX0_c#FcQ(F+fHPgK zk?JWp7l6IdP0c6ZOf3Mm6%qWOv_VB+QrIJe@r1Jo;5`4zi0>2(rKHsxO|y9LBLL_#)dxJvGu8a7U?|A_&_eBpazocILb3);n$SUeKBx~8Jd{^z zfUg@`*}5RM;G}&I7V%8s3-q1~a%|GzK?b{~%p3gcR8Fvn&nQD0yu7#*!PjdJ4y{BLZ1-=K#hp(2tBy82<--{HTucl(8|y*erVRgfaUyz!?9u01~D( zpc>9Z&z?|YK&@8);l+zr@p|e4CJ~fq@b|QJx(O_i@_y7C{9cv^D~v>yoi6DGP*eR|vyYH1Qlj*cU}T!RWl&%TEjS;dzO(p? zr@jQ@ARx!J;6v6U28LRjjFmevOjjigVnUcS=hU_o0F}oiPUD2C!HR%*NA zT!2y8x?&MtR$$5x$q#TX-@x#UfPw>W|Eq^iGGJ_1L14oI^{nIOvh zV~4p?LM*$;V3esq0U^)XOd+H4odq)c=tB&{i!dHc=f!lw$^N|xBo=r8N9~?iV-q-} zD6OX7;C3&IC74=fkMb&>b`#L-mftWy+*k|&Eih7f7Ec4)i4wj22DfopGy%t<8 literal 0 HcmV?d00001 diff --git a/dev/design_assets/favicons/site.webmanifest b/dev/design_assets/favicons/site.webmanifest new file mode 100644 index 0000000..0890ad1 --- /dev/null +++ b/dev/design_assets/favicons/site.webmanifest @@ -0,0 +1,35 @@ +{ + "name": "Reimburse", + "short_name": "reimburse", + "theme_color": "#2f6fed", + "background_color": "#2f6fed", + "display": "standalone", + "icons": [ + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "favicon-48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "favicon-32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "favicon-16.png", + "sizes": "16x16", + "type": "image/png" + } + ] +} diff --git a/dev/design_assets/reimburse-glyph.svg b/dev/design_assets/reimburse-glyph.svg new file mode 100644 index 0000000..96b9f2e --- /dev/null +++ b/dev/design_assets/reimburse-glyph.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/dev/design_assets/reimburse.svg b/dev/design_assets/reimburse.svg new file mode 100644 index 0000000..9fc741b --- /dev/null +++ b/dev/design_assets/reimburse.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/dev/mockups/invoice.html b/dev/mockups/invoice.html new file mode 100644 index 0000000..c1d1b5f --- /dev/null +++ b/dev/mockups/invoice.html @@ -0,0 +1,168 @@ + + + + + + +Invoice — kBenestad reskin + + + + + +
    + + +
    +
    + +
    +
    +
    + +
    + +
    + + +
    +
    + + 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 });