227
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
|
**This file complements `CLAUDE.md`.** `CLAUDE.md` tells Claude Code *what this app is* —
|
||||||
restyle a kBenestad web app.** It is the shared visual contract: follow it and any new
|
its purpose, data model, stack, commands. **`DESIGN.md` governs only the surface:** layout,
|
||||||
screen — invoice, timesheet, reimburse, a dashboard, a settings page — will look like it
|
type, colour, components, copy. Follow it and any new screen — a form, a dashboard, a
|
||||||
belongs to the same family.
|
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
|
It does **not** redefine what the app does. Keep the app's behaviour, routes, and data model
|
||||||
app does. Keep each app's existing purpose, data model, and screens intact — only the
|
intact; only the visual surface is governed here.
|
||||||
surface (layout, type, colour, components, copy) 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
|
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
|
hairline borders, near-black cool ink, and **one** blue accent (`#2f6fed`). Schibsted
|
||||||
Grotesk for everything; JetBrains Mono only for numbers, code, and identifiers. 4px
|
Grotesk for everything; JetBrains Mono only for numbers, code, and identifiers. 4px spacing
|
||||||
spacing grid, small deliberate radii (8px workhorse), soft shadows used sparingly. No
|
grid, small deliberate radii (8px workhorse), soft shadows used sparingly. No gradients, no
|
||||||
gradients, no emoji, no bounce. Sentence case. Borders do the structural work; shadow only
|
emoji, no bounce. Sentence case. Borders do the structural work; shadow only when something
|
||||||
when something genuinely floats. Categorical data is coloured with a muted, config-driven
|
genuinely floats. Categorical data is coloured with a muted, config-driven palette (chip +
|
||||||
palette (chip + left border + optional row tint), never neon.
|
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.
|
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.
|
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.
|
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
|
## 2. Colour tokens
|
||||||
|
|
||||||
Reference the **semantic aliases**, not the raw ramps. The raw ramps exist so the aliases
|
All colour comes from `dev/theme/colors.css`. Reference the **semantic aliases**, not the
|
||||||
have something to point at; your code should almost never name a `--blue-500` directly.
|
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),
|
**Surfaces** — `--surface-page` (cool off-white ground), `--surface-card` (pure white),
|
||||||
`--surface-sunken`, `--surface-raised`, `--surface-hover`, `--surface-active`,
|
`--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`.
|
**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`,
|
**Terminal** (CLI / `gitxt`-style only) — `--term-bg`, `--term-fg`, `--term-dim`,
|
||||||
`--term-green`, `--term-amber`. Don't use these in normal app UI.
|
`--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
|
> Light values you'll see most: page `#f8f9fb`, card `#ffffff`, hairline `#e7eaef`, ink
|
||||||
> `#14181e`, accent `#2f6fed` / hover `#1f57cf`. Dark inverts onto `#0d1117` page /
|
> `#14181e`, accent `#2f6fed` / hover `#1f57cf`. Dark inverts onto `#0d1117` page /
|
||||||
> `#161b22` card with a lighter accent for contrast.
|
> `#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
|
## 3. Typography
|
||||||
|
|
||||||
- **Families:** `--font-sans` everywhere; `--font-mono` **only** for numbers, code, paths,
|
Loaded by `dev/theme/fonts.css`; scale defined in `dev/theme/typography.css`.
|
||||||
package/identifier names, and tabular figures. When showing money, durations, counts,
|
|
||||||
versions, IDs — mono + `font-variant-numeric: tabular-nums`.
|
- **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,
|
- **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-h3` 22, `--text-h4` 18, `--text-body-lg` 17, `--text-body` 15 (UI default),
|
||||||
`--text-body-sm` 13, `--text-caption` 12, `--text-mono` 14.
|
`--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
|
## 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,
|
- **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.
|
96, 128). Card padding ~24px (`--space-5`). Comfortable, not airy.
|
||||||
- **Radii:** `--radius-xs` 3, `--radius-sm` 5 (small controls), `--radius-md` 8 (the
|
- **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.
|
- **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:
|
- **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."
|
"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
|
### 6.1 Page shell
|
||||||
A centred column (`--container-lg` or narrower), `--surface-page` ground, content in
|
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
|
`--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;
|
**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
|
then a **document header** where the *subject's* identity leads (logo tile + org name on the
|
||||||
the left, document title + period on the right). kBenestad is the quiet signature in the
|
left, title + period on the right). kBenestad is the quiet signature in the footer, never
|
||||||
footer, never the headline.
|
the headline.
|
||||||
|
|
||||||
### 6.2 Forms are ROW-MAJOR — this is the load-bearing rule
|
### 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
|
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.
|
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`)
|
- 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).
|
- 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.
|
- 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
|
rows can be colour-coded (see §8). Trailing add/remove circle buttons. Subtotals and the
|
||||||
running total use mono tabular figures.
|
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
|
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
|
of a `--border-strong` rule, bold, with the figure in `--accent` at ~20px. Notes/derived
|
||||||
explanations below in muted small text.
|
explanations below in muted small text.
|
||||||
|
|
@ -200,52 +219,53 @@ explanations below in muted small text.
|
||||||
### 6.7 Validation & feedback (notes / banners)
|
### 6.7 Validation & feedback (notes / banners)
|
||||||
A tinted note block: icon + text, `--*-soft` background with matching `--*-border` and text
|
A tinted note block: icon + text, `--*-soft` background with matching `--*-border` and text
|
||||||
colour. Four kinds: error (`--danger`), warning (`--warning`), success (`--success`), info
|
colour. Four kinds: error (`--danger`), warning (`--warning`), success (`--success`), info
|
||||||
(`--accent`/info). Blocking errors disable the primary action; warnings don't. Mirror
|
(`--accent`). Blocking errors disable the primary action; warnings don't. Mirror issues
|
||||||
issues inline: tint the offending field's border and show the message next to its row. Keep
|
inline: tint the offending field's border and show the message next to its row. Keep
|
||||||
messages specific ("Description is required for OTH rows").
|
messages specific ("Description is required for OTH rows").
|
||||||
|
|
||||||
### 6.8 Config-driven & white-label
|
### 6.8 Config-driven & white-label
|
||||||
Wherever an app has a `config.yml` (org name, logo, accent colour, codes, categories,
|
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
|
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
|
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
|
touching anything else — exactly how the Forgejo theme in `dev/theme/` swaps one palette for
|
||||||
languages.
|
another. Strings come from the config's i18n map; support the configured languages.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Dashboards & data-dense screens
|
## 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
|
- **Metric / stat cards:** white card, hairline border, no shadow. A small uppercase eyebrow
|
||||||
eyebrow label, then the figure **large in mono tabular** (`--text-h2`/`h3`), then an
|
label, then the figure **large in mono tabular** (`--text-h2`/`h3`), then an optional delta
|
||||||
optional delta line. Show change with a small coloured chip or arrow in `--success` /
|
line. Show change with a small coloured chip or arrow in `--success` / `--danger` —
|
||||||
`--danger` — **muted, not neon**, never a red/green gradient.
|
**muted, not neon**, never a red/green gradient.
|
||||||
- **Card grid:** lay metric cards on a responsive grid with `--space-4`/`--space-5` gaps.
|
- **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).
|
Group related cards under a section header (eyebrow label + thin accent tick).
|
||||||
- **Tables:** the workhorse of dashboards. Eyebrow-label header row, hairline row dividers
|
- **Tables:** the workhorse. Eyebrow-label header row, hairline row dividers (or zebra via
|
||||||
(or zebra via `--surface-sunken` at low contrast), generous row height, mono tabular for
|
`--surface-sunken` at low contrast), generous row height, mono tabular for any numeric
|
||||||
any numeric column, right-align numbers. Status/category cells use the chip system (§8).
|
column, right-align numbers. Status/category cells use the chip system (§8). Sticky header
|
||||||
Sticky header for long tables. Row hover = `--surface-hover`.
|
for long tables. Row hover = `--surface-hover`.
|
||||||
- **Charts:** flat fills, no 3D, no drop shadows, no gradients-as-decoration. Use the blue
|
- **Charts:** flat fills, no 3D, no drop shadows, no gradients-as-decoration. Blue accent as
|
||||||
accent as the primary series; pull additional series from the muted categorical palette
|
the primary series; additional series from the muted categorical palette (§8) so chart
|
||||||
(§8) so chart colours match the chips/legends elsewhere. Thin gridlines in
|
colours match the chips/legends elsewhere. Thin gridlines in `--border-subtle`, axis labels
|
||||||
`--border-subtle`, axis labels in `--text-muted`. A legend reuses the same chip styling.
|
in `--text-muted`. Legend reuses the same chip styling.
|
||||||
- **Density:** comfortable, not cramped. Don't add stats, sparklines, or icons that aren't
|
- **Density:** comfortable, not cramped. Don't add stats, sparklines, or icons that aren't
|
||||||
answering a real question — less is more. No "data slop."
|
answering a real question — less is more. No "data slop."
|
||||||
- **Filters / toolbars:** segmented controls and ghost buttons in a row above the content,
|
- **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
|
- **Empty & loading states:** a calm centred message in `--text-muted` with one clear
|
||||||
action — never a spinner alone with no context.
|
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
|
Any time data falls into **named categories** — work codes, expense categories, statuses
|
||||||
categories** — work codes, expense categories, invoice statuses (draft/sent/paid/overdue),
|
(draft/sent/paid/overdue), project tags, leave types, priority levels — give each category a
|
||||||
project tags, leave types, priority levels — give each category a **stable, muted colour
|
**stable, muted colour identity** and surface it consistently. It turns a wall of rows into
|
||||||
identity** and surface it consistently. It turns a wall of rows into something scannable.
|
something scannable.
|
||||||
|
|
||||||
### 8.1 The colour identity
|
### 8.1 The colour identity
|
||||||
Each category owns a trio, ideally defined in **config/data, not hardcoded**, exposed as CSS
|
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)
|
--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
|
Specify all three for control, or specify just `--chip-border` and derive the tint with
|
||||||
with `color-mix(in srgb, var(--chip-border) 16%, var(--surface))`. **Muted, never neon** —
|
`color-mix(in srgb, var(--chip-border) 16%, var(--surface))`. **Muted, never neon** — think
|
||||||
think `#0078d7` blue, `#8cbd18` olive, `#ed616f` coral, `#393939` slate, not pure primaries.
|
`#0078d7` blue, `#8cbd18` olive, `#ed616f` coral, `#393939` slate, not pure primaries.
|
||||||
|
|
||||||
### 8.2 Three ways to surface it (use together)
|
### 8.2 Three ways to surface it (use together)
|
||||||
1. **Chip / pill** — a `--radius-full` pill with a leading filled **dot** in the category
|
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,
|
colour, the code + short name. The primary, always-legible token. Used in cells, filters,
|
||||||
filters, and legends.
|
and legends.
|
||||||
2. **Left border on the row** — `border-left: 3–4px solid var(--chip-border)`. A quiet,
|
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.
|
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`
|
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
|
of it). Make this **toggleable**: some users want the calm border-only view, others want
|
||||||
want the calm border-only view, others want the colour wash. Default to the quieter one.
|
the colour wash. Default to the quieter one.
|
||||||
|
|
||||||
### 8.3 Legend
|
### 8.3 Legend
|
||||||
Whenever colour carries meaning, show a **legend** of chips mapping each colour to its label.
|
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
|
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.
|
works for colour-blind users and in print/PDF.
|
||||||
|
|
||||||
### 8.4 Full-day vs partial, and state variants
|
### 8.4 Sub-states & variants
|
||||||
A category can carry more than one colour for sub-states (the timesheet's PPT uses a
|
A category can carry more than one colour for sub-states (e.g. a full-day vs partial-day
|
||||||
red full-day vs amber partial-day pair). Model these as variant classes/props
|
pair). Model these as variant classes/props rather than inventing ad-hoc colours at the call
|
||||||
(`c-PPT` vs `c-PPTp`) rather than inventing ad-hoc colours at the call site.
|
site.
|
||||||
|
|
||||||
### 8.5 Dark mode
|
### 8.5 Dark mode
|
||||||
Tints are too dark to read directly inverted. Either define dark-mode `--chip-bg`/`--chip-text`
|
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
|
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.
|
(`color-mix` the hue into the surface) and brighten slightly. Always re-check contrast.
|
||||||
|
|
||||||
### 8.6 Reuse across the family
|
### 8.6 Reuse across the app
|
||||||
The *same* category palette should drive chips, row borders, row tints, **and** chart
|
The *same* category palette should drive chips, row borders, row tints, **and** chart series
|
||||||
series — so a "Travel" expense is the same colour in the table, the legend, and the pie
|
— so "Travel" is the same colour in the table, the legend, and the pie chart. Define the
|
||||||
chart. Define the palette once per app and pull from it everywhere.
|
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,
|
- **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.
|
`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.**
|
- **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
|
- Don't draw bespoke illustrations in SVG; prefer clean icons, real screenshots, or
|
||||||
diagrams. No stock photos, no grain, no duotone.
|
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.
|
- Every colour goes through a semantic token; nothing hardcoded that can't invert.
|
||||||
- Page `#0d1117`, card `#161b22`, raised `#1c232c`; borders lighten, text lightens, accent
|
- Page `#0d1117`, card `#161b22`, raised `#1c232c`; borders lighten, text lightens, accent
|
||||||
shifts lighter (`--blue-400`-ish) for contrast on dark.
|
shifts lighter for contrast on dark.
|
||||||
- Shadows get heavier/darker (already handled by the dark elevation tokens).
|
- Shadows get heavier/darker (handled by the dark elevation tokens).
|
||||||
- Re-derive category tints (§8.5). Re-check chip and note contrast.
|
- 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.
|
- 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
|
## 11. Do / Don't
|
||||||
|
|
||||||
**Do**
|
**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.
|
- Use one blue accent; let borders and whitespace do the work.
|
||||||
- Keep forms row-major; keep numbers in mono tabular.
|
- Keep forms row-major; keep numbers in mono tabular.
|
||||||
- Colour-code categories with the muted chip/border/tint system, with a legend.
|
- 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.
|
- Support light + dark through tokens.
|
||||||
|
|
||||||
**Don't**
|
**Don't**
|
||||||
|
- Don't reinvent components the examples already provide.
|
||||||
- No gradients, photographic washes, textures, or glassmorphism.
|
- No gradients, photographic washes, textures, or glassmorphism.
|
||||||
- No emoji, no exclamation marks, no hype words, no Title Case.
|
- No emoji, no exclamation marks, no hype words, no Title Case.
|
||||||
- No neon semantic colours; no red/green gradient deltas.
|
- 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
|
*This file governs surface only. The `dev/` examples are the canonical reference; when this
|
||||||
these tokens against it rather than overriding the product.*
|
app has its own real brand guide or codebase conventions, reconcile against those rather than
|
||||||
|
overriding the product.*
|
||||||
|
|
|
||||||
BIN
dev/design_assets/favicons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
dev/design_assets/favicons/favicon-16.png
Normal file
|
After Width: | Height: | Size: 455 B |
BIN
dev/design_assets/favicons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 786 B |
BIN
dev/design_assets/favicons/favicon-48.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
8
dev/design_assets/favicons/favicon.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="invoice">
|
||||||
|
<rect width="48" height="48" rx="12" fill="#2f6fed"></rect>
|
||||||
|
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"></path>
|
||||||
|
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"></path>
|
||||||
|
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"></path>
|
||||||
|
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"></path>
|
||||||
|
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 684 B |
BIN
dev/design_assets/favicons/icon-512.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
35
dev/design_assets/favicons/site.webmanifest
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "Invoice",
|
||||||
|
"short_name": "invoice",
|
||||||
|
"theme_color": "#2f6fed",
|
||||||
|
"background_color": "#2f6fed",
|
||||||
|
"display": "standalone",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "favicon-48.png",
|
||||||
|
"sizes": "48x48",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "favicon-32.png",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "favicon-16.png",
|
||||||
|
"sizes": "16x16",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
dev/design_assets/invoice-glyph.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" fill="currentColor" role="img" aria-label="invoice">
|
||||||
|
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"></path>
|
||||||
|
<path d="M29 9 V14 H34" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"></path>
|
||||||
|
<path d="M18.5 20 H27" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"></path>
|
||||||
|
<path d="M18.5 25 H29.5" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" opacity=".55"></path>
|
||||||
|
<path d="M18.5 33 H29.5" stroke="currentColor" stroke-width="3.2" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 682 B |
8
dev/design_assets/invoice.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="invoice">
|
||||||
|
<rect width="48" height="48" rx="12" fill="#2f6fed"></rect>
|
||||||
|
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"></path>
|
||||||
|
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"></path>
|
||||||
|
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"></path>
|
||||||
|
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"></path>
|
||||||
|
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 684 B |
168
dev/mockups/invoice.html
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<title>Invoice — kBenestad reskin</title>
|
||||||
|
<link rel="stylesheet" href="kbenestad-forms.css">
|
||||||
|
<script>
|
||||||
|
// allow the review page to force a theme: invoice.html?theme=dark
|
||||||
|
(function(){var p=new URLSearchParams(location.search).get('theme');
|
||||||
|
if(p)document.documentElement.setAttribute('data-theme',p);})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* invoice-specific composition (structural only; all colour/type from shared sheet) */
|
||||||
|
.inv-lines-head { grid-template-columns: 1fr 70px 70px 110px 120px 34px; }
|
||||||
|
.inv-line { grid-template-columns: 1fr 70px 70px 110px 120px 34px; }
|
||||||
|
.inv-tax-head { grid-template-columns: 1fr 90px 130px 34px; }
|
||||||
|
.inv-tax { grid-template-columns: 1fr 90px 130px 34px; }
|
||||||
|
@media (max-width:680px){
|
||||||
|
.inv-lines-head{display:none;}
|
||||||
|
.inv-line,.inv-tax{grid-template-columns:1fr 1fr;gap:6px 10px;background:var(--surface-2);padding:12px;border-radius:var(--radius-sm);}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="kb">
|
||||||
|
<div class="kb-wrap">
|
||||||
|
|
||||||
|
<!-- utility toolbar -->
|
||||||
|
<div class="kb-toolbar">
|
||||||
|
<div class="kb-seg" role="group" aria-label="Language">
|
||||||
|
<button class="is-active">EN</button><button>DE</button><button>FR</button><button>NO</button>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="kb-seg" role="group" aria-label="Text size">
|
||||||
|
<button aria-label="Smaller">A−</button><button class="is-active">A</button><button aria-label="Larger">A+</button>
|
||||||
|
</div>
|
||||||
|
<button class="kb-iconbtn" aria-label="About">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 11.2H7.1v1.5h1.8v-1.5zm0-8.4H7.1v6.2h1.8V2.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- header: issuer (customer of kBenestad) leads -->
|
||||||
|
<header class="kb-header">
|
||||||
|
<div class="kb-brand">
|
||||||
|
<span class="logo">NM</span>
|
||||||
|
<span class="org">Nordmann Consulting<small>Org. 998 877 665 · Oslo, Norway</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-doctitle">
|
||||||
|
<h1>Invoice</h1>
|
||||||
|
<div class="meta">No. INV-2026-0042 · 6 June 2026</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- bill-to + details -->
|
||||||
|
<div class="kb-grid cols-2" style="margin-bottom:16px;">
|
||||||
|
<section class="kb-card" style="margin:0;">
|
||||||
|
<h2 class="kb-card__title">Bill to</h2>
|
||||||
|
<div class="kb-grid">
|
||||||
|
<div class="kb-field">
|
||||||
|
<span class="kb-label">Recipient</span>
|
||||||
|
<select class="kb-select"><option>Acme Corporation</option><option>Example NGO</option><option>Other…</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="kb-field">
|
||||||
|
<span class="kb-label">Address</span>
|
||||||
|
<input class="kb-input" value="Acme Corporation Ltd., 123 Business Avenue, Suite 400" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="kb-grid cols-2" style="gap:14px 16px;">
|
||||||
|
<div class="kb-field"><span class="kb-label">VAT ID</span><input class="kb-input" value="US-EIN-12-3456789" readonly></div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Project code</span>
|
||||||
|
<select class="kb-select"><option>AC-100</option><option>AC-110</option><option>AC-200</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="kb-card" style="margin:0;">
|
||||||
|
<h2 class="kb-card__title">Details</h2>
|
||||||
|
<div class="kb-grid cols-2" style="gap:14px 16px;">
|
||||||
|
<div class="kb-field"><span class="kb-label">Issue date</span><input class="kb-input" value="6 June 2026"></div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Due date</span><input class="kb-input" value="6 July 2026"></div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Currency</span>
|
||||||
|
<select class="kb-select"><option>USD — US dollar</option><option>EUR — Euro</option><option>NOK — Norwegian krone</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Terms</span><input class="kb-input" value="Net 30"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- line items -->
|
||||||
|
<section class="kb-card">
|
||||||
|
<h2 class="kb-card__title">Line items <span class="count">2 lines</span></h2>
|
||||||
|
<div class="kb-rowhead kb-row inv-lines-head">
|
||||||
|
<span>Description</span><span class="r">Qty</span><span>Unit</span><span class="r">Unit price</span><span class="r">Amount</span><span></span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-row inv-line">
|
||||||
|
<input class="kb-input" value="Consulting Services">
|
||||||
|
<input class="kb-input num" value="40">
|
||||||
|
<select class="kb-select"><option>Hour</option><option>Day</option></select>
|
||||||
|
<input class="kb-input num" value="100.00">
|
||||||
|
<span class="r kb-mono">4,000.00</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm" aria-label="Remove">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="kb-row inv-line">
|
||||||
|
<input class="kb-input" value="On-site Training Workshop">
|
||||||
|
<input class="kb-input num" value="2">
|
||||||
|
<select class="kb-select"><option>Day</option><option>Hour</option></select>
|
||||||
|
<input class="kb-input num" value="800.00">
|
||||||
|
<span class="r kb-mono">1,600.00</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm" aria-label="Remove">−</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button class="kb-btn kb-btn--dashed">+ Add line</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- tax + totals -->
|
||||||
|
<div class="kb-grid cols-2">
|
||||||
|
<section class="kb-card" style="margin:0;">
|
||||||
|
<h2 class="kb-card__title">Tax</h2>
|
||||||
|
<div class="kb-rowhead kb-row inv-tax-head">
|
||||||
|
<span>Type</span><span class="r">Rate %</span><span class="r">Amount</span><span></span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-row inv-tax">
|
||||||
|
<select class="kb-select"><option>VAT</option><option>GST</option><option>Sales Tax</option></select>
|
||||||
|
<input class="kb-input num" value="25">
|
||||||
|
<span class="r kb-mono">1,400.00</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm" aria-label="Remove">−</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;"><button class="kb-btn kb-btn--dashed">+ Add tax line</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="kb-card kb-card--flex" style="margin:0;">
|
||||||
|
<h2 class="kb-card__title">Summary</h2>
|
||||||
|
<div class="kb-totals kb-totals--fill">
|
||||||
|
<div class="row"><span class="lab">Subtotal</span><span class="val">5,600.00</span></div>
|
||||||
|
<div class="row"><span class="lab">VAT 25%</span><span class="val">1,400.00</span></div>
|
||||||
|
<div class="grand"><span class="lab">Total due (USD)</span><span class="val">7,000.00</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- payment info -->
|
||||||
|
<section class="kb-card">
|
||||||
|
<h2 class="kb-card__title">Payment information</h2>
|
||||||
|
<div class="kb-grid cols-3" style="gap:14px 16px;">
|
||||||
|
<div class="kb-field"><span class="kb-label">Bank</span><input class="kb-input" value="DNB Bank ASA"></div>
|
||||||
|
<div class="kb-field"><span class="kb-label">IBAN</span><input class="kb-input kb-mono" value="NO93 8601 1117 947"></div>
|
||||||
|
<div class="kb-field"><span class="kb-label">SWIFT / BIC</span><input class="kb-input kb-mono" value="DNBANOKK"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button class="kb-btn kb-btn--primary kb-btn--lg kb-btn--block" style="margin-top:8px;">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>
|
||||||
|
Generate Invoice PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="kb-footer">
|
||||||
|
<span class="kb-mark"><span class="glyph"><i></i><i></i></span>kBenestad</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>© 2026 Kristian Benestad</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<a href="https://docs.benestad.net/invoice">docs.benestad.net</a>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<a href="https://github.com/kbenestad/invoice">kbenestad/invoice</a>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
356
dev/mockups/kbenestad-forms.css
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
/* ============================================================================
|
||||||
|
kBenestad — unified forms design language
|
||||||
|
Shared foundation for invoice · timesheet · reimburse
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Customer-facing white-label apps: the CUSTOMER's identity leads (logo + org
|
||||||
|
name in the header); kBenestad is the quiet craft signature.
|
||||||
|
|
||||||
|
Configurable in each app's config.yml (sensible defaults shown):
|
||||||
|
accent-colour: "#2F6FED" → --accent (recolour to the customer brand)
|
||||||
|
font-size: 1.0 → --font-scale (screen text multiplier)
|
||||||
|
code colours (timesheet) → per-chip --chip-border / --chip-bg
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Type: Schibsted Grotesk (text) + JetBrains Mono (figures), system fallback
|
||||||
|
so the forms render fully offline if the webfonts are unavailable.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ── Fonts: load if present, but the stacks below fall back to system ─────── */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Schibsted Grotesk"; font-style: normal; font-weight: 400 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: local("Schibsted Grotesk"),
|
||||||
|
url("https://fonts.bunny.net/schibsted-grotesk/files/schibsted-grotesk-latin-400-normal.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: local("JetBrains Mono"),
|
||||||
|
url("https://fonts.bunny.net/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tokens ───────────────────────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--font-sans: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* screen type multiplier (config: font-size) — base unit is 16px */
|
||||||
|
--font-scale: 1;
|
||||||
|
--fs-base: calc(15px * var(--font-scale));
|
||||||
|
--fs-input: calc(15px * var(--font-scale));
|
||||||
|
--fs-label: calc(12px * var(--font-scale));
|
||||||
|
--fs-title: calc(13px * var(--font-scale));
|
||||||
|
--fs-small: calc(12.5px * var(--font-scale));
|
||||||
|
--fs-h1: calc(22px * var(--font-scale));
|
||||||
|
|
||||||
|
/* accent — single recolourable token (config: accent-colour) */
|
||||||
|
--accent: #2F6FED;
|
||||||
|
--accent-hover: #1F57CF;
|
||||||
|
--accent-soft: #EEF3FE;
|
||||||
|
--accent-border: #C7D9FB;
|
||||||
|
--on-accent: #FFFFFF;
|
||||||
|
|
||||||
|
/* surfaces & ink (light) */
|
||||||
|
--bg: #F4F6F9;
|
||||||
|
--surface: #FFFFFF;
|
||||||
|
--surface-2: #F8F9FB;
|
||||||
|
--surface-3: #F1F3F6;
|
||||||
|
--border: #E3E7EE;
|
||||||
|
--border-strong:#D3D9E2;
|
||||||
|
--text: #14181E;
|
||||||
|
--text-soft: #3A434F;
|
||||||
|
--text-muted: #5F6975;
|
||||||
|
--placeholder: #9AA3AF;
|
||||||
|
|
||||||
|
/* semantic */
|
||||||
|
--danger: #D64545; --danger-soft: #FBEAEA; --danger-border: #F0C9C9;
|
||||||
|
--warning: #C9851F; --warning-soft: #FBF1DD; --warning-border: #EED9AD;
|
||||||
|
--success: #1F9D5F; --success-soft: #E2F3EA; --success-border: #BFE3CF;
|
||||||
|
--info: #2F6FED; --info-soft: #EEF3FE; --info-border: #C7D9FB;
|
||||||
|
|
||||||
|
/* shape & depth */
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(20,24,30,.05);
|
||||||
|
--shadow: 0 6px 22px rgba(20,24,30,.08);
|
||||||
|
--ring: 0 0 0 3px rgba(47,111,237,.20);
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark — auto by system, or forced via [data-theme="dark"] ─────────────── */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--accent: #5685E9; --accent-hover: #6C98EF; --accent-soft: #16233F; --accent-border: #21386A; --on-accent: #FFFFFF;
|
||||||
|
--bg: #0D1117; --surface: #161B22; --surface-2: #1C232C; --surface-3: #1C232C;
|
||||||
|
--border: #232A33; --border-strong: #2D3641;
|
||||||
|
--text: #EEF1F5; --text-soft: #C2CAD3; --text-muted: #8B95A1; --placeholder: #6F7986;
|
||||||
|
--danger:#E06464; --danger-soft:#341819; --danger-border:#5A2A2A;
|
||||||
|
--warning:#D99A3A; --warning-soft:#33270F; --warning-border:#574017;
|
||||||
|
--success:#3BB97A; --success-soft:#13301F; --success-border:#1F4D33;
|
||||||
|
--info:#88ABF2; --info-soft:#16233F; --info-border:#21386A;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.4); --shadow: 0 8px 28px rgba(0,0,0,.5);
|
||||||
|
--ring: 0 0 0 3px rgba(86,133,233,.32);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--accent: #5685E9; --accent-hover: #6C98EF; --accent-soft: #16233F; --accent-border: #21386A; --on-accent: #FFFFFF;
|
||||||
|
--bg: #0D1117; --surface: #161B22; --surface-2: #1C232C; --surface-3: #1C232C;
|
||||||
|
--border: #232A33; --border-strong: #2D3641;
|
||||||
|
--text: #EEF1F5; --text-soft: #C2CAD3; --text-muted: #8B95A1; --placeholder: #6F7986;
|
||||||
|
--danger:#E06464; --danger-soft:#341819; --danger-border:#5A2A2A;
|
||||||
|
--warning:#D99A3A; --warning-soft:#33270F; --warning-border:#574017;
|
||||||
|
--success:#3BB97A; --success-soft:#13301F; --success-border:#1F4D33;
|
||||||
|
--info:#88ABF2; --info-soft:#16233F; --info-border:#21386A;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.4); --shadow: 0 8px 28px rgba(0,0,0,.5);
|
||||||
|
--ring: 0 0 0 3px rgba(86,133,233,.32);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
.kb {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
}
|
||||||
|
.kb-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ── Page shell ───────────────────────────────────────────────────────────── */
|
||||||
|
.kb-wrap { max-width: 960px; margin: 0 auto; padding: 28px 20px 56px; }
|
||||||
|
|
||||||
|
/* ── Top utility bar (language / text-size / about) ───────────────────────── */
|
||||||
|
.kb-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.kb-toolbar .spacer { flex: 1; }
|
||||||
|
.kb-seg {
|
||||||
|
display: inline-flex; align-items: center; gap: 2px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); padding: 3px;
|
||||||
|
}
|
||||||
|
.kb-seg button {
|
||||||
|
font: 600 var(--fs-small)/1 var(--font-sans);
|
||||||
|
color: var(--text-muted); background: transparent; border: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 6px 11px; border-radius: 4px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.kb-seg button.is-active { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.kb-seg button:hover:not(.is-active) { color: var(--text); }
|
||||||
|
.kb-iconbtn {
|
||||||
|
display: inline-grid; place-items: center; width: 34px; height: 34px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); color: var(--text-muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
.kb-iconbtn:hover { color: var(--accent); border-color: var(--accent-border); }
|
||||||
|
|
||||||
|
/* ── Document header: customer leads ──────────────────────────────────────── */
|
||||||
|
.kb-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
gap: 24px; padding-bottom: 20px; margin-bottom: 22px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.kb-brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.kb-brand .logo {
|
||||||
|
height: 46px; width: 46px; flex: 0 0 46px; border-radius: 10px;
|
||||||
|
display: grid; place-items: center; background: var(--accent-soft);
|
||||||
|
color: var(--accent); font-weight: 800; font-size: 18px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.kb-brand .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.kb-brand .org { font-size: 17px; font-weight: 700; color: var(--text); letter-spacing: -0.01em; }
|
||||||
|
.kb-brand .org small { display: block; font-size: var(--fs-small); font-weight: 500; color: var(--text-muted); letter-spacing: 0; }
|
||||||
|
.kb-doctitle { text-align: right; }
|
||||||
|
.kb-doctitle h1 {
|
||||||
|
margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.kb-doctitle .meta { margin-top: 4px; font-size: var(--fs-small); color: var(--text-muted); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* ── Cards / sections ─────────────────────────────────────────────────────── */
|
||||||
|
.kb-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 20px 22px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.kb-card__title {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
font-size: var(--fs-title); font-weight: 700; letter-spacing: -0.005em;
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.kb-card__title::before {
|
||||||
|
content: ""; width: 3px; height: 14px; border-radius: 2px; background: var(--accent);
|
||||||
|
}
|
||||||
|
.kb-card__title .count { margin-left: auto; font-weight: 500; color: var(--text-muted); font-size: var(--fs-small); }
|
||||||
|
|
||||||
|
/* ── Fields ───────────────────────────────────────────────────────────────── */
|
||||||
|
.kb-grid { display: grid; gap: 14px 16px; }
|
||||||
|
.kb-grid.cols-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.kb-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||||
|
.kb-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||||
|
.kb-field.grow { flex: 1; }
|
||||||
|
.kb-label {
|
||||||
|
font-size: var(--fs-label); font-weight: 600; letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.kb-input, .kb-select, .kb-textarea {
|
||||||
|
width: 100%; font: 400 var(--fs-input)/1.4 var(--font-sans);
|
||||||
|
color: var(--text); background: var(--surface);
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||||
|
padding: 9px 11px; outline: none; transition: border-color .14s, box-shadow .14s;
|
||||||
|
}
|
||||||
|
.kb-input::placeholder, .kb-textarea::placeholder { color: var(--placeholder); }
|
||||||
|
.kb-input:focus, .kb-select:focus, .kb-textarea:focus {
|
||||||
|
border-color: var(--accent); box-shadow: var(--ring);
|
||||||
|
}
|
||||||
|
.kb-input:disabled, .kb-select:disabled, .kb-input[readonly] {
|
||||||
|
background: var(--surface-3); color: var(--text-muted); cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.kb-input.num { font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.kb-textarea { resize: vertical; min-height: 46px; }
|
||||||
|
.kb-select {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%235F6975' stroke-width='1.6'><path d='M4 6l4 4 4-4'/></svg>");
|
||||||
|
background-repeat: no-repeat; background-position: right 10px center; padding-right: 32px;
|
||||||
|
}
|
||||||
|
.kb-input.is-error, .kb-select.is-error { border-color: var(--danger); }
|
||||||
|
.kb-input.is-warn { border-color: var(--warning); background: var(--warning-soft); }
|
||||||
|
|
||||||
|
/* ── Buttons ──────────────────────────────────────────────────────────────── */
|
||||||
|
.kb-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font: 600 var(--fs-input)/1 var(--font-sans);
|
||||||
|
padding: 10px 16px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent; cursor: pointer; transition: background .14s, border-color .14s, color .14s;
|
||||||
|
}
|
||||||
|
.kb-btn svg { width: 16px; height: 16px; }
|
||||||
|
.kb-btn--primary { background: var(--accent); color: var(--on-accent); }
|
||||||
|
.kb-btn--primary:hover { background: var(--accent-hover); }
|
||||||
|
.kb-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.kb-btn--ghost { background: var(--surface); color: var(--text-soft); border-color: var(--border-strong); }
|
||||||
|
.kb-btn--ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.kb-btn--soft { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.kb-btn--soft:hover { background: var(--accent); color: var(--on-accent); }
|
||||||
|
.kb-btn--dashed { background: transparent; color: var(--accent); border: 1px dashed var(--accent-border); }
|
||||||
|
.kb-btn--dashed:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||||
|
.kb-btn--danger-ghost { background: transparent; color: var(--danger); padding: 6px 10px; }
|
||||||
|
.kb-btn--danger-ghost:hover { background: var(--danger-soft); }
|
||||||
|
.kb-btn--lg { padding: 13px 26px; font-size: calc(15px * var(--font-scale)); }
|
||||||
|
.kb-btn--block { width: 100%; }
|
||||||
|
|
||||||
|
/* round add/remove */
|
||||||
|
.kb-circbtn {
|
||||||
|
width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center;
|
||||||
|
font-size: 15px; line-height: 1; font-weight: 700; cursor: pointer; padding: 0;
|
||||||
|
background: var(--surface); border: 1px solid var(--accent); color: var(--accent);
|
||||||
|
}
|
||||||
|
.kb-circbtn:hover { background: var(--accent); color: var(--on-accent); }
|
||||||
|
.kb-circbtn--rm { border-color: var(--danger); color: var(--danger); }
|
||||||
|
.kb-circbtn--rm:hover { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
/* ── Dividers — deliberately simple (no overlap, no doubled rules) ────────── */
|
||||||
|
.kb-divider { height: 1px; background: var(--border); border: 0; margin: 18px 0; }
|
||||||
|
.kb-divider--strong { background: var(--border-strong); }
|
||||||
|
|
||||||
|
/* ── Item / line blocks ───────────────────────────────────────────────────── */
|
||||||
|
.kb-block {
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--surface-2); padding: 16px 18px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.kb-block__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.kb-block__head .tag { font-size: var(--fs-label); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--accent); }
|
||||||
|
.kb-subtotal { font-family: var(--font-mono); font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ── Tables / row grids ───────────────────────────────────────────────────── */
|
||||||
|
.kb-rowhead, .kb-row { display: grid; align-items: center; gap: 8px; }
|
||||||
|
.kb-rowhead {
|
||||||
|
padding: 0 10px 8px; font-size: var(--fs-label); font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.kb-rowhead .r { text-align: right; }
|
||||||
|
.kb-row {
|
||||||
|
padding: 7px 10px; border-radius: var(--radius-sm);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.kb-row:hover { background: var(--surface-2); }
|
||||||
|
.kb-row .r { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ── Code chips (timesheet) — colours come from config per code ───────────── */
|
||||||
|
.kb-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 3px 10px; border-radius: var(--radius-pill);
|
||||||
|
font-size: var(--fs-small); font-weight: 600; line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
/* per-code overrides set --chip-bg / --chip-border / --chip-text inline */
|
||||||
|
background: var(--chip-bg, var(--surface-3));
|
||||||
|
border: 1px solid var(--chip-border, var(--border-strong));
|
||||||
|
color: var(--chip-text, var(--text-soft));
|
||||||
|
}
|
||||||
|
.kb-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--chip-border, var(--text-muted)); }
|
||||||
|
|
||||||
|
/* a row tinted by its code colour */
|
||||||
|
.kb-row.coded { border-left-color: var(--chip-border, transparent); }
|
||||||
|
.kb-row.coded.tint { background: color-mix(in srgb, var(--chip-bg, transparent) 45%, var(--surface)); }
|
||||||
|
|
||||||
|
/* ── Totals panel ─────────────────────────────────────────────────────────── */
|
||||||
|
.kb-totals { margin-left: auto; width: min(380px, 100%); }
|
||||||
|
.kb-totals--fill { margin-left: 0; width: 100%; }
|
||||||
|
.kb-card--flex { display: flex; flex-direction: column; }
|
||||||
|
.kb-card--flex .kb-totals { margin-top: auto; }
|
||||||
|
.kb-totals .row { display: flex; justify-content: space-between; gap: 16px; padding: 6px 0; font-size: var(--fs-base); }
|
||||||
|
.kb-totals .row .lab { color: var(--text-muted); }
|
||||||
|
.kb-totals .row .val { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text); }
|
||||||
|
.kb-totals .grand {
|
||||||
|
margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border-strong);
|
||||||
|
display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
|
||||||
|
}
|
||||||
|
.kb-totals .grand .lab { font-weight: 700; color: var(--text); }
|
||||||
|
.kb-totals .grand .val { font-family: var(--font-mono); font-weight: 700; font-size: calc(20px * var(--font-scale)); color: var(--accent); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ── Validation summary ───────────────────────────────────────────────────── */
|
||||||
|
.kb-note {
|
||||||
|
border-radius: var(--radius-sm); padding: 12px 16px; margin-bottom: 16px;
|
||||||
|
font-size: var(--fs-small); line-height: 1.7;
|
||||||
|
display: flex; gap: 10px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.kb-note svg { width: 17px; height: 17px; flex: 0 0 17px; margin-top: 1px; }
|
||||||
|
.kb-note--error { background: var(--danger-soft); border: 1px solid var(--danger-border); color: var(--danger); }
|
||||||
|
.kb-note--warning { background: var(--warning-soft); border: 1px solid var(--warning-border); color: var(--warning); }
|
||||||
|
.kb-note--success { background: var(--success-soft); border: 1px solid var(--success-border); color: var(--success); }
|
||||||
|
.kb-note--info { background: var(--info-soft); border: 1px solid var(--info-border); color: var(--info); }
|
||||||
|
.kb-note b { font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Signature ────────────────────────────────────────────────────────────── */
|
||||||
|
.kb-sig { border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); height: 96px; }
|
||||||
|
|
||||||
|
/* ── Footer (software credit — stays kBenestad) ───────────────────────────── */
|
||||||
|
.kb-footer {
|
||||||
|
max-width: 960px; margin: 0 auto; padding: 18px 20px 8px;
|
||||||
|
font-size: var(--fs-small); color: var(--text-muted);
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.kb-footer a { color: var(--text-muted); text-decoration: none; }
|
||||||
|
.kb-footer a:hover { color: var(--accent); text-decoration: underline; }
|
||||||
|
.kb-footer .sep { opacity: .45; }
|
||||||
|
/* kBenestad mark — two offset rounded squares, upper-right outlined + lower-left solid.
|
||||||
|
Usage: <span class="kb-mark"><svg …>…</svg>kBenestad</span> */
|
||||||
|
.kb-mark { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-soft); }
|
||||||
|
.kb-mark svg { width: 18px; height: 18px; flex: 0 0 18px; overflow: visible; }
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.kb-grid.cols-2, .kb-grid.cols-3 { grid-template-columns: 1fr; }
|
||||||
|
.kb-header { flex-direction: column; gap: 14px; }
|
||||||
|
.kb-doctitle { text-align: left; }
|
||||||
|
}
|
||||||
326
dev/mockups/reimburse.html
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<title>Reimbursement — kBenestad reskin</title>
|
||||||
|
<link rel="stylesheet" href="kbenestad-forms.css">
|
||||||
|
<script>
|
||||||
|
(function(){var p=new URLSearchParams(location.search).get('theme');
|
||||||
|
if(p)document.documentElement.setAttribute('data-theme',p);})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- React + Babel for the Tweaks panel (mockup-only; not part of the shipped app) -->
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||||
|
<style>
|
||||||
|
.rb-line-head, .rb-line { grid-template-columns: 1fr 96px 120px 120px 30px; }
|
||||||
|
.rb-receipt {
|
||||||
|
display:flex; align-items:center; gap:9px; padding:8px 12px; margin-top:8px;
|
||||||
|
background:var(--accent-soft); border:1px solid var(--accent-border);
|
||||||
|
border-radius:var(--radius-sm); font-size:var(--fs-small); color:var(--text-soft);
|
||||||
|
}
|
||||||
|
.rb-receipt svg{ width:16px;height:16px;color:var(--accent);flex:0 0 16px; }
|
||||||
|
.rb-receipt .name{ flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
|
||||||
|
.rb-receipt .sz{ color:var(--text-muted);font-family:var(--font-mono); }
|
||||||
|
|
||||||
|
/* FX conversion sub-panel — opens beneath a line when the line's currency
|
||||||
|
differs from the claim currency. Mirrors the original app's behaviour:
|
||||||
|
a calculation widget revealed on foreign-currency selection. */
|
||||||
|
.rb-fx {
|
||||||
|
margin-top: 10px; padding: 12px 14px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.rb-fx__head {
|
||||||
|
display:flex; align-items:center; gap:8px;
|
||||||
|
font-size: var(--fs-small); font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: .04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.rb-fx__head svg { width:14px; height:14px; color: var(--accent); flex: 0 0 14px; }
|
||||||
|
.rb-fx__body {
|
||||||
|
display:flex; align-items:center; gap: 18px; flex-wrap: wrap;
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
}
|
||||||
|
.rb-fx__rate {
|
||||||
|
display:flex; align-items:center; gap:8px;
|
||||||
|
padding: 4px 8px 4px 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.rb-fx__rate .lab { color: var(--text-muted); font-size: var(--fs-small); white-space: nowrap; }
|
||||||
|
.rb-fx__rate input {
|
||||||
|
width: 96px; padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: var(--surface); color: var(--text);
|
||||||
|
font-family: var(--font-mono); text-align: right;
|
||||||
|
font-size: var(--fs-input);
|
||||||
|
}
|
||||||
|
.rb-fx__rate input:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
|
||||||
|
.rb-fx__calc {
|
||||||
|
display:flex; align-items:center; gap:10px; flex-wrap: wrap;
|
||||||
|
color: var(--text-muted); font-size: var(--fs-small);
|
||||||
|
}
|
||||||
|
.rb-fx__calc .kb-mono { color: var(--text-soft); }
|
||||||
|
.rb-fx__calc .op { color: var(--text-muted); font-family: var(--font-mono); }
|
||||||
|
.rb-fx__calc .total {
|
||||||
|
color: var(--accent); font-weight: 700;
|
||||||
|
padding-left: 10px; margin-left: 2px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
@media (max-width:680px){
|
||||||
|
.rb-line-head{display:none;}
|
||||||
|
.rb-line{grid-template-columns:1fr 1fr;gap:8px;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="kb">
|
||||||
|
<div class="kb-wrap">
|
||||||
|
|
||||||
|
<div class="kb-toolbar">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="kb-seg" role="group" aria-label="Text size">
|
||||||
|
<button>A−</button><button class="is-active">A</button><button>A+</button>
|
||||||
|
</div>
|
||||||
|
<button class="kb-iconbtn" aria-label="About">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 11.2H7.1v1.5h1.8v-1.5zm0-8.4H7.1v6.2h1.8V2.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="kb-header">
|
||||||
|
<div class="kb-brand">
|
||||||
|
<span class="logo">CA</span>
|
||||||
|
<span class="org">Center for Asylum Protection<small>Expense reimbursement</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-doctitle">
|
||||||
|
<h1>Reimbursement</h1>
|
||||||
|
<div class="meta">Claim · 6 June 2026</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- claimant -->
|
||||||
|
<section class="kb-card">
|
||||||
|
<h2 class="kb-card__title">Claimant</h2>
|
||||||
|
<div class="kb-grid cols-3" style="gap:14px 16px;">
|
||||||
|
<div class="kb-field"><span class="kb-label">Name</span><input class="kb-input" value="Mai Nguyen"></div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Program</span>
|
||||||
|
<select class="kb-select"><option>Legal Aid Program</option><option>Protection Program</option><option>General Operations</option><option>Other…</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Account code</span>
|
||||||
|
<select class="kb-select"><option>2000 — Travel & Transport</option><option>3000 — Office Supplies</option><option>4000 — Professional Services</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Claim currency</span>
|
||||||
|
<select class="kb-select"><option>USD — US dollar</option><option>THB — Thai baht</option><option>EUR — Euro</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="kb-field grow" style="grid-column:span 2;"><span class="kb-label">Purpose</span><input class="kb-input" value="Field visit — refugee status interviews, Mae Sot"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- expense item 1 -->
|
||||||
|
<section class="kb-card">
|
||||||
|
<h2 class="kb-card__title">Expenses <span class="count">2 items</span></h2>
|
||||||
|
|
||||||
|
<div class="kb-block">
|
||||||
|
<div class="kb-block__head">
|
||||||
|
<span class="tag">Item 1 · Transport</span>
|
||||||
|
<span class="kb-subtotal">USD 184.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-rowhead kb-row rb-line-head">
|
||||||
|
<span>Description</span><span class="r">Amount</span><span>Currency</span><span class="r">In USD</span><span></span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-row rb-line">
|
||||||
|
<input class="kb-input" value="Return flight BKK–Mae Sot">
|
||||||
|
<input class="kb-input num" value="6,440.00">
|
||||||
|
<select class="kb-select"><option>THB</option><option>USD</option></select>
|
||||||
|
<span class="r kb-mono">184.00</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="rb-receipt">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 1.5A1.5 1.5 0 0 1 4.5 0h4.7c.4 0 .8.16 1.06.44l2.3 2.3c.28.27.44.66.44 1.06v8.7A1.5 1.5 0 0 1 11.5 16h-7A1.5 1.5 0 0 1 3 14.5zM9 1.5V4h2.5z"/></svg>
|
||||||
|
<span class="name">receipt-flight-bkk.pdf</span><span class="sz">240 KB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FX panel: opens beneath the line because its currency (THB) ≠ claim currency (USD) -->
|
||||||
|
<div class="rb-fx" role="group" aria-label="FX conversion">
|
||||||
|
<div class="rb-fx__head">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M2 4a1 1 0 0 1 1-1h8.6L10.3 1.7a1 1 0 0 1 1.4-1.4l3 3a1 1 0 0 1 0 1.4l-3 3a1 1 0 0 1-1.4-1.4L11.6 5H3a1 1 0 0 1-1-1zm12 8a1 1 0 0 1-1 1H4.4l1.3 1.3a1 1 0 0 1-1.4 1.4l-3-3a1 1 0 0 1 0-1.4l3-3a1 1 0 0 1 1.4 1.4L4.4 11H13a1 1 0 0 1 1 1z"/></svg>
|
||||||
|
<span>Foreign currency — enter exchange rate</span>
|
||||||
|
</div>
|
||||||
|
<div class="rb-fx__body">
|
||||||
|
<label class="rb-fx__rate">
|
||||||
|
<span class="lab">1 USD =</span>
|
||||||
|
<input value="35.00000" aria-label="THB per 1 USD">
|
||||||
|
<span class="lab">THB</span>
|
||||||
|
</label>
|
||||||
|
<div class="rb-fx__calc">
|
||||||
|
<span class="kb-mono">6,440.00 THB</span>
|
||||||
|
<span class="op">÷</span>
|
||||||
|
<span class="kb-mono">35.00</span>
|
||||||
|
<span class="op">=</span>
|
||||||
|
<span class="kb-mono total">USD 184.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /FX panel -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- expense item 2 -->
|
||||||
|
<div class="kb-block">
|
||||||
|
<div class="kb-block__head">
|
||||||
|
<span class="tag">Item 2 · Accommodation</span>
|
||||||
|
<span class="kb-subtotal">USD 96.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-rowhead kb-row rb-line-head">
|
||||||
|
<span>Description</span><span class="r">Amount</span><span>Currency</span><span class="r">In USD</span><span></span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-row rb-line">
|
||||||
|
<input class="kb-input" value="Guesthouse — 2 nights">
|
||||||
|
<input class="kb-input num" value="96.00">
|
||||||
|
<select class="kb-select"><option>USD</option><option>THB</option></select>
|
||||||
|
<span class="r kb-mono">96.00</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="rb-receipt">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 1.5A1.5 1.5 0 0 1 4.5 0h4.7c.4 0 .8.16 1.06.44l2.3 2.3c.28.27.44.66.44 1.06v8.7A1.5 1.5 0 0 1 11.5 16h-7A1.5 1.5 0 0 1 3 14.5zM9 1.5V4h2.5z"/></svg>
|
||||||
|
<span class="name">guesthouse-invoice.jpg</span><span class="sz">1.1 MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="kb-btn kb-btn--dashed">+ Add expense item</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- totals + declaration -->
|
||||||
|
<div class="kb-grid cols-2">
|
||||||
|
<section class="kb-card" style="margin:0;">
|
||||||
|
<h2 class="kb-card__title">Declaration</h2>
|
||||||
|
<div class="kb-note kb-note--info">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 6.8H7.1v5.4h1.8V6.8zM8 3.3a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2z"/></svg>
|
||||||
|
<span>I certify that the above expenses were incurred on official business and are supported by the attached receipts.</span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Claimant signature</span><div class="kb-sig"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="kb-card kb-card--flex" style="margin:0;">
|
||||||
|
<h2 class="kb-card__title">Summary</h2>
|
||||||
|
<div class="kb-totals kb-totals--fill">
|
||||||
|
<div class="row"><span class="lab">Transport</span><span class="val">184.00</span></div>
|
||||||
|
<div class="row"><span class="lab">Accommodation</span><span class="val">96.00</span></div>
|
||||||
|
<div class="grand"><span class="lab">Total claim (USD)</span><span class="val">280.00</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="kb-btn kb-btn--primary kb-btn--lg kb-btn--block" style="margin-top:8px;">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>
|
||||||
|
Generate Reimbursement PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tweaks panel mount (hidden until the host toggles Tweaks on) -->
|
||||||
|
<div id="tweak-root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useTweaks, TweaksPanel, TweakSection, TweakSlider, TweakRadio, TweakColor } = window;
|
||||||
|
|
||||||
|
// Defaults reflect the shipping baseline. Each accent palette is
|
||||||
|
// [--accent, --accent-hover, --accent-soft, --accent-border] so the
|
||||||
|
// panel can recolour the whole form by writing 4 vars in one shot.
|
||||||
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
"theme": "auto",
|
||||||
|
"accent": [
|
||||||
|
"#2F6FED",
|
||||||
|
"#1F57CF",
|
||||||
|
"#EEF3FE",
|
||||||
|
"#C7D9FB"
|
||||||
|
],
|
||||||
|
"fontScale": 1,
|
||||||
|
"radius": "sharp"
|
||||||
|
}/*EDITMODE-END*/;
|
||||||
|
|
||||||
|
const ACCENT_PALETTES = [
|
||||||
|
["#2F6FED","#1F57CF","#EEF3FE","#C7D9FB"], // kBenestad blue
|
||||||
|
["#1F8A5B","#136B45","#E2F3EA","#A7D7BA"], // forest (health / charity)
|
||||||
|
["#B33A3A","#8E2C2C","#FBEAEA","#EBBCBC"], // crimson (legal)
|
||||||
|
["#6B4FBB","#523795","#EEEAFB","#C6BCEF"] // plum (consulting)
|
||||||
|
];
|
||||||
|
|
||||||
|
const RADIUS_PRESETS = {
|
||||||
|
sharp: { r: "2px", rs: "2px" },
|
||||||
|
"default":{ r: "8px", rs: "6px" },
|
||||||
|
rounded: { r: "14px", rs: "10px" }
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyTweaks(t) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
// Theme: 'auto' clears the attr so the CSS @media (prefers-color-scheme) wins.
|
||||||
|
if (t.theme === "auto") root.removeAttribute("data-theme");
|
||||||
|
else root.setAttribute("data-theme", t.theme);
|
||||||
|
// Accent palette
|
||||||
|
const [a, h, s, b] = t.accent;
|
||||||
|
root.style.setProperty("--accent", a);
|
||||||
|
root.style.setProperty("--accent-hover", h);
|
||||||
|
root.style.setProperty("--accent-soft", s);
|
||||||
|
root.style.setProperty("--accent-border", b);
|
||||||
|
// Font scale (addresses the "text feels small" feedback)
|
||||||
|
root.style.setProperty("--font-scale", String(t.fontScale));
|
||||||
|
// Corner radius
|
||||||
|
const rp = RADIUS_PRESETS[t.radius] || RADIUS_PRESETS["default"];
|
||||||
|
root.style.setProperty("--radius", rp.r);
|
||||||
|
root.style.setProperty("--radius-sm", rp.rs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReimburseTweaks() {
|
||||||
|
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
React.useEffect(() => { applyTweaks(t); }, [t]);
|
||||||
|
return (
|
||||||
|
<TweaksPanel title="Reimburse">
|
||||||
|
<TweakSection label="Theme" />
|
||||||
|
<TweakRadio
|
||||||
|
label="Mode" value={t.theme}
|
||||||
|
options={["auto","light","dark"]}
|
||||||
|
onChange={(v) => setTweak("theme", v)} />
|
||||||
|
<TweakColor
|
||||||
|
label="Accent palette" value={t.accent}
|
||||||
|
options={ACCENT_PALETTES}
|
||||||
|
onChange={(v) => setTweak("accent", v)} />
|
||||||
|
|
||||||
|
<TweakSection label="Density & rhythm" />
|
||||||
|
<TweakSlider
|
||||||
|
label="Text scale" value={t.fontScale}
|
||||||
|
min={0.95} max={1.30} step={0.05} unit="×"
|
||||||
|
onChange={(v) => setTweak("fontScale", v)} />
|
||||||
|
<TweakRadio
|
||||||
|
label="Corner radius" value={t.radius}
|
||||||
|
options={["sharp","default","rounded"]}
|
||||||
|
onChange={(v) => setTweak("radius", v)} />
|
||||||
|
</TweaksPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for tweaks-panel.jsx + the rest to finish loading, then mount.
|
||||||
|
function mount() {
|
||||||
|
if (!window.TweaksPanel) return requestAnimationFrame(mount);
|
||||||
|
ReactDOM.createRoot(document.getElementById("tweak-root"))
|
||||||
|
.render(<ReimburseTweaks />);
|
||||||
|
}
|
||||||
|
mount();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="kb-footer">
|
||||||
|
<span class="kb-mark"><span class="glyph"><i></i><i></i></span>kBenestad</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>© 2026 Kristian Benestad</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<a href="https://docs.benestad.net/invoice">docs.benestad.net</a>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<a href="https://github.com/kbenestad/reimburse">kbenestad/reimburse</a>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
96
dev/mockups/review.html
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>kBenestad — unified forms review</title>
|
||||||
|
<link rel="stylesheet" href="kbenestad-forms.css">
|
||||||
|
<style>
|
||||||
|
html,body{height:100%;}
|
||||||
|
body.kb{display:flex;flex-direction:column;min-height:100vh;background:var(--bg);}
|
||||||
|
.rv-bar{
|
||||||
|
position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:14px;flex-wrap:wrap;
|
||||||
|
padding:12px 20px;background:var(--surface);border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.rv-title{display:flex;align-items:center;gap:11px;font-weight:700;font-size:15px;color:var(--text);letter-spacing:-.01em;}
|
||||||
|
.rv-title small{display:block;font-weight:500;font-size:12px;color:var(--text-muted);letter-spacing:0;}
|
||||||
|
.rv-bar .spacer{flex:1;}
|
||||||
|
.rv-bar .lbl{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);margin-right:-6px;}
|
||||||
|
.rv-stage{flex:1;padding:22px;display:flex;justify-content:center;}
|
||||||
|
.rv-framewrap{
|
||||||
|
width:100%;max-width:1000px;background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:14px;box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;
|
||||||
|
}
|
||||||
|
.rv-frametop{display:flex;align-items:center;gap:7px;padding:9px 14px;border-bottom:1px solid var(--border);background:var(--surface-2);}
|
||||||
|
.rv-dot{width:11px;height:11px;border-radius:50%;}
|
||||||
|
.rv-frameurl{margin-left:10px;font-family:var(--font-mono);font-size:12px;color:var(--text-muted);}
|
||||||
|
.rv-open{margin-left:auto;font-size:12px;font-weight:600;color:var(--accent);text-decoration:none;}
|
||||||
|
.rv-open:hover{text-decoration:underline;}
|
||||||
|
iframe{width:100%;height:1120px;border:0;background:var(--surface);display:block;}
|
||||||
|
@media (max-width:680px){ iframe{height:1500px;} }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function(){var p=new URLSearchParams(location.search).get('theme');
|
||||||
|
if(p&&p!=='auto')document.documentElement.setAttribute('data-theme',p);})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="kb">
|
||||||
|
|
||||||
|
<div class="rv-bar">
|
||||||
|
<div class="rv-title">
|
||||||
|
<span class="kb-mark"><span class="glyph"><i></i><i></i></span></span>
|
||||||
|
<span>kBenestad — unified forms<small>invoice · timesheet · reimburse — review build</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="lbl">App</span>
|
||||||
|
<div class="kb-seg" id="appSeg">
|
||||||
|
<button data-app="invoice.html" class="is-active">Invoice</button>
|
||||||
|
<button data-app="timesheet.html">Timesheet</button>
|
||||||
|
<button data-app="reimburse.html">Reimburse</button>
|
||||||
|
</div>
|
||||||
|
<span class="lbl">Theme</span>
|
||||||
|
<div class="kb-seg" id="themeSeg">
|
||||||
|
<button data-theme="auto" class="is-active">Auto</button>
|
||||||
|
<button data-theme="light">Light</button>
|
||||||
|
<button data-theme="dark">Dark</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rv-stage">
|
||||||
|
<div class="rv-framewrap">
|
||||||
|
<div class="rv-frametop">
|
||||||
|
<span class="rv-dot" style="background:#e0625b;"></span>
|
||||||
|
<span class="rv-dot" style="background:#e2b23a;"></span>
|
||||||
|
<span class="rv-dot" style="background:#3bab63;"></span>
|
||||||
|
<span class="rv-frameurl" id="frameUrl">invoice.html</span>
|
||||||
|
<a class="rv-open" id="openLink" href="invoice.html" target="_blank" rel="noopener">Open full ↗</a>
|
||||||
|
</div>
|
||||||
|
<iframe id="frame" src="invoice.html" title="App preview"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var app = 'invoice.html', theme = 'auto';
|
||||||
|
var frame = document.getElementById('frame');
|
||||||
|
var frameUrl = document.getElementById('frameUrl');
|
||||||
|
var openLink = document.getElementById('openLink');
|
||||||
|
|
||||||
|
function src(){ return app + (theme==='auto' ? '' : '?theme='+theme); }
|
||||||
|
function render(){
|
||||||
|
frame.src = src();
|
||||||
|
frameUrl.textContent = src();
|
||||||
|
openLink.href = src();
|
||||||
|
}
|
||||||
|
function wire(segId, set){
|
||||||
|
var seg = document.getElementById(segId);
|
||||||
|
seg.addEventListener('click', function(e){
|
||||||
|
var b = e.target.closest('button'); if(!b) return;
|
||||||
|
[].forEach.call(seg.children, function(c){c.classList.remove('is-active');});
|
||||||
|
b.classList.add('is-active'); set(b); render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wire('appSeg', function(b){ app = b.dataset.app; });
|
||||||
|
wire('themeSeg', function(b){ theme = b.dataset.theme; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
180
dev/mockups/timesheet.html
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<title>Timesheet — kBenestad reskin</title>
|
||||||
|
<link rel="stylesheet" href="kbenestad-forms.css">
|
||||||
|
<script>
|
||||||
|
(function(){var p=new URLSearchParams(location.search).get('theme');
|
||||||
|
if(p)document.documentElement.setAttribute('data-theme',p);})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* code colours come straight from timesheet-config.yml (codes[].colour-*) —
|
||||||
|
here as per-chip CSS vars so they stay fully configurable. */
|
||||||
|
.c-NON { --chip-border:#393939; --chip-bg:#d7d7d7; --chip-text:#393939; }
|
||||||
|
.c-REG { --chip-border:#0078d7; --chip-bg:#cce4f7; --chip-text:#0a4c87; }
|
||||||
|
.c-PPT { --chip-border:#ed616f; --chip-bg:#ffd9d9; --chip-text:#a8323d; }
|
||||||
|
.c-PPTp{ --chip-border:#ffb900; --chip-bg:#fff1cc; --chip-text:#8a6300; }
|
||||||
|
.c-OTH { --chip-border:#a4252c; --chip-bg:#edd4d5; --chip-text:#8a1f25; }
|
||||||
|
.c-UNP { --chip-border:#393939; --chip-bg:#d7d7d7; --chip-text:#393939; }
|
||||||
|
.c-HOL { --chip-border:#8cbd18; --chip-bg:#e8f2d1; --chip-text:#4f6b0d; }
|
||||||
|
:root[data-theme="dark"] .kb-chip { color: var(--chip-border); filter: saturate(1.1) brightness(1.15); }
|
||||||
|
|
||||||
|
.ts-head, .ts-row2 { grid-template-columns: 110px 102px 102px 110px 1fr 64px 30px; }
|
||||||
|
.ts-row2 input[type=time]{ font-family:var(--font-mono); }
|
||||||
|
.ts-date { font-weight:600; font-size:var(--fs-small); color:var(--text); white-space:nowrap; }
|
||||||
|
.ts-date .dow { display:block; font-weight:500; font-size:11px; color:var(--text-muted); }
|
||||||
|
.ts-legend { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:6px; }
|
||||||
|
@media (max-width:680px){
|
||||||
|
.ts-head{display:none;}
|
||||||
|
.ts-row2{grid-template-columns:1fr 1fr;gap:8px;background:var(--surface-2);padding:12px;border-radius:var(--radius-sm);border-left-width:4px;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="kb">
|
||||||
|
<div class="kb-wrap">
|
||||||
|
|
||||||
|
<div class="kb-toolbar">
|
||||||
|
<div class="kb-seg" role="group" aria-label="Language">
|
||||||
|
<button class="is-active">EN</button><button>Tiếng Việt</button>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="kb-seg" role="group" aria-label="Text size">
|
||||||
|
<button>A−</button><button class="is-active">A</button><button>A+</button>
|
||||||
|
</div>
|
||||||
|
<button class="kb-iconbtn" aria-label="About">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 11.2H7.1v1.5h1.8v-1.5zm0-8.4H7.1v6.2h1.8V2.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="kb-header">
|
||||||
|
<div class="kb-brand">
|
||||||
|
<span class="logo">CA</span>
|
||||||
|
<span class="org">Center for Asylum Protection<small>Timesheet · monthly</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-doctitle">
|
||||||
|
<h1>Timesheet</h1>
|
||||||
|
<div class="meta">1–7 June 2026</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- employee meta -->
|
||||||
|
<section class="kb-card">
|
||||||
|
<div class="kb-grid cols-3" style="gap:14px 16px;">
|
||||||
|
<div class="kb-field"><span class="kb-label">Employee</span><input class="kb-input" value="Linh Tran"></div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Employee type</span>
|
||||||
|
<select class="kb-select"><option>Monthly</option><option>Hourly</option><option>Freelance</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Period</span><input class="kb-input" value="1 Jun 2026 to 7 Jun 2026"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- entries -->
|
||||||
|
<section class="kb-card">
|
||||||
|
<h2 class="kb-card__title">Daily entries</h2>
|
||||||
|
|
||||||
|
<div class="ts-legend">
|
||||||
|
<span class="kb-chip c-REG"><span class="dot"></span>REG · Regular</span>
|
||||||
|
<span class="kb-chip c-PPT"><span class="dot"></span>PPT · Paid leave</span>
|
||||||
|
<span class="kb-chip c-OTH"><span class="dot"></span>OTH · Other paid</span>
|
||||||
|
<span class="kb-chip c-HOL"><span class="dot"></span>HOL · Holiday</span>
|
||||||
|
<span class="kb-chip c-UNP"><span class="dot"></span>UNP · Unpaid</span>
|
||||||
|
<span class="kb-chip c-NON"><span class="dot"></span>NON · Non-working</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kb-rowhead kb-row ts-head">
|
||||||
|
<span>Date</span><span>Time in</span><span>Time out</span><span>Code</span><span>Description</span><span class="r">Hours</span><span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kb-row ts-row2 coded c-REG">
|
||||||
|
<span class="ts-date">Mon 1 Jun<span class="dow">Monday</span></span>
|
||||||
|
<input class="kb-input" type="time" value="09:00"><input class="kb-input" type="time" value="17:00">
|
||||||
|
<select class="kb-select"><option>REG</option></select>
|
||||||
|
<input class="kb-input" value="Case intake & client interviews">
|
||||||
|
<span class="r kb-mono">8.0</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm">−</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kb-row ts-row2 coded c-REG">
|
||||||
|
<span class="ts-date">Tue 2 Jun<span class="dow">Tuesday</span></span>
|
||||||
|
<input class="kb-input" type="time" value="09:00"><input class="kb-input" type="time" value="17:00">
|
||||||
|
<select class="kb-select"><option>REG</option></select>
|
||||||
|
<input class="kb-input" value="Protection assessment reports">
|
||||||
|
<span class="r kb-mono">8.0</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm">−</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kb-row ts-row2 coded c-PPTp">
|
||||||
|
<span class="ts-date">Wed 3 Jun<span class="dow">Wednesday</span></span>
|
||||||
|
<input class="kb-input" type="time" value="09:00"><input class="kb-input" type="time" value="13:00">
|
||||||
|
<select class="kb-select"><option>PPT</option></select>
|
||||||
|
<input class="kb-input" value="Medical appointment (half day)">
|
||||||
|
<span class="r kb-mono">4.0</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm">−</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kb-row ts-row2 coded c-HOL">
|
||||||
|
<span class="ts-date">Thu 4 Jun<span class="dow">Thursday</span></span>
|
||||||
|
<input class="kb-input" type="time" value="—" disabled><input class="kb-input" type="time" value="—" disabled>
|
||||||
|
<select class="kb-select" disabled><option>HOL</option></select>
|
||||||
|
<input class="kb-input" value="Public holiday" readonly>
|
||||||
|
<span class="r kb-mono">8.0</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm">−</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kb-row ts-row2 coded c-REG">
|
||||||
|
<span class="ts-date">Fri 5 Jun<span class="dow">Friday</span></span>
|
||||||
|
<input class="kb-input" type="time" value="09:00"><input class="kb-input" type="time" value="16:00">
|
||||||
|
<select class="kb-select"><option>REG</option></select>
|
||||||
|
<input class="kb-input" value="Team sync & documentation">
|
||||||
|
<span class="r kb-mono">7.0</span>
|
||||||
|
<button class="kb-circbtn kb-circbtn--rm">−</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;"><button class="kb-btn kb-btn--dashed">+ Add row</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- summary -->
|
||||||
|
<section class="kb-card">
|
||||||
|
<h2 class="kb-card__title">Summary</h2>
|
||||||
|
<div class="kb-totals kb-totals--fill">
|
||||||
|
<div class="row"><span class="lab">Total hours</span><span class="val">35.0</span></div>
|
||||||
|
<div class="row"><span class="lab">Of which paid leave</span><span class="val">4.0</span></div>
|
||||||
|
<div class="row"><span class="lab">Of which holiday</span><span class="val">8.0</span></div>
|
||||||
|
<div class="grand"><span class="lab">Total (decimal)</span><span class="val">35.00</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- signatures -->
|
||||||
|
<section class="kb-card">
|
||||||
|
<h2 class="kb-card__title">Signatures</h2>
|
||||||
|
<div class="kb-grid cols-2" style="gap:18px;">
|
||||||
|
<div class="kb-field"><span class="kb-label">Employee signature</span><div class="kb-sig"></div></div>
|
||||||
|
<div class="kb-field"><span class="kb-label">Authorised signature</span><div class="kb-sig"></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:12px;align-items:center;margin-top:8px;flex-wrap:wrap;">
|
||||||
|
<button class="kb-btn kb-btn--ghost">New timesheet</button>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<button class="kb-btn kb-btn--soft kb-btn--lg">Validate</button>
|
||||||
|
<button class="kb-btn kb-btn--primary kb-btn--lg">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>
|
||||||
|
Generate Timesheet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="kb-footer">
|
||||||
|
<span class="kb-mark"><span class="glyph"><i></i><i></i></span>kBenestad</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>© 2026 Kristian Benestad</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<a href="https://docs.benestad.net">docs.benestad.net</a>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<a href="https://github.com/kbenestad/timesheet">kbenestad/timesheet</a>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
541
dev/mockups/tweaks-panel.jsx
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||||
|
|
||||||
|
/* BEGIN USAGE */
|
||||||
|
// tweaks-panel.jsx
|
||||||
|
// Reusable Tweaks shell + form-control helpers.
|
||||||
|
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
|
||||||
|
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
|
||||||
|
//
|
||||||
|
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||||
|
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||||
|
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||||
|
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||||
|
//
|
||||||
|
// Usage (in an HTML file that loads React + Babel):
|
||||||
|
//
|
||||||
|
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
// "primaryColor": "#D97757",
|
||||||
|
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
|
||||||
|
// "fontSize": 16,
|
||||||
|
// "density": "regular",
|
||||||
|
// "dark": false
|
||||||
|
// }/*EDITMODE-END*/;
|
||||||
|
//
|
||||||
|
// function App() {
|
||||||
|
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
// return (
|
||||||
|
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||||
|
// Hello
|
||||||
|
// <TweaksPanel>
|
||||||
|
// <TweakSection label="Typography" />
|
||||||
|
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||||
|
// onChange={(v) => setTweak('fontSize', v)} />
|
||||||
|
// <TweakRadio label="Density" value={t.density}
|
||||||
|
// options={['compact', 'regular', 'comfy']}
|
||||||
|
// onChange={(v) => setTweak('density', v)} />
|
||||||
|
// <TweakSection label="Theme" />
|
||||||
|
// <TweakColor label="Primary" value={t.primaryColor}
|
||||||
|
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||||
|
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||||
|
// <TweakColor label="Palette" value={t.palette}
|
||||||
|
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||||
|
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||||
|
// onChange={(v) => setTweak('palette', v)} />
|
||||||
|
// <TweakToggle label="Dark mode" value={t.dark}
|
||||||
|
// onChange={(v) => setTweak('dark', v)} />
|
||||||
|
// </TweaksPanel>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// TweakRadio is the segmented control for 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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||||
|
background-repeat:no-repeat;background-position:right 8px center}
|
||||||
|
|
||||||
|
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||||
|
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||||
|
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||||
|
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||||
|
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
|
||||||
|
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||||
|
background:rgba(0,0,0,.06);user-select:none}
|
||||||
|
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||||
|
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||||
|
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||||
|
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||||
|
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||||
|
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||||
|
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||||
|
overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||||
|
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||||
|
.twk-toggle[data-on="1"]{background:#34c759}
|
||||||
|
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||||
|
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||||
|
|
||||||
|
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||||
|
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||||
|
user-select:none;padding-right:8px}
|
||||||
|
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||||
|
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||||
|
outline:none;color:inherit;-moz-appearance:textfield}
|
||||||
|
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||||
|
-webkit-appearance:none;margin:0}
|
||||||
|
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||||
|
|
||||||
|
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||||
|
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||||
|
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||||
|
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||||
|
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||||
|
|
||||||
|
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||||
|
background:transparent;flex-shrink:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
|
||||||
|
.twk-chips{display:flex;gap:6px}
|
||||||
|
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
||||||
|
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
||||||
|
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
||||||
|
.twk-chip:hover{transform:translateY(-1px);
|
||||||
|
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
||||||
|
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
|
||||||
|
0 2px 6px rgba(0,0,0,.15)}
|
||||||
|
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
||||||
|
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
||||||
|
.twk-chip>span>i:first-child{box-shadow:none}
|
||||||
|
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
|
||||||
|
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||||
|
// Single source of truth for tweak values. setTweak persists via the host
|
||||||
|
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||||
|
function useTweaks(defaults) {
|
||||||
|
const [values, setValues] = React.useState(defaults);
|
||||||
|
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||||
|
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||||
|
// JSON block.
|
||||||
|
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||||
|
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||||
|
? keyOrEdits : { [keyOrEdits]: val };
|
||||||
|
setValues((prev) => ({ ...prev, ...edits }));
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||||
|
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
|
||||||
|
// can react — the parent message only reaches the host, not peers.
|
||||||
|
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
||||||
|
}, []);
|
||||||
|
return [values, setTweak];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||||
|
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||||
|
// availability — if the announce ran first, the host's activate could land
|
||||||
|
// before our handler exists and the toolbar toggle would silently no-op.
|
||||||
|
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||||
|
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||||
|
// is what actually hides the panel.
|
||||||
|
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dragRef = React.useRef(null);
|
||||||
|
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||||
|
const PAD = 16;
|
||||||
|
|
||||||
|
const clampToViewport = React.useCallback(() => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||||
|
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||||
|
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||||
|
offsetRef.current = {
|
||||||
|
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||||
|
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||||
|
};
|
||||||
|
panel.style.right = offsetRef.current.x + 'px';
|
||||||
|
panel.style.bottom = offsetRef.current.y + 'px';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
clampToViewport();
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', clampToViewport);
|
||||||
|
return () => window.removeEventListener('resize', clampToViewport);
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(clampToViewport);
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [open, clampToViewport]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
const t = e?.data?.type;
|
||||||
|
if (t === '__activate_edit_mode') setOpen(true);
|
||||||
|
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setOpen(false);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e) => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const r = panel.getBoundingClientRect();
|
||||||
|
const sx = e.clientX, sy = e.clientY;
|
||||||
|
const startRight = window.innerWidth - r.right;
|
||||||
|
const startBottom = window.innerHeight - r.bottom;
|
||||||
|
const move = (ev) => {
|
||||||
|
offsetRef.current = {
|
||||||
|
x: startRight - (ev.clientX - sx),
|
||||||
|
y: startBottom - (ev.clientY - sy),
|
||||||
|
};
|
||||||
|
clampToViewport();
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{__TWEAKS_STYLE}</style>
|
||||||
|
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
|
||||||
|
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||||
|
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||||
|
<b>{title}</b>
|
||||||
|
<button className="twk-x" aria-label="Close tweaks"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={dismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="twk-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSection({ label, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="twk-sect">{label}</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRow({ label, value, children, inline = false }) {
|
||||||
|
return (
|
||||||
|
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||||
|
<div className="twk-lbl">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value != null && <span className="twk-val">{value}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label} value={`${value}${unit}`}>
|
||||||
|
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakToggle({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||||
|
role="switch" aria-checked={!!value}
|
||||||
|
onClick={() => onChange(!value)}><i /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRadio({ label, value, options, onChange }) {
|
||||||
|
const trackRef = React.useRef(null);
|
||||||
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
// The active value is read by pointer-move handlers attached for the lifetime
|
||||||
|
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||||
|
const valueRef = React.useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
// Segments wrap mid-word once per-segment width runs out. The track is
|
||||||
|
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
|
||||||
|
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
|
||||||
|
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
|
||||||
|
// back to a dropdown rather than wrap.
|
||||||
|
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
||||||
|
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
||||||
|
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
||||||
|
if (!fitsAsSegments) {
|
||||||
|
// <select> emits strings — map back to the original option value so the
|
||||||
|
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
||||||
|
const resolve = (s) => {
|
||||||
|
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
||||||
|
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
||||||
|
};
|
||||||
|
return <TweakSelect label={label} value={value} options={options}
|
||||||
|
onChange={(s) => onChange(resolve(s))} />;
|
||||||
|
}
|
||||||
|
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||||
|
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||||
|
const n = opts.length;
|
||||||
|
|
||||||
|
const segAt = (clientX) => {
|
||||||
|
const r = trackRef.current.getBoundingClientRect();
|
||||||
|
const inner = r.width - 4;
|
||||||
|
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||||
|
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
setDragging(true);
|
||||||
|
const v0 = segAt(e.clientX);
|
||||||
|
if (v0 !== valueRef.current) onChange(v0);
|
||||||
|
const move = (ev) => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
const v = segAt(ev.clientX);
|
||||||
|
if (v !== valueRef.current) onChange(v);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
setDragging(false);
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||||
|
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||||
|
<div className="twk-seg-thumb"
|
||||||
|
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||||
|
width: `calc((100% - 4px) / ${n})` }} />
|
||||||
|
{opts.map((o) => (
|
||||||
|
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakSelect({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const v = typeof o === 'object' ? o.value : o;
|
||||||
|
const l = typeof o === 'object' ? o.label : o;
|
||||||
|
return <option key={v} value={v}>{l}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakText({ label, value, placeholder, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||||
|
const clamp = (n) => {
|
||||||
|
if (min != null && n < min) return min;
|
||||||
|
if (max != null && n > max) return max;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const startRef = React.useRef({ x: 0, val: 0 });
|
||||||
|
const onScrubStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startRef.current = { x: e.clientX, val: value };
|
||||||
|
const decimals = (String(step).split('.')[1] || '').length;
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startRef.current.x;
|
||||||
|
const raw = startRef.current.val + dx * step;
|
||||||
|
const snapped = Math.round(raw / step) * step;
|
||||||
|
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="twk-num">
|
||||||
|
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||||
|
<input type="number" value={value} min={min} max={max} step={step}
|
||||||
|
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||||
|
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
|
||||||
|
// read on both #111 and #fafafa without per-option configuration. Hex input
|
||||||
|
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
|
||||||
|
function __twkIsLight(hex) {
|
||||||
|
const h = String(hex).replace('#', '');
|
||||||
|
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
||||||
|
const n = parseInt(x.slice(0, 6), 16);
|
||||||
|
if (Number.isNaN(n)) return true;
|
||||||
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||||
|
return r * 299 + g * 587 + b * 114 > 148000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __TwkCheck = ({ light }) => (
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// TweakColor — curated color/palette picker. Each option is either a single
|
||||||
|
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
|
||||||
|
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
|
||||||
|
// rest stacked in a sharp column on the right. onChange emits the
|
||||||
|
// option in the shape it was passed (string stays string, array stays array).
|
||||||
|
// Without options it falls back to the native color input for back-compat.
|
||||||
|
function TweakColor({ label, value, options, onChange }) {
|
||||||
|
if (!options || !options.length) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<input type="color" className="twk-swatch" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Native <input type=color> emits lowercase hex per the HTML spec, so
|
||||||
|
// compare case-insensitively. String() guards JSON.stringify(undefined),
|
||||||
|
// which returns the primitive undefined (no .toLowerCase).
|
||||||
|
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
||||||
|
const cur = key(value);
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div className="twk-chips" role="radiogroup">
|
||||||
|
{options.map((o, i) => {
|
||||||
|
const colors = Array.isArray(o) ? o : [o];
|
||||||
|
const [hero, ...rest] = colors;
|
||||||
|
const sup = rest.slice(0, 4);
|
||||||
|
const on = key(o) === cur;
|
||||||
|
return (
|
||||||
|
<button key={i} type="button" className="twk-chip" role="radio"
|
||||||
|
aria-checked={on} data-on={on ? '1' : '0'}
|
||||||
|
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||||
|
style={{ background: hero }}
|
||||||
|
onClick={() => onChange(o)}>
|
||||||
|
{sup.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakButton({ label, onClick, secondary = false }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||||
|
onClick={onClick}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||||
|
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||||
|
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||||
|
});
|
||||||
159
dev/theme/README.md
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# kBenestad theme for Forgejo
|
||||||
|
|
||||||
|
The kBenestad design language as a Forgejo theme — Nordic-minimal, light-first,
|
||||||
|
one calm blue accent. Ships in **light**, **dark**, and **auto** (follows the
|
||||||
|
visitor's OS preference).
|
||||||
|
|
||||||
|
| File | Theme name | Notes |
|
||||||
|
|------|------------|-------|
|
||||||
|
| `theme-kbenestad-light.css` | `kbenestad-light` | cool paper ground, slate ink |
|
||||||
|
| `theme-kbenestad-dark.css` | `kbenestad-dark` | deep slate, brightened blue |
|
||||||
|
| `theme-kbenestad-auto.css` | `kbenestad-auto` | light by day, dark by night |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Each theme defines a **complete, self-contained** kBenestad variable set — every
|
||||||
|
`--color-*`, the type stack, and the corner radii — then layers a small set of
|
||||||
|
**structural overrides** that carry the identity the colors alone can't: Schibsted
|
||||||
|
Grotesk / JetBrains Mono type, accent-soft topic pills, flat primary buttons, and
|
||||||
|
hairline 8px cards. It also `@import`s Forgejo's matching shipped theme as a
|
||||||
|
harmless safety net (it fills any future upstream variables if present, and is
|
||||||
|
silently ignored if absent), so the theme keeps working across upgrades.
|
||||||
|
|
||||||
|
## Matching the kBenestad mark
|
||||||
|
|
||||||
|
The brand direction is **Stack**. Final SVGs live in the design system under
|
||||||
|
`assets/logo/`. To dress Forgejo:
|
||||||
|
|
||||||
|
| Forgejo path | Use this file |
|
||||||
|
|---|---|
|
||||||
|
| `custom/public/img/logo.svg` | `mark-stack-color.svg` (or `app-icon.svg` for a tiled mark) |
|
||||||
|
| `custom/public/img/favicon.svg` | `favicon.svg` |
|
||||||
|
|
||||||
|
No restart needed — hard refresh. For the reversed navbar on the dark theme the
|
||||||
|
mark already inherits `currentColor` where possible; if you want a fixed reverse,
|
||||||
|
use `mark-stack-white.svg`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Copy the `.css` files into your custom assets CSS directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
<FORGEJO_CUSTOM>/public/assets/css/
|
||||||
|
```
|
||||||
|
|
||||||
|
On most installs `FORGEJO_CUSTOM` is `/data/gitea` (Docker) or the `custom/`
|
||||||
|
folder beside your `app.ini`. The files must sit next to the shipped
|
||||||
|
`theme-forgejo-*.css` so the relative `@import`s resolve.
|
||||||
|
|
||||||
|
2. Register the themes in `app.ini` under `[ui]`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[ui]
|
||||||
|
THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-auto,kbenestad-light,kbenestad-dark
|
||||||
|
DEFAULT_THEME = kbenestad-auto
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via environment variables (Docker):
|
||||||
|
|
||||||
|
```
|
||||||
|
FORGEJO__ui__THEMES=forgejo-auto,forgejo-light,forgejo-dark,kbenestad-auto,kbenestad-light,kbenestad-dark
|
||||||
|
FORGEJO__ui__DEFAULT_THEME=kbenestad-auto
|
||||||
|
```
|
||||||
|
|
||||||
|
3. A hard refresh is enough — no restart needed for CSS changes. Users can also
|
||||||
|
pick the theme per-account under **Settings → Appearance**.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
The themes load **Schibsted Grotesk** and **JetBrains Mono** from Google Fonts via
|
||||||
|
`@import` at the top of each file, and force the families directly so they apply
|
||||||
|
the instant the fonts are available.
|
||||||
|
|
||||||
|
### If the type still looks like the system font
|
||||||
|
|
||||||
|
This means the **web font never loaded** — the colors and layout will look right,
|
||||||
|
but text falls back to your OS sans-serif. The `@import` from `fonts.googleapis.com`
|
||||||
|
is being blocked. Common causes:
|
||||||
|
|
||||||
|
- A privacy / tracker blocker in the browser (Vivaldi's built-in blocker, uBlock,
|
||||||
|
Privacy Badger, etc.) blocks Google Fonts domains.
|
||||||
|
- A reverse-proxy or `[security]` Content-Security-Policy that disallows external
|
||||||
|
styles/fonts.
|
||||||
|
- The instance is offline / air-gapped.
|
||||||
|
|
||||||
|
**Fix — self-host the fonts (recommended, bulletproof):**
|
||||||
|
|
||||||
|
1. Download the woff2 files:
|
||||||
|
- Schibsted Grotesk (400/500/600/700) — <https://fonts.google.com/specimen/Schibsted+Grotesk>
|
||||||
|
- JetBrains Mono (400/500/600) — <https://fonts.google.com/specimen/JetBrains+Mono>
|
||||||
|
2. Drop them in `custom/public/assets/fonts/`.
|
||||||
|
3. Delete the `@import url('https://fonts.googleapis.com/…')` line at the top of
|
||||||
|
each kBenestad theme file and paste a local block in its place, e.g.:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@font-face {
|
||||||
|
font-family: "Schibsted Grotesk";
|
||||||
|
font-weight: 400 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/assets/fonts/SchibstedGrotesk.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrains Mono";
|
||||||
|
font-weight: 400 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/assets/fonts/JetBrainsMono.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adjust filenames to the files you downloaded. `/assets/fonts/…` is served
|
||||||
|
directly by Forgejo from `custom/public/assets/fonts/`.)
|
||||||
|
|
||||||
|
The `--fonts-proportional` / `--fonts-monospace` variables already point at the
|
||||||
|
right family names, so no other change is needed.
|
||||||
|
|
||||||
|
## Troubleshooting — "the theme deployed but it still looks stock"
|
||||||
|
|
||||||
|
Forgejo's compiled `index.css` is the raw Fomantic/Semantic-UI base, which
|
||||||
|
*hardcodes* legacy colours (`.ui.primary.button{background:#2185d0}`,
|
||||||
|
`.ui.button{background:#e0e1e2}`, `.ui.label`…). Your `--color-*` variables only
|
||||||
|
take effect once the theme stylesheet that re-points those components onto the
|
||||||
|
variables is the one actually loaded. If the page still looks like default
|
||||||
|
Forgejo, work down this list:
|
||||||
|
|
||||||
|
1. **Stale cache (most common).** Forgejo serves files in
|
||||||
|
`custom/public/assets/css/` with a 6-hour browser cache and **no `?v=` buster**
|
||||||
|
(unlike the versioned `index.css?v=9.0.3~gitea-1.22.0`). After editing a theme
|
||||||
|
file, the browser keeps the old copy. Fix: hard-refresh (Ctrl/Cmd-Shift-R), or
|
||||||
|
bump `STATIC_CACHE_TIME` down while iterating, or append a throwaway query when
|
||||||
|
testing. A server restart does **not** clear the *browser's* copy.
|
||||||
|
2. **Theme not selected.** Confirm `DEFAULT_THEME = kbenestad-light` (or `-auto`)
|
||||||
|
in `[ui]`, *and* that your account isn't pinned to another theme under
|
||||||
|
**Settings → Appearance**. A per-user choice overrides the default.
|
||||||
|
3. **Quick sanity check.** Open dev-tools → inspect `<body>` → Computed →
|
||||||
|
`--color-primary`. It must read `#2f6fed` (ours), not `#4183c4`/`#2185d0`
|
||||||
|
(stock). If it's the stock value, the kBenestad file isn't winning the cascade
|
||||||
|
— that's cause 1 or 2, not the CSS itself.
|
||||||
|
4. **Right file path.** The `.css` must sit in `custom/public/assets/css/` so it's
|
||||||
|
served at `/assets/css/theme-kbenestad-light.css` and the relative
|
||||||
|
`@import "./theme-forgejo-light.css"` resolves next to it.
|
||||||
|
|
||||||
|
## What the theme recolours (and what it can't)
|
||||||
|
|
||||||
|
Pulled onto the brand: primary/positive buttons, repo-header owner/name, repo
|
||||||
|
tabs (active label + accent underline) and their count pills, topic chips,
|
||||||
|
dropdown/pagination active states, form-focus rings, checkboxes/toggles and
|
||||||
|
progress bars. **Language bars** (the Go/Shell/HTML stripe on the repo home) are
|
||||||
|
**not** themeable — Forgejo emits those segment colours as inline styles from its
|
||||||
|
per-language colour table, so they stay their canonical hues by design.
|
||||||
|
|
||||||
|
## Tweaking the accent
|
||||||
|
|
||||||
|
The entire accent ramp derives from the Nordic blue `#2f6fed`. To shift it, edit
|
||||||
|
the `--color-primary*` block (and the matching `rgba(47, 111, 237, …)` alpha
|
||||||
|
values) in each theme file.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
Built against the modern Forgejo CSS-variable theming system (Forgejo v7.0+).
|
||||||
|
Gitea compatibility is likely but untested.
|
||||||
101
dev/theme/kbenestad.yaml
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# mdcms v0.4 | DO NOT REMOVE THIS COMMENT
|
||||||
|
# mdcms theme — kBenestad
|
||||||
|
# Nordic-minimal: cool paper ground, near-black slate ink, one calm blue accent.
|
||||||
|
# Mirrors the kBenestad design language (Schibsted Grotesk + a single #2F6FED blue).
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# 1. Copy this file into your project root (next to config.yml).
|
||||||
|
# 2. In config.yml set: theme: kbenestad.yaml
|
||||||
|
# (or rename this file to theme.yml to replace the default)
|
||||||
|
|
||||||
|
# ──────────────────────────────────
|
||||||
|
# Colours
|
||||||
|
# ──────────────────────────────────
|
||||||
|
light:
|
||||||
|
accent: "#2F6FED"
|
||||||
|
background: "#FFFFFF"
|
||||||
|
nav-background: "#F8F9FB"
|
||||||
|
text: "#14181E"
|
||||||
|
text-muted: "#6B7785"
|
||||||
|
nav-link: "#3A434F" # inactive nav link text
|
||||||
|
nav-link-active: "#2F6FED" # active nav link text
|
||||||
|
nav-section-heading: "#6B7785" # nav section label text
|
||||||
|
nav-sitename: "#14181E" # site name in sidebar header
|
||||||
|
nav-description: "#6B7785" # site description in sidebar header
|
||||||
|
nav-toggle: "#6B7785" # dark/light mode toggle
|
||||||
|
divider: "#E7EAEF" # border/hr colour
|
||||||
|
|
||||||
|
dark:
|
||||||
|
accent: "#5685E9"
|
||||||
|
background: "#0D1117"
|
||||||
|
nav-background: "#161B22"
|
||||||
|
text: "#EEF1F5"
|
||||||
|
text-muted: "#8B95A1"
|
||||||
|
nav-link: "#C2CAD3" # inactive nav link text
|
||||||
|
nav-link-active: "#5685E9" # active nav link text
|
||||||
|
nav-section-heading: "#8B95A1" # nav section label text
|
||||||
|
nav-sitename: "#EEF1F5" # site name in sidebar header
|
||||||
|
nav-description: "#8B95A1" # site description in sidebar header
|
||||||
|
nav-toggle: "#8B95A1" # dark/light mode toggle
|
||||||
|
divider: "#232A33" # border/hr colour
|
||||||
|
|
||||||
|
# ──────────────────────────────────
|
||||||
|
# Semantic colours
|
||||||
|
# Used by callout tags (info, warning, success, error).
|
||||||
|
# colours-semantic applies to both modes; colours-semantic-dark overrides for dark mode.
|
||||||
|
# ──────────────────────────────────
|
||||||
|
colours-semantic:
|
||||||
|
info: "#2F6FED"
|
||||||
|
warning: "#C9851F"
|
||||||
|
success: "#1F9D5F"
|
||||||
|
error: "#D64545"
|
||||||
|
|
||||||
|
colours-semantic-dark:
|
||||||
|
info: "#88ABF2"
|
||||||
|
warning: "#D99A3A"
|
||||||
|
success: "#3BB97A"
|
||||||
|
error: "#E06464"
|
||||||
|
|
||||||
|
# ──────────────────────────────────
|
||||||
|
# Callout defaults
|
||||||
|
# primary-colour matches colours-semantic (light mode).
|
||||||
|
# ──────────────────────────────────
|
||||||
|
callouts:
|
||||||
|
info:
|
||||||
|
icon: info
|
||||||
|
primary-colour: "#2F6FED"
|
||||||
|
background-colour: "#2F6FED"
|
||||||
|
warning:
|
||||||
|
icon: warning
|
||||||
|
primary-colour: "#C9851F"
|
||||||
|
background-colour: "#C9851F"
|
||||||
|
success:
|
||||||
|
icon: success
|
||||||
|
primary-colour: "#1F9D5F"
|
||||||
|
background-colour: "#1F9D5F"
|
||||||
|
error:
|
||||||
|
icon: error
|
||||||
|
primary-colour: "#D64545"
|
||||||
|
background-colour: "#D64545"
|
||||||
|
|
||||||
|
# ──────────────────────────────────
|
||||||
|
# Typography
|
||||||
|
# Format: "provider:Font Name:weight" (provider: bunny | google)
|
||||||
|
# Schibsted Grotesk is the kBenestad type voice — clean Nordic grotesque.
|
||||||
|
# ──────────────────────────────────
|
||||||
|
font-body: "bunny:Schibsted Grotesk:400"
|
||||||
|
font-heading: "bunny:Schibsted Grotesk:700"
|
||||||
|
font-size: 1.00 # unitless multiplier (1.0 = 16px base)
|
||||||
|
line-height: 1.70 # unitless multiplier
|
||||||
|
|
||||||
|
# ──────────────────────────────────
|
||||||
|
# Nav section toggle icons
|
||||||
|
# ──────────────────────────────────
|
||||||
|
nav-section-expand-icon: keyboard_arrow_right
|
||||||
|
nav-section-collapse-icon: keyboard_arrow_down
|
||||||
|
|
||||||
|
# ──────────────────────────────────
|
||||||
|
# Layout
|
||||||
|
# ──────────────────────────────────
|
||||||
|
main-width: 80em
|
||||||
|
nav-width: 20em
|
||||||
827
dev/theme/preview.html
Normal file
|
|
@ -0,0 +1,827 @@
|
||||||
|
<!-- @dsCard group="Brand" name="Forgejo theme preview" subtitle="Repo page mock — light + dark" viewport="1280x900" -->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>kb / utils · code.kbenestad</title>
|
||||||
|
<meta name="viewport" content="width=1280">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* ───────────────────────────────────────────────────────────────
|
||||||
|
kBenestad → Forgejo · preview palette
|
||||||
|
Single file: this mirrors what theme-kbenestad-{light,dark}.css
|
||||||
|
produce on a real Forgejo install, scoped to [data-theme].
|
||||||
|
─────────────────────────────────────────────────────────────── */
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #f8f9fb;
|
||||||
|
--box: #ffffff;
|
||||||
|
--box-header: #f8f9fb;
|
||||||
|
--border: #e7eaef;
|
||||||
|
--border-soft: #eef0f4;
|
||||||
|
--text: #14181e;
|
||||||
|
--text-soft: #3a434f;
|
||||||
|
--text-mute: #6b7785;
|
||||||
|
--text-faint: #97a0ac;
|
||||||
|
--accent: #2f6fed;
|
||||||
|
--accent-hover: #1f57cf;
|
||||||
|
--accent-soft: #eef3fe;
|
||||||
|
--accent-ring: rgba(47,111,237,.18);
|
||||||
|
--code-bg: #f1f3f6;
|
||||||
|
--green: #1f9d5f;
|
||||||
|
--green-soft: #d7f0e1;
|
||||||
|
--hover: #f1f3f6;
|
||||||
|
--shadow: 0 1px 0 rgba(20,24,30,.02);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--box: #161b22;
|
||||||
|
--box-header: #1c232c;
|
||||||
|
--border: #232a33;
|
||||||
|
--border-soft: #1c232c;
|
||||||
|
--text: #eef1f5;
|
||||||
|
--text-soft: #c2cad3;
|
||||||
|
--text-mute: #8b95a1;
|
||||||
|
--text-faint: #6f7986;
|
||||||
|
--accent: #2f6fed;
|
||||||
|
--accent-hover: #4f82ec;
|
||||||
|
--accent-soft: #16233f;
|
||||||
|
--accent-ring: rgba(86,133,233,.28);
|
||||||
|
--code-bg: #0f141a;
|
||||||
|
--green: #3bb97a;
|
||||||
|
--green-soft: #13301f;
|
||||||
|
--hover: #1c232c;
|
||||||
|
--shadow: 0 1px 0 rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
letter-spacing: -.005em;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
code, pre, .mono { font-family: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.container { max-width: 1216px; margin: 0 auto; padding: 0 32px; }
|
||||||
|
|
||||||
|
/* ── Top navbar ─────────────────────────────────────────────── */
|
||||||
|
.nav {
|
||||||
|
height: 56px;
|
||||||
|
background: var(--box);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.nav-inner { width: 100%; display: flex; align-items: center; gap: 24px; }
|
||||||
|
.brand {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
color: var(--text); font-weight: 700; font-size: 15px;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
position: relative; width: 22px; height: 22px;
|
||||||
|
}
|
||||||
|
.brand-mark span {
|
||||||
|
position: absolute; width: 14px; height: 14px;
|
||||||
|
border-radius: 4px; background: var(--accent);
|
||||||
|
}
|
||||||
|
.brand-mark span:nth-child(1) { left: 0; top: 0; opacity: .55; }
|
||||||
|
.brand-mark span:nth-child(2) { right: 0; bottom: 0; background: var(--text); }
|
||||||
|
.brand-dot { color: var(--text-mute); font-weight: 500; }
|
||||||
|
.nav-links { display: flex; gap: 4px; flex: 1; }
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--text-soft); font-weight: 500; font-size: 14px;
|
||||||
|
padding: 6px 10px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { background: var(--hover); text-decoration: none; color: var(--text); }
|
||||||
|
.nav-right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.search {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
height: 32px; padding: 0 12px;
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text-mute); font-size: 13px; min-width: 240px;
|
||||||
|
}
|
||||||
|
.search .kbd {
|
||||||
|
margin-left: auto; padding: 1px 6px;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
font-family: "JetBrains Mono", monospace; font-size: 11px;
|
||||||
|
color: var(--text-faint); background: var(--box);
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
width: 32px; height: 32px; display: grid; place-items: center;
|
||||||
|
border-radius: 6px; color: var(--text-soft); cursor: pointer;
|
||||||
|
background: transparent; border: 0;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--hover); color: var(--text); }
|
||||||
|
.avatar {
|
||||||
|
width: 28px; height: 28px; border-radius: 99px;
|
||||||
|
background: linear-gradient(135deg, #2f6fed, #6b5fd2);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
color: #fff; font-weight: 700; font-size: 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme toggle (preview chrome, not part of Forgejo) ─────── */
|
||||||
|
.theme-toggle {
|
||||||
|
position: fixed; top: 14px; right: 18px; z-index: 50;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 10px; height: 30px;
|
||||||
|
background: var(--box); color: var(--text-soft);
|
||||||
|
border: 1px solid var(--border); border-radius: 99px;
|
||||||
|
font: 600 12px/1 "Schibsted Grotesk", system-ui;
|
||||||
|
cursor: pointer; box-shadow: 0 2px 12px rgba(20,24,30,.10);
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { color: var(--text); }
|
||||||
|
.theme-toggle svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* ── Repo header ─────────────────────────────────────────────── */
|
||||||
|
.repo-header { padding: 24px 0 0; }
|
||||||
|
.repo-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.repo-title .repo-icon { color: var(--text-mute); }
|
||||||
|
.repo-title h1 {
|
||||||
|
margin: 0; font-size: 20px; font-weight: 500;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.repo-title h1 .owner { color: var(--accent); font-weight: 500; }
|
||||||
|
.repo-title h1 .slash { color: var(--text-faint); font-weight: 400; }
|
||||||
|
.repo-title h1 .name { color: var(--accent); font-weight: 700; }
|
||||||
|
.badge {
|
||||||
|
font-size: 11px; font-weight: 600; padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border); border-radius: 99px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
.repo-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 30px; padding: 0 12px;
|
||||||
|
background: var(--box); color: var(--text); font-weight: 500; font-size: 13px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--hover); }
|
||||||
|
.btn .count {
|
||||||
|
margin-left: 4px; padding: 1px 7px;
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 99px; font-size: 11px; color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||||
|
.btn-primary .count {
|
||||||
|
background: rgba(255,255,255,.18); border-color: transparent; color: rgba(255,255,255,.9);
|
||||||
|
}
|
||||||
|
.btn svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* ── Tabs ────────────────────────────────────────────────────── */
|
||||||
|
.tabs {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; gap: 4px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
position: relative; display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 14px; color: var(--text-soft); font-size: 14px; font-weight: 500;
|
||||||
|
border-radius: 6px 6px 0 0; cursor: pointer; margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover { background: var(--hover); color: var(--text); text-decoration: none; }
|
||||||
|
.tab svg { width: 14px; height: 14px; color: var(--text-mute); }
|
||||||
|
.tab.active { color: var(--accent); font-weight: 600; }
|
||||||
|
.tab.active svg { color: var(--accent); }
|
||||||
|
.tab.active::after {
|
||||||
|
content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px;
|
||||||
|
background: var(--accent); border-radius: 2px;
|
||||||
|
}
|
||||||
|
.tab .count {
|
||||||
|
background: var(--accent-soft); border: 1px solid var(--accent-ring);
|
||||||
|
border-radius: 99px; padding: 0 7px; font-size: 11px; font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.tab.active .count {
|
||||||
|
background: var(--accent); border-color: var(--accent); color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main grid ──────────────────────────────────────────────── */
|
||||||
|
.main {
|
||||||
|
display: grid; grid-template-columns: 1fr 296px; gap: 28px;
|
||||||
|
margin: 24px 0 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subbar (branch + code button) ──────────────────────────── */
|
||||||
|
.subbar {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.branch-select {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 30px; padding: 0 10px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
background: var(--box); color: var(--text); font-weight: 500; font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.branch-select svg { width: 14px; height: 14px; color: var(--text-mute); }
|
||||||
|
.ref-counts {
|
||||||
|
display: flex; gap: 14px; color: var(--text-soft); font-size: 13px;
|
||||||
|
}
|
||||||
|
.ref-counts a { color: var(--text-soft); }
|
||||||
|
.ref-counts strong { color: var(--text); font-weight: 600; }
|
||||||
|
.subbar .spacer { flex: 1; }
|
||||||
|
.go-file {
|
||||||
|
display: inline-flex; align-items: center; white-space: nowrap;
|
||||||
|
height: 30px; padding: 0 12px;
|
||||||
|
border: 1px solid var(--border); background: var(--box);
|
||||||
|
border-radius: 6px; color: var(--text-soft); font-size: 13px;
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Box (cards / tables) ───────────────────────────────────── */
|
||||||
|
.box {
|
||||||
|
background: var(--box);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── File table ─────────────────────────────────────────────── */
|
||||||
|
.commit-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--box-header);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.commit-row .avatar { width: 22px; height: 22px; font-size: 10px; }
|
||||||
|
.commit-row .author { font-weight: 600; color: var(--text); }
|
||||||
|
.commit-row .msg { color: var(--text-soft); }
|
||||||
|
.commit-row .sha {
|
||||||
|
margin-left: auto; color: var(--text-mute);
|
||||||
|
font-family: "JetBrains Mono", monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.commit-row .when { color: var(--text-mute); font-size: 12px; }
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
display: grid; grid-template-columns: 24px 1fr 2fr auto;
|
||||||
|
align-items: center; gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.file-row:first-of-type { border-top: 0; }
|
||||||
|
.file-row:hover { background: var(--hover); }
|
||||||
|
.file-row .icon { color: var(--text-mute); display: grid; place-items: center; }
|
||||||
|
.file-row .icon.folder { color: var(--accent); }
|
||||||
|
.file-row .name { color: var(--text); font-weight: 500; }
|
||||||
|
.file-row .name a { color: inherit; }
|
||||||
|
.file-row .msg {
|
||||||
|
color: var(--text-mute); font-size: 13px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-row .msg a { color: var(--text-mute); }
|
||||||
|
.file-row .msg a:hover { color: var(--accent); }
|
||||||
|
.file-row .when {
|
||||||
|
color: var(--text-mute); font-size: 12px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── README card ─────────────────────────────────────────────── */
|
||||||
|
.readme { margin-top: 20px; }
|
||||||
|
.readme-header {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--box-header);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px; font-weight: 600; color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.readme-header svg { width: 14px; height: 14px; color: var(--text-mute); }
|
||||||
|
.readme-body { padding: 28px 36px 36px; }
|
||||||
|
.readme-body h1 {
|
||||||
|
margin: 0 0 4px; font-size: 28px; font-weight: 700; letter-spacing: -.02em;
|
||||||
|
padding-bottom: 10px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.readme-body h2 {
|
||||||
|
margin: 28px 0 10px; font-size: 19px; font-weight: 600; letter-spacing: -.01em;
|
||||||
|
padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.readme-body p { margin: 0 0 12px; color: var(--text-soft); }
|
||||||
|
.readme-body ul { padding-left: 22px; margin: 0 0 12px; color: var(--text-soft); }
|
||||||
|
.readme-body ul li { margin: 4px 0; }
|
||||||
|
.readme-body ul li code { color: var(--text); }
|
||||||
|
.readme-body code {
|
||||||
|
background: var(--code-bg); padding: 1px 6px; border-radius: 4px;
|
||||||
|
font-size: 12.5px; color: var(--text);
|
||||||
|
}
|
||||||
|
.readme-body pre {
|
||||||
|
background: var(--code-bg); padding: 14px 16px;
|
||||||
|
border-radius: 8px; margin: 0 0 16px;
|
||||||
|
overflow-x: auto; font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.readme-body pre code { background: transparent; padding: 0; font-size: 13px; }
|
||||||
|
.readme-body .tag { color: var(--accent); }
|
||||||
|
.readme-body .com { color: var(--text-faint); }
|
||||||
|
|
||||||
|
/* ── Sidebar ────────────────────────────────────────────────── */
|
||||||
|
.side-section { margin-bottom: 24px; }
|
||||||
|
.side-section h3 {
|
||||||
|
margin: 0 0 12px; font-size: 13px; font-weight: 600;
|
||||||
|
color: var(--text); letter-spacing: -.005em;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.side-section h3 .edit {
|
||||||
|
margin-left: auto; color: var(--text-mute); cursor: pointer;
|
||||||
|
}
|
||||||
|
.side-section .desc { color: var(--text-soft); margin: 0 0 12px; }
|
||||||
|
.side-meta { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
|
||||||
|
.side-meta li {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
color: var(--text-soft); font-size: 13px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.side-meta li svg { width: 14px; height: 14px; color: var(--text-mute); flex: 0 0 14px; }
|
||||||
|
.side-meta li a { color: var(--text-soft); }
|
||||||
|
.side-meta li a:hover { color: var(--accent); }
|
||||||
|
.topics { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.topic {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--accent-ring);
|
||||||
|
}
|
||||||
|
.topic:hover { background: var(--accent); color: #fff; text-decoration: none; }
|
||||||
|
.divider { height: 1px; background: var(--border); margin: 20px 0; border: 0; }
|
||||||
|
|
||||||
|
.release {
|
||||||
|
display: flex; gap: 10px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.release .tag-icon {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
background: var(--green-soft); color: var(--green);
|
||||||
|
border-radius: 6px; display: grid; place-items: center;
|
||||||
|
flex: 0 0 28px;
|
||||||
|
}
|
||||||
|
.release-meta { flex: 1; min-width: 0; }
|
||||||
|
.release-title {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--text);
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
font-size: 10px; font-weight: 700; padding: 1px 7px;
|
||||||
|
background: var(--green); color: #fff;
|
||||||
|
border-radius: 99px; text-transform: uppercase; letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
.release-when { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||||
|
|
||||||
|
.lang-bar {
|
||||||
|
display: flex; height: 8px; border-radius: 99px; overflow: hidden;
|
||||||
|
background: var(--border); margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.lang-key {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px;
|
||||||
|
font-size: 12px; color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.lang-key .dot {
|
||||||
|
display: inline-block; width: 10px; height: 10px;
|
||||||
|
border-radius: 99px; margin-right: 6px; vertical-align: -1px;
|
||||||
|
}
|
||||||
|
.lang-key .pct { color: var(--text-mute); margin-left: 2px; }
|
||||||
|
|
||||||
|
/* ── Footer ─────────────────────────────────────────────────── */
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-mute); font-size: 12px;
|
||||||
|
padding: 16px 0 32px;
|
||||||
|
display: flex; gap: 18px;
|
||||||
|
}
|
||||||
|
.footer a { color: var(--text-mute); }
|
||||||
|
.footer a:hover { color: var(--accent); }
|
||||||
|
.footer .spacer { flex: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- preview chrome: theme toggle ───────────────────────────────── -->
|
||||||
|
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" id="themeIcon" aria-hidden="true">
|
||||||
|
<path d="M8 12.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zM8 0a.5.5 0 0 1 .5.5v1.5a.5.5 0 0 1-1 0V.5A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5V15a.5.5 0 0 1-1 0v-1.5A.5.5 0 0 1 8 13zM3.05 2.343a.5.5 0 0 1 .707 0l1.06 1.06a.5.5 0 1 1-.707.708L3.05 3.05a.5.5 0 0 1 0-.707zm8.486 8.486a.5.5 0 0 1 .707 0l1.06 1.06a.5.5 0 1 1-.707.708l-1.06-1.06a.5.5 0 0 1 0-.708zM0 8a.5.5 0 0 1 .5-.5H2a.5.5 0 0 1 0 1H.5A.5.5 0 0 1 0 8zm13 0a.5.5 0 0 1 .5-.5H15a.5.5 0 0 1 0 1h-1.5A.5.5 0 0 1 13 8zM2.343 12.95a.5.5 0 0 1 0-.707l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.5.5 0 0 1-.708 0zm8.486-8.486a.5.5 0 0 1 0-.707l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.5.5 0 0 1-.708 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span id="themeLabel">Light</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ── Nav ─────────────────────────────────────────────────────── -->
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="container nav-inner">
|
||||||
|
<a class="brand" href="#">
|
||||||
|
<span class="brand-mark" aria-hidden="true"><span></span><span></span></span>
|
||||||
|
<span>code<span class="brand-dot">.kbenestad</span></span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#">Dashboard</a>
|
||||||
|
<a href="#">Issues</a>
|
||||||
|
<a href="#">Pull Requests</a>
|
||||||
|
<a href="#">Milestones</a>
|
||||||
|
<a href="#">Explore</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-right">
|
||||||
|
<div class="search">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M10.68 11.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04zM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0z"/></svg>
|
||||||
|
<span>Search…</span>
|
||||||
|
<span class="kbd">⌘K</span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" aria-label="New">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8.75 1a.75.75 0 0 0-1.5 0v6.25H1a.75.75 0 0 0 0 1.5h6.25V15a.75.75 0 0 0 1.5 0V8.75H15a.75.75 0 0 0 0-1.5H8.75V1z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" aria-label="Notifications">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5a.75.75 0 0 1 .75.75v.554a4.752 4.752 0 0 1 3.5 4.586v1.79l1.058 2.116A.75.75 0 0 1 12.63 12.4H10.2a2.25 2.25 0 0 1-4.4 0H3.37a.75.75 0 0 1-.67-1.085l1.05-2.1V7.39A4.752 4.752 0 0 1 7.25 2.804v-.554A.75.75 0 0 1 8 1.5zM7.2 12.4a.75.75 0 0 0 1.6 0H7.2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="avatar" title="kb">KB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- ── Repo header ───────────────────────────────────────────── -->
|
||||||
|
<header class="repo-header">
|
||||||
|
<div class="repo-title">
|
||||||
|
<svg class="repo-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5zm10.5-1H4.5a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8zM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2z"/></svg>
|
||||||
|
<h1>
|
||||||
|
<span class="owner">kb</span><span class="slash">/</span><span class="name">utils</span>
|
||||||
|
</h1>
|
||||||
|
<span class="badge">Public</span>
|
||||||
|
<div class="repo-actions">
|
||||||
|
<button class="btn">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.67-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2zM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717zM8 10a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>
|
||||||
|
Watch<span class="count">7</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25z"/></svg>
|
||||||
|
Star<span class="count">142</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0zM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0z"/></svg>
|
||||||
|
Fork<span class="count">23</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Tabs ───────────────────────────────────────────────── -->
|
||||||
|
<div class="tabs">
|
||||||
|
<a class="tab active" href="#">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4.72 3.22a.75.75 0 0 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06zm6.56 0a.75.75 0 0 0-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06l4.25-4.25a.75.75 0 0 0 0-1.06z"/></svg>
|
||||||
|
Code
|
||||||
|
</a>
|
||||||
|
<a class="tab" href="#">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0z"/></svg>
|
||||||
|
Issues <span class="count">12</span>
|
||||||
|
</a>
|
||||||
|
<a class="tab" href="#">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354z"/></svg>
|
||||||
|
Pull Requests <span class="count">3</span>
|
||||||
|
</a>
|
||||||
|
<a class="tab" href="#">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25z"/></svg>
|
||||||
|
Actions
|
||||||
|
</a>
|
||||||
|
<a class="tab" href="#">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="m8.878.392 5.25 3.045a2 2 0 0 1 .872 1.617v7.642a2 2 0 0 1-.872 1.617l-5.25 3.045a2 2 0 0 1-2.066 0L1.872 14.32a2 2 0 0 1-.872-1.617V5.054a2 2 0 0 1 .872-1.617L7.122.392a2 2 0 0 1 2.066 0z"/></svg>
|
||||||
|
Packages
|
||||||
|
</a>
|
||||||
|
<a class="tab" href="#">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775zM6 5a1 1 0 1 0-2 0 1 1 0 0 0 2 0z"/></svg>
|
||||||
|
Releases <span class="count">4</span>
|
||||||
|
</a>
|
||||||
|
<a class="tab" href="#">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75z"/></svg>
|
||||||
|
Wiki
|
||||||
|
</a>
|
||||||
|
<a class="tab" href="#">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C5.91.645 6.457.095 7.2.031A8.2 8.2 0 0 1 8 0zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"/></svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── Main content + sidebar ────────────────────────────────── -->
|
||||||
|
<div class="main">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<!-- subbar ------------------------------------------------- -->
|
||||||
|
<div class="subbar">
|
||||||
|
<button class="branch-select">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.49 2.49 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25z"/></svg>
|
||||||
|
<strong>main</strong>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="opacity:.7"><path d="M12.78 6.22a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.22 7.28a.75.75 0 0 1 1.06-1.06L8 9.94l3.72-3.72a.75.75 0 0 1 1.06 0z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="ref-counts">
|
||||||
|
<a href="#"><strong>12</strong> Branches</a>
|
||||||
|
<a href="#"><strong>8</strong> Tags</a>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="go-file">Go to file</span>
|
||||||
|
<button class="btn">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8.75 1a.75.75 0 0 0-1.5 0v6.25H1a.75.75 0 0 0 0 1.5h6.25V15a.75.75 0 0 0 1.5 0V8.75H15a.75.75 0 0 0 0-1.5H8.75V1z"/></svg>
|
||||||
|
Add file
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4.72 3.22a.75.75 0 0 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06zm6.56 0a.75.75 0 0 0-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06l4.25-4.25a.75.75 0 0 0 0-1.06z"/></svg>
|
||||||
|
Code
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="margin-left:2px;width:12px;height:12px"><path d="M12.78 6.22a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.22 7.28a.75.75 0 0 1 1.06-1.06L8 9.94l3.72-3.72a.75.75 0 0 1 1.06 0z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- file table -------------------------------------------- -->
|
||||||
|
<div class="box">
|
||||||
|
<div class="commit-row">
|
||||||
|
<div class="avatar" style="background:linear-gradient(135deg,#1f9d5f,#2f6fed);">KB</div>
|
||||||
|
<span class="author">kb</span>
|
||||||
|
<span class="msg">Tighten retry backoff jitter; cap at 30s</span>
|
||||||
|
<span class="sha">a8f3c0e</span>
|
||||||
|
<span class="when">· 2 days ago</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon folder">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">.forgejo</a></span>
|
||||||
|
<span class="msg"><a href="#">ci: pin runner image to v2</a></span>
|
||||||
|
<span class="when">3 weeks ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon folder">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">cmd</a></span>
|
||||||
|
<span class="msg"><a href="#">cmd/kbu: drop deprecated --raw flag</a></span>
|
||||||
|
<span class="when">last month</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon folder">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">internal</a></span>
|
||||||
|
<span class="msg"><a href="#">internal/retry: jitter cap 30s</a></span>
|
||||||
|
<span class="when">2 days ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon folder">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">pkg</a></span>
|
||||||
|
<span class="msg"><a href="#">pkg/slug: handle combining marks correctly</a></span>
|
||||||
|
<span class="when">last week</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon folder">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3h-6.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5.25 1z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">testdata</a></span>
|
||||||
|
<span class="msg"><a href="#">golden: refresh fixtures for v2.4</a></span>
|
||||||
|
<span class="when">2 weeks ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">.editorconfig</a></span>
|
||||||
|
<span class="msg"><a href="#">chore: standardize 2-space yaml</a></span>
|
||||||
|
<span class="when">3 months ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">.gitignore</a></span>
|
||||||
|
<span class="msg"><a href="#">ignore local kbpkg cache dirs</a></span>
|
||||||
|
<span class="when">6 months ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">LICENSE</a></span>
|
||||||
|
<span class="msg"><a href="#">Initial commit</a></span>
|
||||||
|
<span class="when">2 years ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">Makefile</a></span>
|
||||||
|
<span class="msg"><a href="#">make: split test:unit / test:race</a></span>
|
||||||
|
<span class="when">last month</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">README.md</a></span>
|
||||||
|
<span class="msg"><a href="#">docs: usage examples for retry helper</a></span>
|
||||||
|
<span class="when">2 days ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">go.mod</a></span>
|
||||||
|
<span class="msg"><a href="#">deps: go 1.22; bump x/sync</a></span>
|
||||||
|
<span class="when">last month</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="name"><a href="#">go.sum</a></span>
|
||||||
|
<span class="msg"><a href="#">deps: go 1.22; bump x/sync</a></span>
|
||||||
|
<span class="when">last month</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- README ------------------------------------------------ -->
|
||||||
|
<div class="box readme">
|
||||||
|
<div class="readme-header">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.744 3.744 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75z"/></svg>
|
||||||
|
README.md
|
||||||
|
</div>
|
||||||
|
<div class="readme-body">
|
||||||
|
<h1>utils</h1>
|
||||||
|
<p>A small, opinionated bundle of Go helpers used across <code>kb/*</code> services —
|
||||||
|
retry/backoff, slug, env loading, structured errors. Zero non-stdlib runtime
|
||||||
|
dependencies; everything else is dev-only.</p>
|
||||||
|
|
||||||
|
<h2>Install</h2>
|
||||||
|
<pre><code><span class="com"># Go module</span>
|
||||||
|
go get code.kbenestad.net/kb/utils@v2.4.0
|
||||||
|
|
||||||
|
<span class="com"># Or via kbpkg, our internal package manager</span>
|
||||||
|
kbpkg install kb/utils
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<h2>Usage</h2>
|
||||||
|
<pre><code><span class="tag">package</span> main
|
||||||
|
|
||||||
|
<span class="tag">import</span> (
|
||||||
|
"context"
|
||||||
|
"code.kbenestad.net/kb/utils/retry"
|
||||||
|
)
|
||||||
|
|
||||||
|
<span class="tag">func</span> main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = retry.Do(ctx, retry.Default, <span class="tag">func</span>() <span class="tag">error</span> {
|
||||||
|
<span class="com">// network call here</span>
|
||||||
|
<span class="tag">return</span> nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<h2>What's inside</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>retry</code> — context-aware exponential backoff with full jitter (cap 30s).</li>
|
||||||
|
<li><code>slug</code> — Unicode-correct slugification; handles combining marks.</li>
|
||||||
|
<li><code>envx</code> — typed env loading with defaults and required-key checks.</li>
|
||||||
|
<li><code>errs</code> — structured error wrapping that survives JSON round-trips.</li>
|
||||||
|
<li><code>iox</code> — small io helpers (limited readers, atomic file writes).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Stability</h2>
|
||||||
|
<p>Public API follows semver. Anything under <code>internal/</code> is fair game and
|
||||||
|
will change without notice.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Sidebar ───────────────────────────────────────────── -->
|
||||||
|
<aside>
|
||||||
|
<section class="side-section">
|
||||||
|
<h3>About <span class="edit" title="Edit">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M.18 13.86A1.75 1.75 0 0 0 0 14.629V15.5a.5.5 0 0 0 .5.5h.871a1.75 1.75 0 0 0 .77-.18l9.55-4.775a.5.5 0 0 0 .224-.224l1.286-2.572a.5.5 0 0 0-.09-.567L9.318.18a.5.5 0 0 0-.567-.09L6.18 1.378a.5.5 0 0 0-.224.224L1.18 11.151a1.75 1.75 0 0 0-.18.77z"/></svg>
|
||||||
|
</span></h3>
|
||||||
|
<p class="desc">Small Go helpers shared across kBenestad services — retry, slug, env, structured errors.</p>
|
||||||
|
<div class="topics">
|
||||||
|
<a class="topic" href="#">go</a>
|
||||||
|
<a class="topic" href="#">utilities</a>
|
||||||
|
<a class="topic" href="#">retry</a>
|
||||||
|
<a class="topic" href="#">backoff</a>
|
||||||
|
<a class="topic" href="#">kbenestad</a>
|
||||||
|
</div>
|
||||||
|
<ul class="side-meta" style="margin-top:14px">
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.744 3.744 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75z"/></svg>
|
||||||
|
Readme
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C13.928 10.86 13.144 11 12.5 11s-1.428-.14-2.072-.46a4.07 4.07 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L11.628 4.5h-.539c-.166 0-.331-.027-.49-.078L9.293 4.94c-.279.156-.594.236-.913.234V13h2.25a.75.75 0 0 1 0 1.5h-6a.75.75 0 0 1 0-1.5h2.25V5.173l-1-.572a.51.51 0 0 0-.123-.033H4.91l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C5.428 10.86 4.644 11 4 11s-1.428-.14-2.072-.46a4.07 4.07 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L3.128 4.5H2.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.868-.23h.985V.75a.75.75 0 0 1 1.5 0z"/></svg>
|
||||||
|
<a href="#">MIT License</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm0 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zm.75 3.25v3.5h2.5a.75.75 0 0 1 0 1.5h-3.25a.75.75 0 0 1-.75-.75V4.75a.75.75 0 0 1 1.5 0z"/></svg>
|
||||||
|
<a href="#">Activity</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.67-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2zM8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5zM8 6a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>
|
||||||
|
<a href="#">7 watching</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25z"/></svg>
|
||||||
|
<a href="#">142 stars</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0z"/></svg>
|
||||||
|
<a href="#">23 forks</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<section class="side-section">
|
||||||
|
<h3>Releases <span style="color:var(--text-mute);font-weight:500;margin-left:auto">4</span></h3>
|
||||||
|
<div class="release">
|
||||||
|
<div class="tag-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775zM6 5a1 1 0 1 0-2 0 1 1 0 0 0 2 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="release-meta">
|
||||||
|
<div class="release-title">
|
||||||
|
<a href="#">v2.4.0</a>
|
||||||
|
<span class="pill">Latest</span>
|
||||||
|
</div>
|
||||||
|
<div class="release-when">2 days ago</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:12px 0 0;color:var(--text-mute);font-size:13px">
|
||||||
|
<a href="#">+ 3 releases</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<section class="side-section">
|
||||||
|
<h3>Languages</h3>
|
||||||
|
<div class="lang-bar" aria-label="Language breakdown">
|
||||||
|
<span style="width:78%;background:#00ADD8"></span>
|
||||||
|
<span style="width:14%;background:var(--text-mute)"></span>
|
||||||
|
<span style="width:8%;background:var(--green)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lang-key">
|
||||||
|
<span><span class="dot" style="background:#00ADD8"></span>Go<span class="pct"> 78.2%</span></span>
|
||||||
|
<span><span class="dot" style="background:var(--text-mute)"></span>Shell<span class="pct"> 13.7%</span></span>
|
||||||
|
<span><span class="dot" style="background:var(--green)"></span>Makefile<span class="pct"> 8.1%</span></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Footer ─────────────────────────────────────────────────── -->
|
||||||
|
<footer class="footer">
|
||||||
|
<span>Powered by Forgejo · kBenestad theme</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a href="#">English</a>
|
||||||
|
<a href="#">Licenses</a>
|
||||||
|
<a href="#">API</a>
|
||||||
|
<a href="#">Source</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Theme toggle ↔ localStorage. Order: stored → OS → light.
|
||||||
|
(function () {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var btn = document.getElementById('themeToggle');
|
||||||
|
var lbl = document.getElementById('themeLabel');
|
||||||
|
var ico = document.getElementById('themeIcon');
|
||||||
|
|
||||||
|
var SUN = '<path d="M8 12.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zM8 0a.5.5 0 0 1 .5.5v1.5a.5.5 0 0 1-1 0V.5A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5V15a.5.5 0 0 1-1 0v-1.5A.5.5 0 0 1 8 13zM3.05 2.343a.5.5 0 0 1 .707 0l1.06 1.06a.5.5 0 1 1-.707.708L3.05 3.05a.5.5 0 0 1 0-.707zm8.486 8.486a.5.5 0 0 1 .707 0l1.06 1.06a.5.5 0 1 1-.707.708l-1.06-1.06a.5.5 0 0 1 0-.708zM0 8a.5.5 0 0 1 .5-.5H2a.5.5 0 0 1 0 1H.5A.5.5 0 0 1 0 8zm13 0a.5.5 0 0 1 .5-.5H15a.5.5 0 0 1 0 1h-1.5A.5.5 0 0 1 13 8zM2.343 12.95a.5.5 0 0 1 0-.707l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.5.5 0 0 1-.708 0zm8.486-8.486a.5.5 0 0 1 0-.707l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.5.5 0 0 1-.708 0z"/>';
|
||||||
|
var MOON = '<path d="M6 .278a.77.77 0 0 1 .08.858 7.2 7.2 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.78.78 0 0 1 .81.316.73.73 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.78.78 0 0 1 6 .278z"/>';
|
||||||
|
|
||||||
|
function apply(t) {
|
||||||
|
root.setAttribute('data-theme', t);
|
||||||
|
lbl.textContent = t === 'dark' ? 'Dark' : 'Light';
|
||||||
|
ico.innerHTML = t === 'dark' ? MOON : SUN;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored = null;
|
||||||
|
try { stored = localStorage.getItem('kb-theme'); } catch (e) {}
|
||||||
|
var initial = stored || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
apply(initial);
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||||
|
apply(next);
|
||||||
|
try { localStorage.setItem('kb-theme', next); } catch (e) {}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
dev/theme/theme-kbenestad-auto.css
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/* ============================================================================
|
||||||
|
theme-kbenestad-auto.css
|
||||||
|
kBenestad — Forgejo theme (AUTO)
|
||||||
|
Follows the visitor's OS light/dark preference: kBenestad light by day,
|
||||||
|
kBenestad dark by night. Register as `kbenestad-auto` in app.ini [ui] THEMES.
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Media-conditional @imports load the matching kBenestad theme; the base
|
||||||
|
forgejo-auto import guarantees a complete variable set as a fallback.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@import "./theme-forgejo-auto.css";
|
||||||
|
@import "./theme-kbenestad-light.css" (prefers-color-scheme: light);
|
||||||
|
@import "./theme-kbenestad-dark.css" (prefers-color-scheme: dark);
|
||||||
|
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
464
dev/theme/theme-kbenestad-dark.css
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
/* ============================================================================
|
||||||
|
theme-kbenestad-dark.css
|
||||||
|
kBenestad — Forgejo theme (DARK)
|
||||||
|
Faithful inversion onto deep slate (#0d1117) with a lighter blue for contrast.
|
||||||
|
|
||||||
|
Install: drop this file in custom/public/assets/css/
|
||||||
|
then in app.ini [ui]
|
||||||
|
THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-light,kbenestad-dark
|
||||||
|
DEFAULT_THEME = kbenestad-dark
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Strategy: a COMPLETE, self-contained variable set followed by the same
|
||||||
|
structural overrides as the light theme — Schibsted Grotesk type, accent-soft
|
||||||
|
topic pills, flat buttons, hairline cards.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
@import "./theme-forgejo-dark.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--is-dark-theme: true;
|
||||||
|
|
||||||
|
/* ── Type ──────────────────────────────────────────────────────────────────
|
||||||
|
Forgejo resolves body type as var(--fonts-override, var(--fonts-proportional)),
|
||||||
|
"Noto Sans", … — so these vars ARE the hook. Marked !important to win even
|
||||||
|
if the shipped base theme sets them !important first (per Forgejo's docs). */
|
||||||
|
--fonts-proportional: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
|
||||||
|
--fonts-regular: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
|
||||||
|
--fonts-monospace: "JetBrains Mono", ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace !important;
|
||||||
|
|
||||||
|
/* ── Corners ───────────────────────────────────────────────────────────── */
|
||||||
|
--border-radius: 6px;
|
||||||
|
--border-radius-medium: 8px;
|
||||||
|
--border-radius-full: 99999px;
|
||||||
|
|
||||||
|
/* ── Accent — Nordic blue, brightened toward light for dark ground ─────── */
|
||||||
|
--color-primary: #2f6fed;
|
||||||
|
--color-primary-contrast: #ffffff;
|
||||||
|
--color-primary-dark-1: #4f82ec;
|
||||||
|
--color-primary-dark-2: #6c98ef;
|
||||||
|
--color-primary-dark-3: #88abf2;
|
||||||
|
--color-primary-dark-4: #a6c1f5;
|
||||||
|
--color-primary-dark-5: #c2d5f8;
|
||||||
|
--color-primary-dark-6: #dbe7fb;
|
||||||
|
--color-primary-dark-7: #eef3fe;
|
||||||
|
--color-primary-light-1: #245ac8;
|
||||||
|
--color-primary-light-2: #1f57cf;
|
||||||
|
--color-primary-light-3: #1b46a3;
|
||||||
|
--color-primary-light-4: #163b88;
|
||||||
|
--color-primary-light-5: #142e63;
|
||||||
|
--color-primary-light-6: #102450;
|
||||||
|
--color-primary-light-7: #0c1c3e;
|
||||||
|
--color-primary-alpha-10: rgba(86, 133, 233, 0.10);
|
||||||
|
--color-primary-alpha-20: rgba(86, 133, 233, 0.20);
|
||||||
|
--color-primary-alpha-30: rgba(86, 133, 233, 0.30);
|
||||||
|
--color-primary-alpha-40: rgba(86, 133, 233, 0.40);
|
||||||
|
--color-primary-alpha-50: rgba(86, 133, 233, 0.50);
|
||||||
|
--color-primary-alpha-60: rgba(86, 133, 233, 0.60);
|
||||||
|
--color-primary-alpha-70: rgba(86, 133, 233, 0.70);
|
||||||
|
--color-primary-alpha-80: rgba(86, 133, 233, 0.80);
|
||||||
|
--color-primary-alpha-90: rgba(86, 133, 233, 0.90);
|
||||||
|
--color-primary-hover: #4f82ec;
|
||||||
|
--color-primary-active: #6c98ef;
|
||||||
|
|
||||||
|
/* ── Secondary ─────────────────────────────────────────────────────────── */
|
||||||
|
--color-secondary: #2d3641;
|
||||||
|
--color-secondary-dark-1: #3d4856;
|
||||||
|
--color-secondary-dark-2: #4a5666;
|
||||||
|
--color-secondary-dark-3: #5f6975;
|
||||||
|
--color-secondary-dark-4: #6f7986;
|
||||||
|
--color-secondary-dark-5: #8b95a1;
|
||||||
|
--color-secondary-dark-6: #9aa4af;
|
||||||
|
--color-secondary-dark-7: #aab2bd;
|
||||||
|
--color-secondary-dark-8: #c2cad3;
|
||||||
|
--color-secondary-dark-9: #d3d9e0;
|
||||||
|
--color-secondary-dark-10: #e1e6eb;
|
||||||
|
--color-secondary-dark-11: #eef1f5;
|
||||||
|
--color-secondary-dark-12: #f5f7f9;
|
||||||
|
--color-secondary-dark-13: #ffffff;
|
||||||
|
--color-secondary-light-1: #262e38;
|
||||||
|
--color-secondary-light-2: #232a33;
|
||||||
|
--color-secondary-light-3: #1c232c;
|
||||||
|
--color-secondary-light-4: #161b22;
|
||||||
|
--color-secondary-alpha-10: rgba(123, 134, 148, 0.10);
|
||||||
|
--color-secondary-alpha-20: rgba(123, 134, 148, 0.20);
|
||||||
|
--color-secondary-alpha-30: rgba(123, 134, 148, 0.30);
|
||||||
|
--color-secondary-alpha-40: rgba(123, 134, 148, 0.40);
|
||||||
|
--color-secondary-alpha-50: rgba(123, 134, 148, 0.50);
|
||||||
|
--color-secondary-alpha-60: rgba(123, 134, 148, 0.60);
|
||||||
|
--color-secondary-alpha-70: rgba(123, 134, 148, 0.70);
|
||||||
|
--color-secondary-alpha-80: rgba(123, 134, 148, 0.80);
|
||||||
|
--color-secondary-alpha-90: rgba(123, 134, 148, 0.90);
|
||||||
|
--color-secondary-button: #3d4856;
|
||||||
|
--color-secondary-hover: #1c232c;
|
||||||
|
--color-secondary-active: #232b35;
|
||||||
|
|
||||||
|
/* ── Text / ink ─────────────────────────────────────────────────────────── */
|
||||||
|
--color-text-dark: #ffffff;
|
||||||
|
--color-text: #eef1f5;
|
||||||
|
--color-text-light: #c2cad3;
|
||||||
|
--color-text-light-1: #8b95a1;
|
||||||
|
--color-text-light-2: #6f7986;
|
||||||
|
--color-text-light-3: #5f6975;
|
||||||
|
--color-placeholder-text: #5f6975;
|
||||||
|
--color-text-focus: #ffffff;
|
||||||
|
|
||||||
|
/* ── Surfaces ──────────────────────────────────────────────────────────── */
|
||||||
|
--color-body: #0d1117; /* page ground (deep slate) */
|
||||||
|
--color-box-body: #161b22; /* cards, file list, panels */
|
||||||
|
--color-box-body-highlight: #1c232c;
|
||||||
|
--color-box-header: #1c232c; /* segment / table headers */
|
||||||
|
--color-nav-bg: #161b22; /* top navbar */
|
||||||
|
--color-nav-hover-bg: #1c232c;
|
||||||
|
--color-secondary-nav-bg: #0d1117;
|
||||||
|
--color-footer: #161b22;
|
||||||
|
--color-light: rgba(139, 149, 161, 0.05);
|
||||||
|
--color-light-mimic-enabled: rgba(255, 255, 255, calc(8 / 255));
|
||||||
|
--color-light-border: #232a33;
|
||||||
|
--color-hover: #1c232c; /* row / item hover */
|
||||||
|
--color-active: #232b35; /* active row */
|
||||||
|
--color-menu: #161b22;
|
||||||
|
--color-card: #161b22;
|
||||||
|
--fancy-card-bg: #161b22;
|
||||||
|
--fancy-card-border: #232a33;
|
||||||
|
--color-markup-tab-default: var(--color-box-header);
|
||||||
|
--color-markup-tab-active: var(--color-box-body);
|
||||||
|
--color-header-wrapper: #161b22;
|
||||||
|
--color-header-wrapper-transparent: rgba(22, 27, 34, 0);
|
||||||
|
|
||||||
|
/* ── Inputs ────────────────────────────────────────────────────────────── */
|
||||||
|
--color-input-text: #eef1f5;
|
||||||
|
--color-input-background: #0f141a;
|
||||||
|
--color-input-toggle-background: #0f141a;
|
||||||
|
--color-input-border: #2d3641;
|
||||||
|
--color-input-border-hover: #3d4856;
|
||||||
|
|
||||||
|
/* ── Borders / dividers ────────────────────────────────────────────────── */
|
||||||
|
--color-border: #232a33;
|
||||||
|
--color-secondary-bg: #1c232c;
|
||||||
|
|
||||||
|
/* ── Code / markup ─────────────────────────────────────────────────────── */
|
||||||
|
--color-markup-code-block: #0f141a;
|
||||||
|
--color-markup-code-inline: #1c232c;
|
||||||
|
--color-markup-table-row: rgba(255, 255, 255, 0.024);
|
||||||
|
--color-code-bg: #161b22;
|
||||||
|
--color-code-sidebar-bg: #0f141a;
|
||||||
|
|
||||||
|
/* ── Shadows ───────────────────────────────────────────────────────────── */
|
||||||
|
--color-shadow: rgba(0, 0, 0, 0.45);
|
||||||
|
--color-secondary-shadow: rgba(0, 0, 0, 0.30);
|
||||||
|
|
||||||
|
/* ── Accent line / selection / timeline / caret ────────────────────────── */
|
||||||
|
--color-accent: #5685e9;
|
||||||
|
--color-small-accent: #19243a;
|
||||||
|
--color-active-line: #16233f;
|
||||||
|
--color-editor-line-highlight: #16233f;
|
||||||
|
--color-timeline: #2d3641;
|
||||||
|
--color-caret: #eef1f5;
|
||||||
|
--color-highlight-fg: #88abf2;
|
||||||
|
--color-highlight-bg: rgba(86, 133, 233, 0.16);
|
||||||
|
--color-selection-bg: #16233f;
|
||||||
|
--color-selection-fg: #eef1f5;
|
||||||
|
--color-overlay-backdrop: rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
|
/* ── Semantic hues ─────────────────────────────────────────────────────── */
|
||||||
|
--color-red: #e06464;
|
||||||
|
--color-orange: #d99a3a;
|
||||||
|
--color-yellow: #d99a3a;
|
||||||
|
--color-olive: #a3a34a;
|
||||||
|
--color-green: #3bb97a;
|
||||||
|
--color-teal: #3aa6a6;
|
||||||
|
--color-blue: #5685e9;
|
||||||
|
--color-violet: #8579e0;
|
||||||
|
--color-purple: #a06ce0;
|
||||||
|
--color-pink: #e06ca6;
|
||||||
|
--color-brown: #a3866b;
|
||||||
|
--color-grey: #8b95a1;
|
||||||
|
--color-gold: #d99a3a;
|
||||||
|
--color-white: #ffffff;
|
||||||
|
--color-black: #0d1117;
|
||||||
|
--color-pure-black: #000000;
|
||||||
|
|
||||||
|
/* light variants */
|
||||||
|
--color-red-light: #e88585;
|
||||||
|
--color-green-light: #5fd28a;
|
||||||
|
--color-blue-light: #7ba2f0;
|
||||||
|
--color-grey-light: #aab2bd;
|
||||||
|
|
||||||
|
/* dark-1 variants */
|
||||||
|
--color-red-dark-1: #c95252;
|
||||||
|
--color-green-dark-1: #2fa069;
|
||||||
|
--color-blue-dark-1: #4f82ec;
|
||||||
|
|
||||||
|
/* Status text / background / border */
|
||||||
|
--color-success-text: #5fd28a;
|
||||||
|
--color-success-bg: #13301f;
|
||||||
|
--color-success-border: #1f4d33;
|
||||||
|
--color-error-text: #f08a8a;
|
||||||
|
--color-error-bg: #341819;
|
||||||
|
--color-error-bg-active:#4a2222;
|
||||||
|
--color-error-bg-hover: #3d1e1f;
|
||||||
|
--color-error-border: #5a2a2a;
|
||||||
|
--color-warning-text: #e7b35a;
|
||||||
|
--color-warning-bg: #33270f;
|
||||||
|
--color-warning-border: #574017;
|
||||||
|
--color-info-text: #88abf2;
|
||||||
|
--color-info-bg: #16233f;
|
||||||
|
--color-info-border: #21386a;
|
||||||
|
--color-danger-bg: #2a1718;
|
||||||
|
|
||||||
|
/* Diff */
|
||||||
|
--color-diff-removed-word-bg: #5a2a2a;
|
||||||
|
--color-diff-added-word-bg: #1f4d33;
|
||||||
|
--color-diff-removed-row-bg: #2a1718;
|
||||||
|
--color-diff-added-row-bg: #122a1d;
|
||||||
|
--color-diff-removed-row-border: #3d2122;
|
||||||
|
--color-diff-added-row-border: #1b3a28;
|
||||||
|
--color-diff-moved-row-bg: #33270f;
|
||||||
|
--color-diff-moved-row-border:#574017;
|
||||||
|
--color-diff-inactive: #1c232c;
|
||||||
|
|
||||||
|
/* Labels / reactions / tooltip / nav */
|
||||||
|
--color-label-text: #c2cad3;
|
||||||
|
--color-label-bg: #2d3641;
|
||||||
|
--color-label-hover-bg: #3d4856;
|
||||||
|
--color-label-active-bg: #4a5666;
|
||||||
|
--color-reaction-bg: #1c232c;
|
||||||
|
--color-reaction-hover-bg: rgba(86, 133, 233, 0.22);
|
||||||
|
--color-reaction-active-bg: rgba(86, 133, 233, 0.32);
|
||||||
|
--color-tooltip-text: #14181e;
|
||||||
|
--color-tooltip-bg: #eef1f5;
|
||||||
|
--color-button: #3d4856;
|
||||||
|
--color-expand-button: #2d3641;
|
||||||
|
|
||||||
|
/* badges */
|
||||||
|
--color-red-badge: #e06464;
|
||||||
|
--color-red-badge-bg: rgba(224, 100, 100, 0.16);
|
||||||
|
--color-red-badge-bg-hover: rgba(224, 100, 100, 0.30);
|
||||||
|
--color-green-badge: #3bb97a;
|
||||||
|
--color-green-badge-bg: rgba(59, 185, 122, 0.16);
|
||||||
|
--color-green-badge-bg-hover: rgba(59, 185, 122, 0.30);
|
||||||
|
--color-yellow-badge: #d99a3a;
|
||||||
|
--color-yellow-badge-bg: rgba(217, 154, 58, 0.16);
|
||||||
|
--color-yellow-badge-bg-hover: rgba(217, 154, 58, 0.30);
|
||||||
|
--color-orange-badge: #d99a3a;
|
||||||
|
--color-orange-badge-bg: rgba(217, 154, 58, 0.16);
|
||||||
|
--color-orange-badge-bg-hover: rgba(217, 154, 58, 0.30);
|
||||||
|
|
||||||
|
/* status indicators */
|
||||||
|
--color-indicator-offline: #5f6975;
|
||||||
|
--color-indicator-idle: #d99a3a;
|
||||||
|
--color-indicator-active: #3bb97a;
|
||||||
|
|
||||||
|
/* checkerboard (image diff) */
|
||||||
|
--checkerboard-color-1: #161b22;
|
||||||
|
--checkerboard-color-2: #0f141a;
|
||||||
|
|
||||||
|
/* project board */
|
||||||
|
--color-project-board-bg: #0d1117;
|
||||||
|
|
||||||
|
/* console */
|
||||||
|
--color-console-fg: #e6e9ef;
|
||||||
|
--color-console-fg-subtle: #8b95a1;
|
||||||
|
--color-console-bg: #0b0f14;
|
||||||
|
--color-console-border: #1c232c;
|
||||||
|
--color-console-hover-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
--color-console-active-bg: rgba(255, 255, 255, 0.10);
|
||||||
|
--color-console-menu-bg: #161b22;
|
||||||
|
--color-console-menu-border:#2d3641;
|
||||||
|
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
STRUCTURAL OVERRIDES — identical identity layer to the light theme.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ── Type ───────────────────────────────────────────────────────────────── */
|
||||||
|
body,
|
||||||
|
input, button, select, textarea, optgroup,
|
||||||
|
.ui, .ui.menu, .ui.header, .ui.form, .ui.dropdown,
|
||||||
|
.ui.dropdown .menu > .item, .ui.input > input,
|
||||||
|
.markup, h1, h2, h3, h4, h5, h6,
|
||||||
|
.repo-header, .repository .header-wrapper, .commit-summary, .file-info {
|
||||||
|
font-family: var(--fonts-proportional) !important;
|
||||||
|
}
|
||||||
|
code, pre, tt, kbd, samp, .mono,
|
||||||
|
.ui.input.mono, .commit-id, .sha, .ui.label.commit-id,
|
||||||
|
.code-view, .lines-code, .lines-num, .CodeMirror,
|
||||||
|
.markup code, .markup pre, .markup tt {
|
||||||
|
font-family: var(--fonts-monospace) !important;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Primary / positive buttons ─────────────────────────────────────────── */
|
||||||
|
.ui.primary.button,
|
||||||
|
.ui.primary.buttons .button,
|
||||||
|
.ui.positive.button,
|
||||||
|
.ui.positive.buttons .button {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.ui.primary.button:hover,
|
||||||
|
.ui.primary.buttons .button:hover,
|
||||||
|
.ui.positive.button:hover,
|
||||||
|
.ui.positive.buttons .button:hover {
|
||||||
|
background: var(--color-primary-hover) !important;
|
||||||
|
border-color: var(--color-primary-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Default / secondary buttons ────────────────────────────────────────── */
|
||||||
|
.ui.button {
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.ui.basic.button,
|
||||||
|
.ui.basic.buttons .button {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Repo tabs — accent active label + accent underline ─────────────────────
|
||||||
|
The repo tab bar is Forgejo's `.ui.secondary.pointing.menu`; the active item's
|
||||||
|
colour is set by the full active/hover/focus/dropdown selector group, so we
|
||||||
|
match it verbatim to actually win the cascade. */
|
||||||
|
.ui.secondary.pointing.menu .active.item,
|
||||||
|
.ui.secondary.pointing.menu .active.item:hover,
|
||||||
|
.ui.secondary.pointing.menu .active.item:focus,
|
||||||
|
.ui.secondary.pointing.menu .dropdown.item:hover,
|
||||||
|
.ui.secondary.pointing.menu .dropdown.item:focus,
|
||||||
|
.ui.tabular.menu .active.item {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ui.secondary.pointing.menu .item:hover {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
/* Tab count pills (12 · 3 · 4) render as plain `.ui.label` inside each item →
|
||||||
|
accent-tinted instead of flat grey. */
|
||||||
|
.ui.secondary.pointing.menu .item .ui.label,
|
||||||
|
.ui.tabular.menu .item .ui.label {
|
||||||
|
background: var(--color-primary-alpha-10) !important;
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
border: 1px solid var(--color-primary-alpha-20) !important;
|
||||||
|
}
|
||||||
|
.ui.secondary.pointing.menu .active.item .ui.label,
|
||||||
|
.ui.tabular.menu .active.item .ui.label {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Repo header title — owner / name carry the accent (branded chrome) ── */
|
||||||
|
.repo-header .flex-text-block a,
|
||||||
|
.repo-header .repo-title a,
|
||||||
|
.repository .header-wrapper .repo-title a {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
}
|
||||||
|
.repo-header .flex-text-block a:hover { text-decoration: underline; }
|
||||||
|
/* Watch / Star / Fork stay button-coloured — never accent text */
|
||||||
|
.repo-header .repo-buttons a,
|
||||||
|
.repo-header .repo-buttons .ui.button {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Repo topics → real accent pills (clearly blue, never grey) ─────────────
|
||||||
|
Forgejo's topic markup has shifted across versions (#repo-topics > a,
|
||||||
|
a.repo-topic, a.topic.label …) — match them all so the chips are always
|
||||||
|
branded, with a visible blue fill + ring instead of the pale wash. */
|
||||||
|
#repo-topics a,
|
||||||
|
#repo-topics .ui.label,
|
||||||
|
a.repo-topic,
|
||||||
|
a.repo-topic.ui.label,
|
||||||
|
.repository-topics a.ui.label,
|
||||||
|
.topic.ui.label,
|
||||||
|
a.topic.label {
|
||||||
|
background: var(--color-primary-alpha-10) !important;
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
border: 1px solid var(--color-primary-alpha-30) !important;
|
||||||
|
border-radius: var(--border-radius-full) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
#repo-topics a:hover,
|
||||||
|
a.repo-topic:hover,
|
||||||
|
.topic.ui.label:hover {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Extra interactive coverage — the bits that quietly stay grey ──────────
|
||||||
|
Dropdowns, pagination, form focus, toggles and progress bars across the rest
|
||||||
|
of the app, pulled onto the accent so the brand reads everywhere, not just on
|
||||||
|
the repo home. Trim any rule you don't want. */
|
||||||
|
|
||||||
|
/* Dropdown / select menus — selected + active item in accent */
|
||||||
|
.ui.dropdown .menu > .item.selected,
|
||||||
|
.ui.dropdown .menu > .item.active,
|
||||||
|
.ui.selection.dropdown .menu > .item.active.selected {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
background: var(--color-primary-alpha-10) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination — current page in solid accent */
|
||||||
|
.ui.pagination.menu .active.item {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form fields — accent focus ring instead of the default grey/teal */
|
||||||
|
.ui.input input:focus,
|
||||||
|
.ui.form input:focus,
|
||||||
|
.ui.form textarea:focus,
|
||||||
|
.ui.selection.active.dropdown,
|
||||||
|
.ui.selection.dropdown:focus {
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary-alpha-20) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes, radios, toggles — checked state in accent */
|
||||||
|
.ui.checkbox input:checked ~ .box::before,
|
||||||
|
.ui.checkbox input:checked ~ label::before,
|
||||||
|
.ui.radio.checkbox input:checked ~ .box::before,
|
||||||
|
.ui.radio.checkbox input:checked ~ label::before,
|
||||||
|
.ui.toggle.checkbox input:checked ~ .box::before,
|
||||||
|
.ui.toggle.checkbox input:checked ~ label::before {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bars (upload, migration, theme-driven stats) */
|
||||||
|
.ui.progress .bar {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards / segments ───────────────────────────────────────────────────── */
|
||||||
|
.ui.segment,
|
||||||
|
.ui.segments,
|
||||||
|
.ui.attached.segment,
|
||||||
|
.repository-summary,
|
||||||
|
.repo-description-box,
|
||||||
|
.ui.card,
|
||||||
|
.ui.cards > .card {
|
||||||
|
box-shadow: 0 1px 0 var(--color-shadow);
|
||||||
|
}
|
||||||
|
.ui.segments,
|
||||||
|
.ui.card,
|
||||||
|
.ui.cards > .card {
|
||||||
|
border-radius: var(--border-radius-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Links ──────────────────────────────────────────────────────────────── */
|
||||||
|
a { text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Octicons ───────────────────────────────────────────────────────────── */
|
||||||
|
.svg, .gt-octicon, .octicon { fill: currentColor; }
|
||||||
472
dev/theme/theme-kbenestad-light.css
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
/* ============================================================================
|
||||||
|
theme-kbenestad-light.css
|
||||||
|
kBenestad — Forgejo theme (LIGHT)
|
||||||
|
Nordic minimal: cool paper ground, near-black slate ink, one calm blue.
|
||||||
|
|
||||||
|
Install: drop this file in custom/public/assets/css/
|
||||||
|
then in app.ini [ui]
|
||||||
|
THEMES = forgejo-auto,forgejo-light,forgejo-dark,kbenestad-light,kbenestad-dark
|
||||||
|
DEFAULT_THEME = kbenestad-light
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Strategy: a COMPLETE, self-contained variable set (so the theme renders
|
||||||
|
correctly even when Forgejo's shipped base theme isn't present) followed by
|
||||||
|
the structural overrides that give kBenestad its identity — Schibsted Grotesk
|
||||||
|
type, accent-soft topic pills, flat buttons, hairline cards.
|
||||||
|
|
||||||
|
The forgejo-light import below is a harmless safety net: if the shipped file
|
||||||
|
exists it fills any future upstream variables; if it's missing it's ignored.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
@import "./theme-forgejo-light.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--is-dark-theme: false;
|
||||||
|
|
||||||
|
/* ── Type ──────────────────────────────────────────────────────────────────
|
||||||
|
Forgejo resolves body type as var(--fonts-override, var(--fonts-proportional)),
|
||||||
|
"Noto Sans", … — so these vars ARE the hook. Marked !important to win even
|
||||||
|
if the shipped base theme sets them !important first (per Forgejo's docs). */
|
||||||
|
--fonts-proportional: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
|
||||||
|
--fonts-regular: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif !important;
|
||||||
|
--fonts-monospace: "JetBrains Mono", ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace !important;
|
||||||
|
|
||||||
|
/* ── Corners (kBenestad: 6px workhorse, 8px cards, small + deliberate) ──── */
|
||||||
|
--border-radius: 6px;
|
||||||
|
--border-radius-medium: 8px;
|
||||||
|
--border-radius-full: 99999px;
|
||||||
|
|
||||||
|
/* ── Accent — Nordic blue #2f6fed and its ramp ──────────────────────────── */
|
||||||
|
--color-primary: #2f6fed;
|
||||||
|
--color-primary-contrast: #ffffff;
|
||||||
|
--color-primary-dark-1: #1f57cf;
|
||||||
|
--color-primary-dark-2: #1b46a3;
|
||||||
|
--color-primary-dark-3: #163b88;
|
||||||
|
--color-primary-dark-4: #142e63;
|
||||||
|
--color-primary-dark-5: #102450;
|
||||||
|
--color-primary-dark-6: #0c1c3e;
|
||||||
|
--color-primary-dark-7: #08142c;
|
||||||
|
--color-primary-light-1: #5685e9;
|
||||||
|
--color-primary-light-2: #88abf2;
|
||||||
|
--color-primary-light-3: #b7cdf8;
|
||||||
|
--color-primary-light-4: #d9e4fc;
|
||||||
|
--color-primary-light-5: #eef3fe;
|
||||||
|
--color-primary-light-6: #f5f8ff;
|
||||||
|
--color-primary-light-7: #fafcff;
|
||||||
|
--color-primary-alpha-10: rgba(47, 111, 237, 0.10);
|
||||||
|
--color-primary-alpha-20: rgba(47, 111, 237, 0.20);
|
||||||
|
--color-primary-alpha-30: rgba(47, 111, 237, 0.30);
|
||||||
|
--color-primary-alpha-40: rgba(47, 111, 237, 0.40);
|
||||||
|
--color-primary-alpha-50: rgba(47, 111, 237, 0.50);
|
||||||
|
--color-primary-alpha-60: rgba(47, 111, 237, 0.60);
|
||||||
|
--color-primary-alpha-70: rgba(47, 111, 237, 0.70);
|
||||||
|
--color-primary-alpha-80: rgba(47, 111, 237, 0.80);
|
||||||
|
--color-primary-alpha-90: rgba(47, 111, 237, 0.90);
|
||||||
|
--color-primary-hover: #1f57cf;
|
||||||
|
--color-primary-active: #1b46a3;
|
||||||
|
|
||||||
|
/* ── Secondary (neutral fills, borders, secondary buttons) ──────────────── */
|
||||||
|
--color-secondary: #e7eaef;
|
||||||
|
--color-secondary-dark-1: #d8dde4;
|
||||||
|
--color-secondary-dark-2: #c7cdd6;
|
||||||
|
--color-secondary-dark-3: #aab2bd;
|
||||||
|
--color-secondary-dark-4: #97a0ac;
|
||||||
|
--color-secondary-dark-5: #7b8694;
|
||||||
|
--color-secondary-dark-6: #6b7785;
|
||||||
|
--color-secondary-dark-7: #56606d;
|
||||||
|
--color-secondary-dark-8: #3a434f;
|
||||||
|
--color-secondary-dark-9: #2c343d;
|
||||||
|
--color-secondary-dark-10: #232a33;
|
||||||
|
--color-secondary-dark-11: #1b212a;
|
||||||
|
--color-secondary-dark-12: #14181e;
|
||||||
|
--color-secondary-dark-13: #0f141a;
|
||||||
|
--color-secondary-light-1: #eef0f4;
|
||||||
|
--color-secondary-light-2: #f1f3f6;
|
||||||
|
--color-secondary-light-3: #f5f6f9;
|
||||||
|
--color-secondary-light-4: #f8f9fb;
|
||||||
|
--color-secondary-alpha-10: rgba(123, 134, 148, 0.10);
|
||||||
|
--color-secondary-alpha-20: rgba(123, 134, 148, 0.20);
|
||||||
|
--color-secondary-alpha-30: rgba(123, 134, 148, 0.30);
|
||||||
|
--color-secondary-alpha-40: rgba(123, 134, 148, 0.40);
|
||||||
|
--color-secondary-alpha-50: rgba(123, 134, 148, 0.50);
|
||||||
|
--color-secondary-alpha-60: rgba(123, 134, 148, 0.60);
|
||||||
|
--color-secondary-alpha-70: rgba(123, 134, 148, 0.70);
|
||||||
|
--color-secondary-alpha-80: rgba(123, 134, 148, 0.80);
|
||||||
|
--color-secondary-alpha-90: rgba(123, 134, 148, 0.90);
|
||||||
|
--color-secondary-button: #d8dde4;
|
||||||
|
--color-secondary-hover: #f1f3f6;
|
||||||
|
--color-secondary-active: #e7eaef;
|
||||||
|
|
||||||
|
/* ── Text / ink ─────────────────────────────────────────────────────────── */
|
||||||
|
--color-text-dark: #0d1117;
|
||||||
|
--color-text: #14181e;
|
||||||
|
--color-text-light: #3a434f;
|
||||||
|
--color-text-light-1: #56606d;
|
||||||
|
--color-text-light-2: #7b8694;
|
||||||
|
--color-text-light-3: #aab2bd;
|
||||||
|
--color-placeholder-text: #aab2bd;
|
||||||
|
--color-text-focus: #ffffff;
|
||||||
|
|
||||||
|
/* ── Surfaces ──────────────────────────────────────────────────────────── */
|
||||||
|
--color-body: #f8f9fb; /* page ground (cool paper) */
|
||||||
|
--color-box-body: #ffffff; /* cards, file list, panels */
|
||||||
|
--color-box-body-highlight: #f5f8ff;
|
||||||
|
--color-box-header: #f8f9fb; /* segment / table headers */
|
||||||
|
--color-nav-bg: #ffffff; /* top navbar */
|
||||||
|
--color-nav-hover-bg: #f1f3f6;
|
||||||
|
--color-secondary-nav-bg: #f8f9fb;
|
||||||
|
--color-footer: #ffffff;
|
||||||
|
--color-light: rgba(123, 134, 148, 0.04);
|
||||||
|
--color-light-mimic-enabled: rgba(0, 0, 0, calc(6 / 255));
|
||||||
|
--color-light-border: #e7eaef;
|
||||||
|
--color-hover: #f1f3f6; /* row / item hover */
|
||||||
|
--color-active: #e7eaef; /* active row */
|
||||||
|
--color-menu: #ffffff;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--fancy-card-bg: #ffffff;
|
||||||
|
--fancy-card-border: #e7eaef;
|
||||||
|
--color-markup-tab-default: var(--color-box-header);
|
||||||
|
--color-markup-tab-active: var(--color-box-body);
|
||||||
|
--color-header-wrapper: #ffffff;
|
||||||
|
--color-header-wrapper-transparent: rgba(255, 255, 255, 0);
|
||||||
|
|
||||||
|
/* ── Inputs ────────────────────────────────────────────────────────────── */
|
||||||
|
--color-input-text: #14181e;
|
||||||
|
--color-input-background: #ffffff;
|
||||||
|
--color-input-toggle-background: #ffffff;
|
||||||
|
--color-input-border: #d8dde4;
|
||||||
|
--color-input-border-hover: #aab2bd;
|
||||||
|
|
||||||
|
/* ── Borders / dividers ────────────────────────────────────────────────── */
|
||||||
|
--color-border: #e7eaef;
|
||||||
|
--color-secondary-bg: #f1f3f6;
|
||||||
|
|
||||||
|
/* ── Code / markup ─────────────────────────────────────────────────────── */
|
||||||
|
--color-markup-code-block: #f1f3f6;
|
||||||
|
--color-markup-code-inline: #eef0f4;
|
||||||
|
--color-markup-table-row: rgba(0, 0, 0, 0.024);
|
||||||
|
--color-code-bg: #ffffff;
|
||||||
|
--color-code-sidebar-bg: #f8f9fb;
|
||||||
|
|
||||||
|
/* ── Shadows (soft, cool, low opacity) ─────────────────────────────────── */
|
||||||
|
--color-shadow: rgba(20, 24, 30, 0.06);
|
||||||
|
--color-secondary-shadow: rgba(20, 24, 30, 0.04);
|
||||||
|
|
||||||
|
/* ── Accent line / selection / timeline / caret ────────────────────────── */
|
||||||
|
--color-accent: #2f6fed;
|
||||||
|
--color-small-accent: #eef3fe;
|
||||||
|
--color-active-line: #eef3fe;
|
||||||
|
--color-editor-line-highlight: #f5f8ff;
|
||||||
|
--color-timeline: #e7eaef;
|
||||||
|
--color-caret: #14181e;
|
||||||
|
--color-highlight-fg: #1f57cf;
|
||||||
|
--color-highlight-bg: rgba(47, 111, 237, 0.10);
|
||||||
|
--color-selection-bg: #d9e4fc;
|
||||||
|
--color-selection-fg: #14181e;
|
||||||
|
--color-overlay-backdrop: rgba(20, 24, 30, 0.32);
|
||||||
|
|
||||||
|
/* ── Semantic hues (muted, never neon) ─────────────────────────────────── */
|
||||||
|
--color-red: #d64545;
|
||||||
|
--color-orange: #c9851f;
|
||||||
|
--color-yellow: #c9851f;
|
||||||
|
--color-olive: #8a8a2f;
|
||||||
|
--color-green: #1f9d5f;
|
||||||
|
--color-teal: #1f8a8a;
|
||||||
|
--color-blue: #2f6fed;
|
||||||
|
--color-violet: #6b5fd2;
|
||||||
|
--color-purple: #8a4fd2;
|
||||||
|
--color-pink: #cf4f8a;
|
||||||
|
--color-brown: #8a6b4f;
|
||||||
|
--color-grey: #7b8694;
|
||||||
|
--color-gold: #c9851f;
|
||||||
|
--color-white: #ffffff;
|
||||||
|
--color-black: #14181e;
|
||||||
|
--color-pure-black: #000000;
|
||||||
|
|
||||||
|
/* light variants */
|
||||||
|
--color-red-light: #e06464;
|
||||||
|
--color-green-light: #34b677;
|
||||||
|
--color-blue-light: #5685e9;
|
||||||
|
--color-grey-light: #aab2bd;
|
||||||
|
|
||||||
|
/* dark-1 variants */
|
||||||
|
--color-red-dark-1: #b8332f;
|
||||||
|
--color-green-dark-1: #178049;
|
||||||
|
--color-blue-dark-1: #1f57cf;
|
||||||
|
|
||||||
|
/* Status text / background / border */
|
||||||
|
--color-success-text: #178049;
|
||||||
|
--color-success-bg: #d7f0e1;
|
||||||
|
--color-success-border: #aee0c4;
|
||||||
|
--color-error-text: #b8332f;
|
||||||
|
--color-error-bg: #fadcdc;
|
||||||
|
--color-error-bg-active:#f3bcbc;
|
||||||
|
--color-error-bg-hover: #fbe6e6;
|
||||||
|
--color-error-border: #f3bcbc;
|
||||||
|
--color-warning-text: #a86c14;
|
||||||
|
--color-warning-bg: #fbeacb;
|
||||||
|
--color-warning-border: #f3d79a;
|
||||||
|
--color-info-text: #1b46a3;
|
||||||
|
--color-info-bg: #eef3fe;
|
||||||
|
--color-info-border: #b7cdf8;
|
||||||
|
--color-danger-bg: #fbeeee;
|
||||||
|
|
||||||
|
/* Diff */
|
||||||
|
--color-diff-removed-word-bg: #f3bcbc;
|
||||||
|
--color-diff-added-word-bg: #aee0c4;
|
||||||
|
--color-diff-removed-row-bg: #fbeeee;
|
||||||
|
--color-diff-added-row-bg: #e9f7ef;
|
||||||
|
--color-diff-removed-row-border: #f3d2d2;
|
||||||
|
--color-diff-added-row-border: #cfeadd;
|
||||||
|
--color-diff-moved-row-bg: #fbeacb;
|
||||||
|
--color-diff-moved-row-border:#f3d79a;
|
||||||
|
--color-diff-inactive: #f1f3f6;
|
||||||
|
|
||||||
|
/* Labels / reactions / tooltip / nav */
|
||||||
|
--color-label-text: #3a434f;
|
||||||
|
--color-label-bg: #e7eaef;
|
||||||
|
--color-label-hover-bg: #d8dde4;
|
||||||
|
--color-label-active-bg: #c7cdd6;
|
||||||
|
--color-reaction-bg: #f1f3f6;
|
||||||
|
--color-reaction-hover-bg: rgba(47, 111, 237, 0.20);
|
||||||
|
--color-reaction-active-bg: rgba(47, 111, 237, 0.30);
|
||||||
|
--color-tooltip-text: #ffffff;
|
||||||
|
--color-tooltip-bg: #232a33;
|
||||||
|
--color-button: #d8dde4;
|
||||||
|
--color-expand-button: #e7eaef;
|
||||||
|
|
||||||
|
/* badges */
|
||||||
|
--color-red-badge: #d64545;
|
||||||
|
--color-red-badge-bg: rgba(214, 69, 69, 0.13);
|
||||||
|
--color-red-badge-bg-hover: rgba(214, 69, 69, 0.27);
|
||||||
|
--color-green-badge: #1f9d5f;
|
||||||
|
--color-green-badge-bg: rgba(31, 157, 95, 0.13);
|
||||||
|
--color-green-badge-bg-hover: rgba(31, 157, 95, 0.27);
|
||||||
|
--color-yellow-badge: #c9851f;
|
||||||
|
--color-yellow-badge-bg: rgba(201, 133, 31, 0.13);
|
||||||
|
--color-yellow-badge-bg-hover: rgba(201, 133, 31, 0.27);
|
||||||
|
--color-orange-badge: #c9851f;
|
||||||
|
--color-orange-badge-bg: rgba(201, 133, 31, 0.13);
|
||||||
|
--color-orange-badge-bg-hover: rgba(201, 133, 31, 0.27);
|
||||||
|
|
||||||
|
/* status indicators */
|
||||||
|
--color-indicator-offline: #aab2bd;
|
||||||
|
--color-indicator-idle: #c9851f;
|
||||||
|
--color-indicator-active: #1f9d5f;
|
||||||
|
|
||||||
|
/* checkerboard (image diff) */
|
||||||
|
--checkerboard-color-1: #ffffff;
|
||||||
|
--checkerboard-color-2: #f1f3f6;
|
||||||
|
|
||||||
|
/* project board */
|
||||||
|
--color-project-board-bg: #f8f9fb;
|
||||||
|
|
||||||
|
/* console — calm dark surface */
|
||||||
|
--color-console-fg: #e6e9ef;
|
||||||
|
--color-console-fg-subtle: #8b95a1;
|
||||||
|
--color-console-bg: #161b22;
|
||||||
|
--color-console-border: #232a33;
|
||||||
|
--color-console-hover-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
--color-console-active-bg: rgba(255, 255, 255, 0.10);
|
||||||
|
--color-console-menu-bg: #1c232c;
|
||||||
|
--color-console-menu-border:#2d3641;
|
||||||
|
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
STRUCTURAL OVERRIDES — kBenestad identity, mirroring the design mockup.
|
||||||
|
Scoped, conservative, modeled on real Forgejo / Semantic-UI selectors.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ── Type: enforce the kBenestad faces everywhere ───────────────────────────
|
||||||
|
Setting the variable alone isn't enough when a privacy blocker or CSP stops
|
||||||
|
the web font from loading on the var fallback. We force the family directly,
|
||||||
|
so the moment the font is available (web or self-hosted) it renders. */
|
||||||
|
body,
|
||||||
|
input, button, select, textarea, optgroup,
|
||||||
|
.ui, .ui.menu, .ui.header, .ui.form, .ui.dropdown,
|
||||||
|
.ui.dropdown .menu > .item, .ui.input > input,
|
||||||
|
.markup, h1, h2, h3, h4, h5, h6,
|
||||||
|
.repo-header, .repository .header-wrapper, .commit-summary, .file-info {
|
||||||
|
font-family: var(--fonts-proportional) !important;
|
||||||
|
}
|
||||||
|
code, pre, tt, kbd, samp, .mono,
|
||||||
|
.ui.input.mono, .commit-id, .sha, .ui.label.commit-id,
|
||||||
|
.code-view, .lines-code, .lines-num, .CodeMirror,
|
||||||
|
.markup code, .markup pre, .markup tt {
|
||||||
|
font-family: var(--fonts-monospace) !important;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Primary / positive buttons — flat accent, no gloss ─────────────────── */
|
||||||
|
.ui.primary.button,
|
||||||
|
.ui.primary.buttons .button,
|
||||||
|
.ui.positive.button,
|
||||||
|
.ui.positive.buttons .button {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.ui.primary.button:hover,
|
||||||
|
.ui.primary.buttons .button:hover,
|
||||||
|
.ui.positive.button:hover,
|
||||||
|
.ui.positive.buttons .button:hover {
|
||||||
|
background: var(--color-primary-hover) !important;
|
||||||
|
border-color: var(--color-primary-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Default / secondary buttons — calm, hairline, no shadow ────────────── */
|
||||||
|
.ui.button {
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.ui.basic.button,
|
||||||
|
.ui.basic.buttons .button {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Repo tabs — accent active label + accent underline ─────────────────────
|
||||||
|
The repo tab bar is Forgejo's `.ui.secondary.pointing.menu`; the active item's
|
||||||
|
colour is set by the full active/hover/focus/dropdown selector group, so we
|
||||||
|
match it verbatim to actually win the cascade. */
|
||||||
|
.ui.secondary.pointing.menu .active.item,
|
||||||
|
.ui.secondary.pointing.menu .active.item:hover,
|
||||||
|
.ui.secondary.pointing.menu .active.item:focus,
|
||||||
|
.ui.secondary.pointing.menu .dropdown.item:hover,
|
||||||
|
.ui.secondary.pointing.menu .dropdown.item:focus,
|
||||||
|
.ui.tabular.menu .active.item {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ui.secondary.pointing.menu .item:hover {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
/* Tab count pills (12 · 3 · 4) render as plain `.ui.label` inside each item →
|
||||||
|
accent-tinted instead of flat grey. */
|
||||||
|
.ui.secondary.pointing.menu .item .ui.label,
|
||||||
|
.ui.tabular.menu .item .ui.label {
|
||||||
|
background: var(--color-primary-alpha-10) !important;
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
border: 1px solid var(--color-primary-alpha-20) !important;
|
||||||
|
}
|
||||||
|
.ui.secondary.pointing.menu .active.item .ui.label,
|
||||||
|
.ui.tabular.menu .active.item .ui.label {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Repo header title — owner / name carry the accent (branded chrome) ── */
|
||||||
|
.repo-header .flex-text-block a,
|
||||||
|
.repo-header .repo-title a,
|
||||||
|
.repository .header-wrapper .repo-title a {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
}
|
||||||
|
.repo-header .flex-text-block a:hover { text-decoration: underline; }
|
||||||
|
/* Watch / Star / Fork stay button-coloured — never accent text */
|
||||||
|
.repo-header .repo-buttons a,
|
||||||
|
.repo-header .repo-buttons .ui.button {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Repo topics → real accent pills (clearly blue, never grey) ─────────────
|
||||||
|
Forgejo's topic markup has shifted across versions (#repo-topics > a,
|
||||||
|
a.repo-topic, a.topic.label …) — match them all so the chips are always
|
||||||
|
branded, with a visible blue fill + ring instead of the pale wash. */
|
||||||
|
#repo-topics a,
|
||||||
|
#repo-topics .ui.label,
|
||||||
|
a.repo-topic,
|
||||||
|
a.repo-topic.ui.label,
|
||||||
|
.repository-topics a.ui.label,
|
||||||
|
.topic.ui.label,
|
||||||
|
a.topic.label {
|
||||||
|
background: var(--color-primary-alpha-10) !important;
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
border: 1px solid var(--color-primary-alpha-30) !important;
|
||||||
|
border-radius: var(--border-radius-full) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
#repo-topics a:hover,
|
||||||
|
a.repo-topic:hover,
|
||||||
|
.topic.ui.label:hover {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Extra interactive coverage — the bits that quietly stay grey ──────────
|
||||||
|
Dropdowns, pagination, form focus, toggles and progress bars across the rest
|
||||||
|
of the app, pulled onto the accent so the brand reads everywhere, not just on
|
||||||
|
the repo home. Trim any rule you don't want. */
|
||||||
|
|
||||||
|
/* Dropdown / select menus — selected + active item in accent */
|
||||||
|
.ui.dropdown .menu > .item.selected,
|
||||||
|
.ui.dropdown .menu > .item.active,
|
||||||
|
.ui.selection.dropdown .menu > .item.active.selected {
|
||||||
|
color: var(--color-accent) !important;
|
||||||
|
background: var(--color-primary-alpha-10) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination — current page in solid accent */
|
||||||
|
.ui.pagination.menu .active.item {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form fields — accent focus ring instead of the default grey/teal */
|
||||||
|
.ui.input input:focus,
|
||||||
|
.ui.form input:focus,
|
||||||
|
.ui.form textarea:focus,
|
||||||
|
.ui.selection.active.dropdown,
|
||||||
|
.ui.selection.dropdown:focus {
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary-alpha-20) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes, radios, toggles — checked state in accent */
|
||||||
|
.ui.checkbox input:checked ~ .box::before,
|
||||||
|
.ui.checkbox input:checked ~ label::before,
|
||||||
|
.ui.radio.checkbox input:checked ~ .box::before,
|
||||||
|
.ui.radio.checkbox input:checked ~ label::before,
|
||||||
|
.ui.toggle.checkbox input:checked ~ .box::before,
|
||||||
|
.ui.toggle.checkbox input:checked ~ label::before {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bars (upload, migration, theme-driven stats) */
|
||||||
|
.ui.progress .bar {
|
||||||
|
background: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards / segments — 8px corners, hairline border, faint cool shadow ─── */
|
||||||
|
.ui.segment,
|
||||||
|
.ui.segments,
|
||||||
|
.ui.attached.segment,
|
||||||
|
.repository-summary,
|
||||||
|
.repo-description-box,
|
||||||
|
.ui.card,
|
||||||
|
.ui.cards > .card {
|
||||||
|
box-shadow: 0 1px 0 var(--color-shadow);
|
||||||
|
}
|
||||||
|
.ui.segments,
|
||||||
|
.ui.card,
|
||||||
|
.ui.cards > .card {
|
||||||
|
border-radius: var(--border-radius-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Links — accent, underline only on hover (matches mockup chrome) ────── */
|
||||||
|
a { text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Octicons stay legible on light chrome ──────────────────────────────── */
|
||||||
|
.svg, .gt-octicon, .octicon { fill: currentColor; }
|
||||||
133
dev/ui_kits/gitxt/index.html
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<!-- @dsCard group="gitxt" viewport="900x620" name="gitxt teletext" subtitle="Teletext-for-git — type a page number or use the FASTEXT bar" -->
|
||||||
|
<!-- @startingPoint section="gitxt" subtitle="Teletext terminal with 3-digit page navigation" viewport="900x620" -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>gitxt</title>
|
||||||
|
<link rel="stylesheet" href="../../styles.css">
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body { background: #07090c; display: grid; place-items: center; padding: 28px; }
|
||||||
|
|
||||||
|
.tx-stage { width: min(720px, 100%); }
|
||||||
|
.tx-screen {
|
||||||
|
background: #000; border-radius: 10px; padding: 26px 30px 22px;
|
||||||
|
font-family: var(--font-mono); font-weight: 500;
|
||||||
|
box-shadow: 0 0 0 2px #1a1f26, 0 30px 80px rgba(0,0,0,.7);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
/* faint scanlines */
|
||||||
|
.tx-screen::after {
|
||||||
|
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(to bottom, rgba(255,255,255,.025) 0 1px, transparent 1px 3px);
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
.tx-statusbar {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 16px; color: #e8ecf0; letter-spacing: .5px; margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.tx-statusbar .pg { color: #3fe0e0; font-weight: 600; }
|
||||||
|
.tx-statusbar .svc { color: #f2d44a; }
|
||||||
|
.tx-statusbar .clk { color: #5fd28a; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.tx-body { min-height: 340px; }
|
||||||
|
.tx-row { font-size: 19px; line-height: 1.5; letter-spacing: .4px; color: #e8ecf0; white-space: pre-wrap; }
|
||||||
|
.tx-title { font-size: 40px; font-weight: 800; line-height: 1.05; letter-spacing: 1px; margin: 2px 0 8px; text-transform: lowercase; }
|
||||||
|
.tx-link { cursor: pointer; }
|
||||||
|
.tx-link:hover { background: rgba(255,255,255,.12); }
|
||||||
|
|
||||||
|
.tx-notfound { color: #ff6b6b; }
|
||||||
|
|
||||||
|
.tx-fastext { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 18px; }
|
||||||
|
.tx-fx { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 5px;
|
||||||
|
cursor: pointer; color: #000; font-size: 14px; }
|
||||||
|
.tx-fx b { font-size: 13px; font-weight: 700; }
|
||||||
|
.tx-fx span { font-size: 12px; opacity: .8; }
|
||||||
|
.tx-fx:hover { filter: brightness(1.12); }
|
||||||
|
.tx-fx--red { background: #ff6b6b; }
|
||||||
|
.tx-fx--green { background: #5fd28a; }
|
||||||
|
.tx-fx--yellow { background: #f2d44a; }
|
||||||
|
.tx-fx--cyan { background: #3fe0e0; }
|
||||||
|
|
||||||
|
.tx-hint { text-align: center; margin-top: 16px; font-family: var(--font-mono); font-size: 12px; color: #5a6470; letter-spacing: .5px; }
|
||||||
|
.tx-hint kbd { color: #aab2bd; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script type="text/babel" src="pages.jsx"></script>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { PAGES, useState, useEffect } = window;
|
||||||
|
const FX_COLORS = ['red', 'green', 'yellow', 'cyan'];
|
||||||
|
const FX_WORDS = { 100: 'index', 200: 'repos', 300: 'commits', 400: 'issues', 500: 'builds', 888: 'help', 210: 'kbpkg', 220: 'gitxt', 310: 'log' };
|
||||||
|
|
||||||
|
function Clock() {
|
||||||
|
const [t, setT] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
const fmt = () => {
|
||||||
|
const d = new Date();
|
||||||
|
const day = d.toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' });
|
||||||
|
const tm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
setT(day + ' ' + tm);
|
||||||
|
};
|
||||||
|
fmt(); const id = setInterval(fmt, 1000); return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
return <span className="clk">{t}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [page, setPage] = useState(100);
|
||||||
|
const [buf, setBuf] = useState('');
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
|
||||||
|
const go = (n) => { if (PAGES[n]) { setPage(n); setBuf(''); setNotFound(false); } else { setNotFound(true); setTimeout(() => setNotFound(false), 1200); } };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (/^[0-9]$/.test(e.key)) {
|
||||||
|
setBuf(prev => {
|
||||||
|
const next = (prev + e.key).slice(0, 3);
|
||||||
|
if (next.length === 3) { const n = parseInt(next, 10); setTimeout(() => go(n), 250); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else if (e.key === 'Backspace') { setBuf(prev => prev.slice(0, -1)); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pageDisp = buf ? ('P' + (buf + '___').slice(0, 3)) : ('P' + page);
|
||||||
|
const fast = (PAGES[page].fast) || [100, 200, 300, 100];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tx-stage">
|
||||||
|
<div className="tx-screen">
|
||||||
|
<div className="tx-statusbar">
|
||||||
|
<span className="pg">{pageDisp}</span>
|
||||||
|
<span className="svc">gitxt</span>
|
||||||
|
<Clock />
|
||||||
|
</div>
|
||||||
|
<div className="tx-body">
|
||||||
|
{notFound ? <div className="tx-row tx-notfound">page not found — try 100</div> : PAGES[page].render(go)}
|
||||||
|
</div>
|
||||||
|
<div className="tx-fastext">
|
||||||
|
{fast.map((n, i) => (
|
||||||
|
<div key={i} className={'tx-fx tx-fx--' + FX_COLORS[i]} onClick={() => go(n)}>
|
||||||
|
<b>{n}</b><span>{FX_WORDS[n] || 'page'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tx-hint">type a page number (e.g. <kbd>300</kbd>) or click a coloured button · <kbd>100</kbd> for index</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
128
dev/ui_kits/gitxt/pages.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// gitxt — teletext for git. Pages addressed by 3-digit numbers; navigate by
|
||||||
|
// typing digits or clicking the coloured FASTEXT bar. Authentic teletext look.
|
||||||
|
const { useState, useEffect, useRef } = React;
|
||||||
|
|
||||||
|
// colour helpers (teletext palette)
|
||||||
|
const C = ({ children }) => <span style={{ color: '#3fe0e0' }}>{children}</span>; // cyan
|
||||||
|
const Y = ({ children }) => <span style={{ color: '#f2d44a' }}>{children}</span>; // yellow
|
||||||
|
const G = ({ children }) => <span style={{ color: '#5fd28a' }}>{children}</span>; // green
|
||||||
|
const R = ({ children }) => <span style={{ color: '#ff6b6b' }}>{children}</span>; // red
|
||||||
|
const M = ({ children }) => <span style={{ color: '#e07ad0' }}>{children}</span>; // magenta
|
||||||
|
const W = ({ children }) => <span style={{ color: '#e8ecf0' }}>{children}</span>; // white
|
||||||
|
|
||||||
|
const Row = ({ children, center }) => (
|
||||||
|
<div className="tx-row" style={center ? { textAlign: 'center' } : null}>{children || '\u00A0'}</div>
|
||||||
|
);
|
||||||
|
// double-height title block
|
||||||
|
const Title = ({ color = '#3fe0e0', children }) => (
|
||||||
|
<div className="tx-title" style={{ color }}>{children}</div>
|
||||||
|
);
|
||||||
|
const Link = ({ n, go, children }) => (
|
||||||
|
<span className="tx-link" onClick={() => go(n)}><G>{n}</G> <W>{children}</W></span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PAGES = {
|
||||||
|
100: { fast: [200, 300, 400, 100], render: (go) => (<>
|
||||||
|
<Title>gitxt</Title>
|
||||||
|
<Row><W>teletext for git</W> · <C>kBenestad</C></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><Y>━━━━━━━━━━━━━━ index ━━━━━━━━━━━━━━</Y></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><Link n={200} go={go}>repositories</Link></Row>
|
||||||
|
<Row><Link n={300} go={go}>recent commits</Link></Row>
|
||||||
|
<Row><Link n={400} go={go}>open issues</Link></Row>
|
||||||
|
<Row><Link n={500} go={go}>build status</Link></Row>
|
||||||
|
<Row><Link n={888} go={go}>help & navigation</Link></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><W>type a page number, or use the</W></Row>
|
||||||
|
<Row><W>coloured buttons below.</W></Row>
|
||||||
|
</>) },
|
||||||
|
|
||||||
|
200: { fast: [210, 220, 300, 100], render: (go) => (<>
|
||||||
|
<Title color="#f2d44a">repositories</Title>
|
||||||
|
<Row><C>page 200</C> · <W>8 repos tracked</W></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><Link n={210} go={go}>kbpkg </Link> <G>v2.4.0</G></Row>
|
||||||
|
<Row><Link n={220} go={go}>gitxt </Link> <G>v0.3.0</G></Row>
|
||||||
|
<Row><Link n={230} go={go}>mdcms </Link> <Y>v0.6.1</Y></Row>
|
||||||
|
<Row><Link n={240} go={go}>capcms </Link> <Y>v0.2.0</Y></Row>
|
||||||
|
<Row><Link n={250} go={go}>invoice </Link> <G>v1.1.0</G></Row>
|
||||||
|
<Row><Link n={260} go={go}>timesheet </Link> <G>v1.0.0</G></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><W>select a repo for detail.</W></Row>
|
||||||
|
</>) },
|
||||||
|
|
||||||
|
210: { fast: [300, 400, 200, 100], render: (go) => (<>
|
||||||
|
<Title>kbpkg</Title>
|
||||||
|
<Row><C>repo 210</C> · <G>v2.4.0</G> · <W>main</W></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><Y>git-based package manager</Y></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><W>branch </W><G>main</G><W> ↑0 ↓0</W></Row>
|
||||||
|
<Row><W>commits </W><C>1,284</C></Row>
|
||||||
|
<Row><W>open </W><R>3 issues</R></Row>
|
||||||
|
<Row><W>build </W><G>● passing</G></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><M>last:</M> <W>fix: resolve nested deps</W></Row>
|
||||||
|
<Row><W>by karl · 1 day ago</W></Row>
|
||||||
|
</>) },
|
||||||
|
|
||||||
|
300: { fast: [310, 200, 400, 100], render: (go) => (<>
|
||||||
|
<Title color="#5fd28a">recent commits</Title>
|
||||||
|
<Row><C>page 300</C> · <W>all repos</W></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><Y>a3f1</Y> <W>fix: resolve nested deps</W></Row>
|
||||||
|
<Row><W>kbpkg · 1d</W></Row>
|
||||||
|
<Row><Y>9c02</Y> <W>feat: number navigation</W></Row>
|
||||||
|
<Row><W>gitxt · 2d</W></Row>
|
||||||
|
<Row><Y>1e7d</Y> <W>chore: bump cms/md</W></Row>
|
||||||
|
<Row><W>mdcms · 4d</W></Row>
|
||||||
|
<Row><Y>b840</Y> <W>fix: locale rounding</W></Row>
|
||||||
|
<Row><W>invoice · 5d</W></Row>
|
||||||
|
</>) },
|
||||||
|
|
||||||
|
400: { fast: [200, 300, 500, 100], render: (go) => (<>
|
||||||
|
<Title color="#ff6b6b">open issues</Title>
|
||||||
|
<Row><C>page 400</C> · <W>6 open</W></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><R>#42</R> <W>resolve circular dep graph</W></Row>
|
||||||
|
<Row><W>kbpkg · high</W></Row>
|
||||||
|
<Row><R>#38</R> <W>page 9xx reserved range</W></Row>
|
||||||
|
<Row><W>gitxt · low</W></Row>
|
||||||
|
<Row><R>#31</R> <W>fr-NO plural forms</W></Row>
|
||||||
|
<Row><W>mdcms · medium</W></Row>
|
||||||
|
</>) },
|
||||||
|
|
||||||
|
500: { fast: [200, 300, 400, 100], render: (go) => (<>
|
||||||
|
<Title color="#5fd28a">build status</Title>
|
||||||
|
<Row><C>page 500</C> · <W>last 24h</W></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><G>● passing</G><W> kbpkg</W></Row>
|
||||||
|
<Row><G>● passing</G><W> gitxt</W></Row>
|
||||||
|
<Row><G>● passing</G><W> invoice</W></Row>
|
||||||
|
<Row><Y>● pending</Y><W> mdcms</W></Row>
|
||||||
|
<Row><R>● failing</R><W> capcms</W></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><W>capcms: test timeout in</W></Row>
|
||||||
|
<Row><W>case-export suite.</W></Row>
|
||||||
|
</>) },
|
||||||
|
|
||||||
|
888: { fast: [100, 200, 300, 100], render: (go) => (<>
|
||||||
|
<Title color="#e07ad0">help</Title>
|
||||||
|
<Row><C>page 888</C></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><W>type any 3-digit page number</W></Row>
|
||||||
|
<Row><W>to jump straight to it.</W></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><G>100</G> <W>index</W></Row>
|
||||||
|
<Row><G>200</G> <W>repositories</W></Row>
|
||||||
|
<Row><G>300</G> <W>commits</W></Row>
|
||||||
|
<Row />
|
||||||
|
<Row><W>coloured buttons jump to the</W></Row>
|
||||||
|
<Row><W>four pages shown at the foot.</W></Row>
|
||||||
|
</>) },
|
||||||
|
};
|
||||||
|
// alias detail pages
|
||||||
|
[220, 230, 240, 250, 260, 310].forEach(n => { if (!PAGES[n]) PAGES[n] = PAGES[210]; });
|
||||||
|
|
||||||
|
Object.assign(window, { PAGES, C, Y, G, R, M, W, Row, Title });
|
||||||
152
dev/ui_kits/invoice/app.jsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// invoice app — sidebar shell, invoice list, invoice detail
|
||||||
|
const { Icon, Button, StatusBadge, Avatar, kr, useState } = window;
|
||||||
|
|
||||||
|
function Sidebar() {
|
||||||
|
const nav = [
|
||||||
|
{ icon: 'file', label: 'Invoices', active: true },
|
||||||
|
{ icon: 'users', label: 'Clients' },
|
||||||
|
{ icon: 'chart', label: 'Reports' },
|
||||||
|
{ icon: 'settings', label: 'Settings' },
|
||||||
|
];
|
||||||
|
const apps = [['inv', 'invoice', true], ['ts', 'timesheet', false], ['re', 'reimburse', false]];
|
||||||
|
return (
|
||||||
|
<aside className="iv-side">
|
||||||
|
<div className="iv-brand">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 48 48" fill="none">
|
||||||
|
<rect x="16" y="8" width="24" height="24" rx="6" stroke="var(--accent)" strokeWidth="3" opacity=".34" />
|
||||||
|
<rect x="8" y="16" width="24" height="24" rx="6" fill="var(--accent)" />
|
||||||
|
</svg>
|
||||||
|
<span><span style={{ color: 'var(--accent)' }}>k</span>Benestad</span>
|
||||||
|
</div>
|
||||||
|
<div className="iv-appname">invoice</div>
|
||||||
|
<nav className="iv-nav">
|
||||||
|
{nav.map(n => (
|
||||||
|
<a key={n.label} className={'iv-navitem' + (n.active ? ' is-active' : '')}>
|
||||||
|
<Icon name={n.icon} size={17} /> {n.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="iv-switch">
|
||||||
|
<div className="iv-switch__label">kBenestad apps</div>
|
||||||
|
<div className="iv-switch__tiles">
|
||||||
|
{apps.map(([k, label, on]) => (
|
||||||
|
<div key={k} className={'iv-tile' + (on ? ' is-on' : '')} title={label}>{k}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceList({ onOpen }) {
|
||||||
|
const { invoices } = window.INVOICE_DATA;
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const list = invoices.filter(i => (i.id + i.client).toLowerCase().includes(q.toLowerCase()));
|
||||||
|
const outstanding = invoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((s, i) => s + i.amount, 0);
|
||||||
|
const paid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + i.amount, 0);
|
||||||
|
return (
|
||||||
|
<div className="iv-content">
|
||||||
|
<div className="iv-topbar">
|
||||||
|
<h1>Invoices</h1>
|
||||||
|
<Button leftIcon={<Icon name="plus" size={16} />}>New invoice</Button>
|
||||||
|
</div>
|
||||||
|
<div className="iv-stats">
|
||||||
|
<div className="iv-stat"><span className="iv-stat__k">Outstanding</span><span className="iv-stat__v">kr {kr(outstanding)}</span></div>
|
||||||
|
<div className="iv-stat"><span className="iv-stat__k">Paid this period</span><span className="iv-stat__v">kr {kr(paid)}</span></div>
|
||||||
|
<div className="iv-stat"><span className="iv-stat__k">Open invoices</span><span className="iv-stat__v">{invoices.filter(i => i.status !== 'paid' && i.status !== 'draft').length}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="iv-search">
|
||||||
|
<Icon name="search" size={16} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search invoices or clients…" />
|
||||||
|
</div>
|
||||||
|
<div className="kb-card" style={{ overflow: 'hidden' }}>
|
||||||
|
<table className="iv-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Invoice</th><th>Client</th><th>Status</th><th>Due</th><th className="num">Amount</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map(inv => (
|
||||||
|
<tr key={inv.id} onClick={() => onOpen(inv)}>
|
||||||
|
<td className="mono">{inv.id}</td>
|
||||||
|
<td>{inv.client}</td>
|
||||||
|
<td><StatusBadge status={inv.status} /></td>
|
||||||
|
<td className="muted">{inv.due}</td>
|
||||||
|
<td className="num mono">kr {kr(inv.amount)}</td>
|
||||||
|
<td className="chev"><Icon name="chevron" size={15} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceDetail({ inv, onBack }) {
|
||||||
|
const { from } = window.INVOICE_DATA;
|
||||||
|
const subtotal = inv.items.reduce((s, [, qty, rate]) => s + qty * rate, 0);
|
||||||
|
const vat = Math.round(subtotal * 0.25);
|
||||||
|
const total = subtotal + vat;
|
||||||
|
return (
|
||||||
|
<div className="iv-content">
|
||||||
|
<div className="iv-topbar">
|
||||||
|
<button className="iv-back" onClick={onBack}><Icon name="back" size={16} /> Invoices</button>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
{inv.status !== 'paid' && <Button variant="secondary" leftIcon={<Icon name="check" size={16} />}>Mark paid</Button>}
|
||||||
|
<Button variant="secondary" leftIcon={<Icon name="download" size={16} />}>PDF</Button>
|
||||||
|
{inv.status === 'draft'
|
||||||
|
? <Button leftIcon={<Icon name="send" size={16} />}>Send</Button>
|
||||||
|
: <Button leftIcon={<Icon name="send" size={16} />}>Resend</Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="iv-sheet kb-card kb-card--raised">
|
||||||
|
<div className="iv-sheet__head">
|
||||||
|
<div>
|
||||||
|
<div className="iv-sheet__no">{inv.id}</div>
|
||||||
|
<StatusBadge status={inv.status} />
|
||||||
|
</div>
|
||||||
|
<div className="iv-sheet__from">
|
||||||
|
<strong>{from.org}</strong>
|
||||||
|
<span>{from.name}</span>
|
||||||
|
<span>{from.email}</span>
|
||||||
|
<span className="muted">{from.orgnr}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="iv-sheet__parties">
|
||||||
|
<div><span className="kb-eyebrow">Billed to</span><div className="iv-party">{inv.client}</div></div>
|
||||||
|
<div className="iv-dates">
|
||||||
|
<div><span className="kb-eyebrow">Issued</span><div>{inv.issued}</div></div>
|
||||||
|
<div><span className="kb-eyebrow">Due</span><div>{inv.due}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table className="iv-lines">
|
||||||
|
<thead><tr><th>Description</th><th className="num">Qty</th><th className="num">Rate</th><th className="num">Amount</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{inv.items.map(([desc, qty, rate], i) => (
|
||||||
|
<tr key={i}><td>{desc}</td><td className="num mono">{qty}</td><td className="num mono">kr {kr(rate)}</td><td className="num mono">kr {kr(qty * rate)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="iv-totals">
|
||||||
|
<div className="iv-totrow"><span>Subtotal</span><span className="mono">kr {kr(subtotal)}</span></div>
|
||||||
|
<div className="iv-totrow"><span>VAT 25%</span><span className="mono">kr {kr(vat)}</span></div>
|
||||||
|
<div className="iv-totrow iv-totrow--grand"><span>Total</span><span className="mono">kr {kr(total)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [inv, setInv] = useState(null);
|
||||||
|
return (
|
||||||
|
<div className="iv-app">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="iv-main">
|
||||||
|
{inv ? <InvoiceDetail inv={inv} onBack={() => setInv(null)} /> : <InvoiceList onOpen={setInv} />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
16
dev/ui_kits/invoice/data.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// invoice — mock data for the internal-tools app family
|
||||||
|
window.INVOICE_DATA = {
|
||||||
|
invoices: [
|
||||||
|
{ id: 'INV-0042', client: 'Nordlys Media AS', amount: 18400, currency: 'NOK', status: 'paid', issued: '12 May', due: '26 May',
|
||||||
|
items: [['Design system audit', 1, 14000], ['Component build', 1, 4400]] },
|
||||||
|
{ id: 'INV-0041', client: 'Bergen Legal Aid', amount: 9600, currency: 'NOK', status: 'sent', issued: '02 Jun', due: '16 Jun',
|
||||||
|
items: [['capcms monthly retainer', 1, 9600]] },
|
||||||
|
{ id: 'INV-0040', client: 'Fjord Software', amount: 26250, currency: 'NOK', status: 'overdue', issued: '18 Apr', due: '02 May',
|
||||||
|
items: [['kbpkg integration', 35, 750]] },
|
||||||
|
{ id: 'INV-0039', client: 'Oslo Kommune', amount: 12000, currency: 'NOK', status: 'draft', issued: '—', due: '—',
|
||||||
|
items: [['mdcms migration', 1, 12000]] },
|
||||||
|
{ id: 'INV-0038', client: 'Nordlys Media AS', amount: 7200, currency: 'NOK', status: 'paid', issued: '28 Apr', due: '12 May',
|
||||||
|
items: [['Maintenance', 12, 600]] },
|
||||||
|
],
|
||||||
|
from: { name: 'Karl Benestad', org: 'kBenestad', email: 'karl@kbenestad.no', orgnr: 'NO 998 877 665' },
|
||||||
|
};
|
||||||
106
dev/ui_kits/invoice/index.html
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<!-- @dsCard group="invoice" viewport="1280x820" name="invoice app" subtitle="Internal-tools family — invoice list & detail (shared shell for timesheet, reimburse, capcms, mdcms)" -->
|
||||||
|
<!-- @startingPoint section="Internal tools" subtitle="Sidebar app shell with list + record detail" viewport="1280x820" -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>invoice — kBenestad</title>
|
||||||
|
<link rel="stylesheet" href="../../styles.css">
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body { background: var(--surface-page); }
|
||||||
|
.iv-app { display: grid; grid-template-columns: 248px 1fr; height: 100vh; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.iv-side { background: var(--surface-card); border-right: var(--border-thin) solid var(--border-subtle);
|
||||||
|
display: flex; flex-direction: column; padding: 20px 16px; }
|
||||||
|
.iv-brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 17px; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-strong); padding: 0 6px; }
|
||||||
|
.iv-appname { font-family: var(--font-mono); font-size: var(--text-body-sm); color: var(--text-muted);
|
||||||
|
padding: 2px 6px 0 41px; margin-bottom: 26px; }
|
||||||
|
.iv-nav { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.iv-navitem { display: flex; align-items: center; gap: 11px; padding: 9px 11px; border-radius: var(--radius-md);
|
||||||
|
color: var(--text-body); font-weight: var(--weight-medium); cursor: pointer; font-size: var(--text-body); }
|
||||||
|
.iv-navitem:hover { background: var(--surface-hover); text-decoration: none; }
|
||||||
|
.iv-navitem.is-active { background: var(--accent-soft); color: var(--accent-soft-text); }
|
||||||
|
.iv-switch { margin-top: auto; }
|
||||||
|
.iv-switch__label { font-size: var(--text-caption); letter-spacing: var(--tracking-caps); text-transform: uppercase;
|
||||||
|
color: var(--text-subtle); font-weight: 600; padding: 0 6px 10px; }
|
||||||
|
.iv-switch__tiles { display: flex; gap: 8px; padding: 0 4px; }
|
||||||
|
.iv-tile { width: 38px; height: 38px; border-radius: var(--radius-md); display: grid; place-items: center;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; font-weight: 600; background: var(--surface-sunken);
|
||||||
|
color: var(--text-muted); border: var(--border-thin) solid var(--border-subtle); cursor: pointer; }
|
||||||
|
.iv-tile.is-on { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Main */
|
||||||
|
.iv-main { overflow-y: auto; }
|
||||||
|
.iv-content { max-width: 920px; margin: 0 auto; padding: 32px 36px 60px; }
|
||||||
|
.iv-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 26px; }
|
||||||
|
.iv-topbar h1 { font-size: var(--text-h1); font-weight: var(--weight-bold); }
|
||||||
|
.iv-back { display: inline-flex; align-items: center; gap: 6px; background: none; border: none; cursor: pointer;
|
||||||
|
font-family: var(--font-sans); font-size: var(--text-body); font-weight: var(--weight-medium); color: var(--text-muted); padding: 0; }
|
||||||
|
.iv-back:hover { color: var(--text-strong); }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.iv-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 22px; }
|
||||||
|
.iv-stat { background: var(--surface-card); border: var(--border-thin) solid var(--border-subtle); border-radius: var(--radius-lg);
|
||||||
|
padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.iv-stat__k { font-size: var(--text-body-sm); color: var(--text-muted); }
|
||||||
|
.iv-stat__v { font-size: var(--text-h3); font-weight: var(--weight-bold); color: var(--text-strong); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.iv-search { display: flex; align-items: center; gap: 10px; padding: 0 14px; height: 42px; margin-bottom: 18px;
|
||||||
|
background: var(--surface-card); border: var(--border-thin) solid var(--border-default); border-radius: var(--radius-md); }
|
||||||
|
.iv-search:focus-within { border-color: var(--border-focus); box-shadow: var(--focus-ring); }
|
||||||
|
.iv-search input { flex: 1; border: none; outline: none; background: none; font-family: var(--font-sans); font-size: var(--text-body); color: var(--text-strong); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.iv-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.iv-table th { text-align: left; font-size: var(--text-caption); letter-spacing: var(--tracking-caps); text-transform: uppercase;
|
||||||
|
color: var(--text-muted); font-weight: 600; padding: 12px 16px; border-bottom: var(--border-thin) solid var(--border-subtle); }
|
||||||
|
.iv-table td { padding: 14px 16px; border-bottom: var(--border-thin) solid var(--border-subtle); font-size: var(--text-body); color: var(--text-body); }
|
||||||
|
.iv-table tbody tr { cursor: pointer; }
|
||||||
|
.iv-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.iv-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.iv-table .num { text-align: right; }
|
||||||
|
.iv-table .mono { font-family: var(--font-mono); color: var(--text-strong); }
|
||||||
|
.iv-table .muted { color: var(--text-muted); }
|
||||||
|
.iv-table .chev { color: var(--text-subtle); width: 24px; }
|
||||||
|
|
||||||
|
/* Invoice sheet */
|
||||||
|
.iv-sheet { padding: 36px 40px; }
|
||||||
|
.iv-sheet__head { display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 26px; border-bottom: var(--border-thin) solid var(--border-subtle); }
|
||||||
|
.iv-sheet__no { font-family: var(--font-mono); font-size: var(--text-h2); font-weight: var(--weight-bold); color: var(--text-strong); margin-bottom: 10px; }
|
||||||
|
.iv-sheet__from { display: flex; flex-direction: column; gap: 2px; text-align: right; font-size: var(--text-body-sm); color: var(--text-body); }
|
||||||
|
.iv-sheet__from strong { color: var(--text-strong); font-size: var(--text-body); }
|
||||||
|
.iv-sheet__parties { display: flex; justify-content: space-between; padding: 24px 0; }
|
||||||
|
.iv-party { font-size: var(--text-body-lg); font-weight: var(--weight-semibold); color: var(--text-strong); margin-top: 6px; }
|
||||||
|
.iv-dates { display: flex; gap: 40px; text-align: right; }
|
||||||
|
.iv-dates > div > div { color: var(--text-strong); font-weight: var(--weight-medium); margin-top: 6px; }
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.iv-lines { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||||
|
.iv-lines th { text-align: left; font-size: var(--text-caption); letter-spacing: var(--tracking-caps); text-transform: uppercase;
|
||||||
|
color: var(--text-muted); font-weight: 600; padding: 10px 0; border-bottom: var(--border-medium) solid var(--border-default); }
|
||||||
|
.iv-lines td { padding: 13px 0; border-bottom: var(--border-thin) solid var(--border-subtle); font-size: var(--text-body); color: var(--text-body); }
|
||||||
|
.iv-lines .num { text-align: right; }
|
||||||
|
.iv-lines .mono { font-family: var(--font-mono); }
|
||||||
|
.iv-totals { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; margin-left: auto; width: 260px; }
|
||||||
|
.iv-totrow { display: flex; justify-content: space-between; font-size: var(--text-body); color: var(--text-body); }
|
||||||
|
.iv-totrow--grand { border-top: var(--border-medium) solid var(--border-default); padding-top: 12px; margin-top: 4px;
|
||||||
|
font-size: var(--text-h4); font-weight: var(--weight-bold); color: var(--text-strong); }
|
||||||
|
|
||||||
|
@media (max-width: 760px) { .iv-app { grid-template-columns: 1fr; } .iv-side { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script type="text/babel" src="ui.jsx"></script>
|
||||||
|
<script type="text/babel" src="app.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
dev/ui_kits/invoice/ui.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// invoice UI primitives + icons (mirrors design-system components via .kb-* classes)
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
file: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6ZM14 2v6h6M8 13h8M8 17h8M8 9h2',
|
||||||
|
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8',
|
||||||
|
chart: 'M3 3v18h18M7 16v-5M12 16V8M17 16v-9',
|
||||||
|
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2V21a2 2 0 1 1-4 0v-.1A1.7 1.7 0 0 0 6 19.4l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H2a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 6l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9.4A1.7 1.7 0 0 0 11 2.1V2a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9.4a1.7 1.7 0 0 0 2.1 1.6H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z',
|
||||||
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
download: 'M12 3v12m0 0 4-4m-4 4-4-4M5 21h14',
|
||||||
|
send: 'M22 2 11 13M22 2l-7 20-4-9-9-4 20-7Z',
|
||||||
|
check: 'M20 6 9 17l-5-5',
|
||||||
|
search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z',
|
||||||
|
chevron: 'm9 6 6 6-6 6',
|
||||||
|
back: 'm15 18-6-6 6-6',
|
||||||
|
clock: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Icon({ name, size = 16, strokeWidth = 2, style }) {
|
||||||
|
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, ...style }}><path d={ICONS[name]} /></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button({ variant = 'primary', size, leftIcon, className = '', children, ...rest }) {
|
||||||
|
const cls = ['kb-btn', `kb-btn--${variant}`, size && `kb-btn--${size}`, className].filter(Boolean).join(' ');
|
||||||
|
return <button className={cls} {...rest}>{leftIcon}{children}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TONE = { paid: 'success', sent: 'accent', draft: 'neutral', overdue: 'danger' };
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
return <span className={`kb-badge kb-badge--${STATUS_TONE[status]}`}><span className="kb-badge__dot" />{status}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({ name = '', size = 32, square }) {
|
||||||
|
const initials = name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
|
||||||
|
return <span className={'kb-avatar' + (square ? ' kb-avatar--square' : '')} style={{ width: size, height: size, fontSize: size * 0.38 }}>{initials}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kr = (n) => n.toLocaleString('nb-NO');
|
||||||
|
|
||||||
|
Object.assign(window, { Icon, Button, StatusBadge, Avatar, kr, useState });
|
||||||
57
dev/ui_kits/kbpkg/data.js
Normal file
|
|
@ -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']],
|
||||||
|
},
|
||||||
|
];
|
||||||
118
dev/ui_kits/kbpkg/index.html
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<!-- @dsCard group="kbpkg" viewport="1280x860" name="kbpkg registry" subtitle="Flagship — git-based package registry & package pages" -->
|
||||||
|
<!-- @startingPoint section="kbpkg" subtitle="Package registry home with search & package detail" viewport="1280x860" -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>kbpkg — registry</title>
|
||||||
|
<link rel="stylesheet" href="../../styles.css">
|
||||||
|
<style>
|
||||||
|
body { background: var(--surface-page); color: var(--text-body); }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.kb-header { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 32px;
|
||||||
|
height: 60px; padding: 0 28px; background: color-mix(in srgb, var(--surface-card) 88%, transparent);
|
||||||
|
backdrop-filter: saturate(1.1) blur(8px); border-bottom: var(--border-thin) solid var(--border-subtle); }
|
||||||
|
.kb-nav { display: flex; gap: 4px; flex: 1; }
|
||||||
|
.kb-nav__item { font-size: var(--text-body); font-weight: var(--weight-medium); color: var(--text-muted);
|
||||||
|
padding: 6px 10px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||||
|
.kb-nav__item:hover { color: var(--text-strong); background: var(--surface-hover); text-decoration: none; }
|
||||||
|
.kb-nav__item.is-active { color: var(--text-strong); }
|
||||||
|
|
||||||
|
/* Page shell */
|
||||||
|
.kb-page { max-width: 920px; margin: 0 auto; padding: 40px 28px 80px; }
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.kb-hero { margin-bottom: 36px; }
|
||||||
|
.kb-hero__title { font-size: var(--text-h1); font-weight: var(--weight-bold); margin: 10px 0 8px; }
|
||||||
|
.kb-hero__sub { font-size: var(--text-body-lg); color: var(--text-muted); max-width: 52ch; }
|
||||||
|
.kb-search { display: flex; align-items: center; gap: 10px; margin-top: 24px; padding: 0 16px; height: 48px;
|
||||||
|
background: var(--surface-card); border: var(--border-thin) solid var(--border-default);
|
||||||
|
border-radius: var(--radius-lg); transition: border-color var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) var(--ease-out); }
|
||||||
|
.kb-search:focus-within { border-color: var(--border-focus); box-shadow: var(--focus-ring); }
|
||||||
|
.kb-search input { flex: 1; border: none; outline: none; background: none; font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-body-lg); color: var(--text-strong); }
|
||||||
|
.kb-search input::placeholder { color: var(--text-subtle); }
|
||||||
|
.kb-filters { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* List */
|
||||||
|
.kb-listhead { display: flex; justify-content: space-between; font-size: var(--text-body-sm);
|
||||||
|
color: var(--text-muted); padding: 0 4px 12px; border-bottom: var(--border-thin) solid var(--border-subtle); }
|
||||||
|
.kb-list { display: flex; flex-direction: column; }
|
||||||
|
.kb-pkg { display: flex; gap: 16px; padding: 20px 16px; border-bottom: var(--border-thin) solid var(--border-subtle);
|
||||||
|
cursor: pointer; border-radius: var(--radius-md); transition: background var(--duration-fast) var(--ease-out); margin: 0 -16px; }
|
||||||
|
.kb-pkg:hover { background: var(--surface-card); }
|
||||||
|
.kb-pkg__body { flex: 1; min-width: 0; }
|
||||||
|
.kb-pkg__top { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.kb-pkg__name { font-family: var(--font-mono); font-weight: var(--weight-semibold); font-size: var(--text-body-lg); color: var(--text-strong); }
|
||||||
|
.kb-pkg__desc { font-size: var(--text-body); color: var(--text-body); margin: 4px 0 10px; }
|
||||||
|
.kb-pkg__tags { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||||
|
.kb-pkg__meta { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; font-size: var(--text-body-sm); color: var(--text-muted); white-space: nowrap; }
|
||||||
|
.kb-pkg__meta span { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.kb-empty { padding: 40px 0; text-align: center; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Install command */
|
||||||
|
.kb-cmd { display: flex; align-items: center; gap: 12px; background: var(--term-bg); border-radius: var(--radius-md);
|
||||||
|
padding: 0 8px 0 18px; height: 50px; margin: 22px 0 28px; }
|
||||||
|
.kb-cmd__prompt { color: var(--term-green); font-family: var(--font-mono); }
|
||||||
|
.kb-cmd__text { flex: 1; color: var(--term-fg); font-family: var(--font-mono); font-size: var(--text-mono); }
|
||||||
|
.kb-cmd__copy { display: inline-flex; align-items: center; gap: 6px; background: rgba(255,255,255,.08); color: var(--term-fg);
|
||||||
|
border: none; border-radius: var(--radius-sm); padding: 8px 12px; font-family: var(--font-sans); font-size: var(--text-body-sm);
|
||||||
|
font-weight: var(--weight-medium); cursor: pointer; }
|
||||||
|
.kb-cmd__copy:hover { background: rgba(255,255,255,.16); }
|
||||||
|
|
||||||
|
/* Package detail */
|
||||||
|
.kb-crumb { display: flex; align-items: center; gap: 8px; font-size: var(--text-body-sm); color: var(--text-muted); margin-bottom: 20px; }
|
||||||
|
.kb-crumb a { color: var(--text-muted); cursor: pointer; }
|
||||||
|
.kb-crumb a:hover { color: var(--text-strong); text-decoration: none; }
|
||||||
|
.kb-pkghead { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; }
|
||||||
|
.kb-pkghead__name { font-family: var(--font-mono); font-size: var(--text-h2); font-weight: var(--weight-bold); display: flex; align-items: center; gap: 12px; }
|
||||||
|
.kb-pkghead__desc { font-size: var(--text-body-lg); color: var(--text-muted); margin-top: 8px; max-width: 56ch; }
|
||||||
|
.kb-cols { display: grid; grid-template-columns: 1fr 280px; gap: 36px; }
|
||||||
|
.kb-main { min-width: 0; }
|
||||||
|
.kb-tabpanel { padding-top: 24px; }
|
||||||
|
.kb-prose h3 { font-family: var(--font-mono); font-size: var(--text-h4); margin-bottom: 12px; }
|
||||||
|
.kb-prose p { color: var(--text-body); line-height: var(--leading-relaxed); margin-bottom: 14px; max-width: 64ch; }
|
||||||
|
.kb-versions { display: flex; flex-direction: column; }
|
||||||
|
.kb-version { display: flex; justify-content: space-between; align-items: center; padding: 13px 6px;
|
||||||
|
border-bottom: var(--border-thin) solid var(--border-subtle); }
|
||||||
|
.kb-version__v { font-weight: var(--weight-semibold); color: var(--text-strong); display: inline-flex; align-items: center; gap: 10px; }
|
||||||
|
.kb-version__when { font-size: var(--text-body-sm); color: var(--text-muted); }
|
||||||
|
.kb-side { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.kb-meta { display: flex; flex-direction: column; gap: 0; border: var(--border-thin) solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
.kb-meta__row { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; font-size: var(--text-body-sm);
|
||||||
|
border-bottom: var(--border-thin) solid var(--border-subtle); }
|
||||||
|
.kb-meta__row:last-child { border-bottom: none; }
|
||||||
|
.kb-meta__label { display: inline-flex; align-items: center; gap: 8px; color: var(--text-muted); }
|
||||||
|
.kb-meta__val { color: var(--text-strong); font-weight: var(--weight-medium); white-space: nowrap; }
|
||||||
|
.kb-link { color: var(--text-link); display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) { .kb-cols { grid-template-columns: 1fr; } .kb-nav { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script type="text/babel" src="ui.jsx"></script>
|
||||||
|
<script type="text/babel" src="screens.jsx"></script>
|
||||||
|
<script type="text/babel">
|
||||||
|
const { Header, Registry, PackagePage, useState } = window;
|
||||||
|
function App() {
|
||||||
|
const [pkg, setPkg] = useState(null);
|
||||||
|
const open = (p) => { setPkg(p); window.scrollTo(0, 0); };
|
||||||
|
const home = () => { setPkg(null); window.scrollTo(0, 0); };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header onHome={home} />
|
||||||
|
{pkg ? <PackagePage pkg={pkg} onHome={home} onOpen={open} /> : <Registry onOpen={open} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
179
dev/ui_kits/kbpkg/screens.jsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// kbpkg screens — Header, install command, registry list, package detail.
|
||||||
|
const { Icon, Button, Badge, Tag, Avatar, Lockup, useState } = window;
|
||||||
|
|
||||||
|
function Header({ view, onHome }) {
|
||||||
|
const nav = ['Packages', 'Docs', 'Changelog'];
|
||||||
|
return (
|
||||||
|
<header className="kb-header">
|
||||||
|
<Lockup onClick={onHome} />
|
||||||
|
<nav className="kb-nav">
|
||||||
|
{nav.map((n, i) => (
|
||||||
|
<a key={n} className={'kb-nav__item' + (i === 0 ? ' is-active' : '')} onClick={onHome}>{n}</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<Button variant="secondary" size="sm" leftIcon={<Icon name="plus" size={15} />}>Publish</Button>
|
||||||
|
<Avatar name="Karl Benestad" size={30} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyCommand({ id }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const cmd = `kbpkg add ${id}`;
|
||||||
|
const copy = () => { setCopied(true); setTimeout(() => setCopied(false), 1400); };
|
||||||
|
return (
|
||||||
|
<div className="kb-cmd">
|
||||||
|
<span className="kb-cmd__prompt">$</span>
|
||||||
|
<code className="kb-cmd__text">{cmd}</code>
|
||||||
|
<button className="kb-cmd__copy" onClick={copy} aria-label="Copy command">
|
||||||
|
<Icon name={copied ? 'check' : 'copy'} size={15} />
|
||||||
|
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageRow({ pkg, onOpen }) {
|
||||||
|
return (
|
||||||
|
<div className="kb-pkg" onClick={() => onOpen(pkg)}>
|
||||||
|
<span className="kb-avatar kb-avatar--square" style={{ width: 40, height: 40, background: 'var(--accent-soft)', color: 'var(--accent)' }}>
|
||||||
|
<Icon name="box" size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="kb-pkg__body">
|
||||||
|
<div className="kb-pkg__top">
|
||||||
|
<span className="kb-pkg__name">{pkg.name}</span>
|
||||||
|
<Badge tone="accent">v{pkg.version}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="kb-pkg__desc">{pkg.desc}</p>
|
||||||
|
<div className="kb-pkg__tags">{pkg.tags.map(t => <Tag key={t}>{t}</Tag>)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kb-pkg__meta">
|
||||||
|
<span><Icon name="download" size={14} /> {pkg.installs}</span>
|
||||||
|
<span><Icon name="clock" size={14} /> {pkg.updated}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Registry({ onOpen }) {
|
||||||
|
const all = window.KBPKG_PACKAGES;
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [filter, setFilter] = useState(null);
|
||||||
|
const tags = [...new Set(all.flatMap(p => p.tags))].slice(0, 8);
|
||||||
|
const list = all.filter(p => {
|
||||||
|
const matchQ = !q || (p.name + ' ' + p.desc).toLowerCase().includes(q.toLowerCase());
|
||||||
|
const matchT = !filter || p.tags.includes(filter);
|
||||||
|
return matchQ && matchT;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="kb-page">
|
||||||
|
<div className="kb-hero">
|
||||||
|
<span className="kb-eyebrow">kbpkg registry</span>
|
||||||
|
<h1 className="kb-hero__title">A package manager for me.</h1>
|
||||||
|
<p className="kb-hero__sub">Git-based packages for the kBenestad apps. Install anything with one command.</p>
|
||||||
|
<div className="kb-search">
|
||||||
|
<Icon name="search" size={18} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search packages…" />
|
||||||
|
</div>
|
||||||
|
<div className="kb-filters">
|
||||||
|
<Tag onClick={() => setFilter(null)} active={!filter}>all</Tag>
|
||||||
|
{tags.map(t => <Tag key={t} onClick={() => setFilter(t === filter ? null : t)} active={t === filter}>{t}</Tag>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kb-listhead">
|
||||||
|
<span>{list.length} package{list.length === 1 ? '' : 's'}</span>
|
||||||
|
<span>sorted by recently updated</span>
|
||||||
|
</div>
|
||||||
|
<div className="kb-list">
|
||||||
|
{list.map(p => <PackageRow key={p.id} pkg={p} onOpen={onOpen} />)}
|
||||||
|
{list.length === 0 && <div className="kb-empty">No packages match “{q}”.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaRow({ icon, label, children }) {
|
||||||
|
return (
|
||||||
|
<div className="kb-meta__row">
|
||||||
|
<span className="kb-meta__label"><Icon name={icon} size={15} /> {label}</span>
|
||||||
|
<span className="kb-meta__val">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackagePage({ pkg, onHome, onOpen }) {
|
||||||
|
const [tab, setTab] = useState('readme');
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'readme', label: 'Readme' },
|
||||||
|
{ id: 'versions', label: `Versions` },
|
||||||
|
{ id: 'deps', label: 'Dependencies' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="kb-page">
|
||||||
|
<div className="kb-crumb"><a onClick={onHome}>Packages</a><Icon name="chevronR" size={14} /><span>{pkg.name}</span></div>
|
||||||
|
<div className="kb-pkghead">
|
||||||
|
<div>
|
||||||
|
<h1 className="kb-pkghead__name">{pkg.name} <Badge tone="accent">v{pkg.version}</Badge></h1>
|
||||||
|
<p className="kb-pkghead__desc">{pkg.desc}</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="success" dot>published</Badge>
|
||||||
|
</div>
|
||||||
|
<CopyCommand id={pkg.id} />
|
||||||
|
<div className="kb-cols">
|
||||||
|
<div className="kb-main">
|
||||||
|
<div className="kb-tabs">
|
||||||
|
{tabs.map(t => <button key={t.id} className="kb-tab" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}>{t.label}</button>)}
|
||||||
|
</div>
|
||||||
|
<div className="kb-tabpanel">
|
||||||
|
{tab === 'readme' && (
|
||||||
|
<div className="kb-prose">
|
||||||
|
<h3>{pkg.name}</h3>
|
||||||
|
<p>{pkg.readme}</p>
|
||||||
|
<p>Install with <code>kbpkg add {pkg.id}</code> and import what you need. Licensed under {pkg.license}.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'versions' && (
|
||||||
|
<div className="kb-versions">
|
||||||
|
{pkg.versions.map(([v, when], i) => (
|
||||||
|
<div key={v} className="kb-version">
|
||||||
|
<span className="kb-version__v">v{v}{i === 0 && <Badge tone="success">latest</Badge>}</span>
|
||||||
|
<span className="kb-version__when">{when}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'deps' && (
|
||||||
|
<div className="kb-versions">
|
||||||
|
{pkg.dependencies.length === 0 && <div className="kb-empty" style={{ padding: '20px 0' }}>No dependencies — this package is self-contained.</div>}
|
||||||
|
{pkg.dependencies.map(([d, range]) => {
|
||||||
|
const dp = window.KBPKG_PACKAGES.find(p => p.id === d);
|
||||||
|
return (
|
||||||
|
<div key={d} className="kb-version" style={{ cursor: dp ? 'pointer' : 'default' }} onClick={() => dp && onOpen(dp)}>
|
||||||
|
<span className="kb-version__v" style={{ fontFamily: 'var(--font-mono)' }}>{d}</span>
|
||||||
|
<span className="kb-version__when" style={{ fontFamily: 'var(--font-mono)' }}>{range}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="kb-side">
|
||||||
|
<Button variant="primary" leftIcon={<Icon name="download" size={16} />} style={{ width: '100%' }}>Install</Button>
|
||||||
|
<div className="kb-meta">
|
||||||
|
<MetaRow icon="box" label="Version">v{pkg.version}</MetaRow>
|
||||||
|
<MetaRow icon="scale" label="License">{pkg.license}</MetaRow>
|
||||||
|
<MetaRow icon="drive" label="Size">{pkg.size}</MetaRow>
|
||||||
|
<MetaRow icon="layers" label="Dependencies">{pkg.deps}</MetaRow>
|
||||||
|
<MetaRow icon="branch" label="Repository"><a className="kb-link" href="#">{pkg.repo} <Icon name="external" size={13} /></a></MetaRow>
|
||||||
|
<MetaRow icon="user" label="Owner"><span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><Avatar name={pkg.owner} size={22} /> {pkg.owner}</span></MetaRow>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { Header, Registry, PackagePage });
|
||||||
69
dev/ui_kits/kbpkg/ui.jsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// kbpkg UI primitives — mirror the kBenestad design-system components using the
|
||||||
|
// real .kb-* classes from components.css (linked via styles.css). Self-contained
|
||||||
|
// so the kit renders anywhere, not only inside the Design System tab.
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z',
|
||||||
|
download: 'M12 3v12m0 0 4-4m-4 4-4-4M5 21h14',
|
||||||
|
copy: 'M9 9h10v10H9zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
|
||||||
|
check: 'M20 6 9 17l-5-5',
|
||||||
|
box: 'M21 8v8a2 2 0 0 1-1 1.7l-7 4a2 2 0 0 1-2 0l-7-4A2 2 0 0 1 3 16V8a2 2 0 0 1 1-1.7l7-4a2 2 0 0 1 2 0l7 4A2 2 0 0 1 21 8ZM3.3 7 12 12l8.7-5M12 22V12',
|
||||||
|
branch: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a9 9 0 0 1-9 9',
|
||||||
|
clock: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
|
||||||
|
terminal: 'm4 17 6-6-6-6M12 19h8',
|
||||||
|
chevronR: 'm9 6 6 6-6 6',
|
||||||
|
external: 'M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6',
|
||||||
|
scale: 'M12 3v18M3 7h18M7 7l-3 7a3 3 0 0 0 6 0L7 7Zm10 0-3 7a3 3 0 0 0 6 0l-3-7ZM5 21h14',
|
||||||
|
drive: 'M22 12H2M5.5 6h13l3 6v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-6l3.5-6ZM6 16h.01M10 16h.01',
|
||||||
|
layers: 'm12 2 9 5-9 5-9-5 9-5ZM3 12l9 5 9-5M3 17l9 5 9-5',
|
||||||
|
user: 'M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z',
|
||||||
|
book: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5v15Z',
|
||||||
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Icon({ name, size = 16, strokeWidth = 2, style }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ flexShrink: 0, ...style }}>
|
||||||
|
<path d={ICONS[name]} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button({ variant = 'primary', size, iconOnly, leftIcon, className = '', children, ...rest }) {
|
||||||
|
const cls = ['kb-btn', `kb-btn--${variant}`, size && `kb-btn--${size}`, iconOnly && 'kb-btn--icon', className].filter(Boolean).join(' ');
|
||||||
|
return <button className={cls} {...rest}>{leftIcon}{children}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ tone = 'neutral', dot, children }) {
|
||||||
|
return <span className={`kb-badge kb-badge--${tone}`}>{dot && <span className="kb-badge__dot" />}{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tag({ children, onClick, active }) {
|
||||||
|
return <span className="kb-tag" onClick={onClick}
|
||||||
|
style={onClick ? { cursor: 'pointer', borderColor: active ? 'var(--accent)' : undefined, color: active ? 'var(--accent-soft-text)' : undefined, background: active ? 'var(--accent-soft)' : undefined } : undefined}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({ name = '', size = 32, square }) {
|
||||||
|
const initials = name.split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
|
||||||
|
return <span className={'kb-avatar' + (square ? ' kb-avatar--square' : '')} style={{ width: size, height: size, fontSize: size * 0.4 }}>{initials}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brand lockup (stack mark + wordmark)
|
||||||
|
function Lockup({ onClick }) {
|
||||||
|
return (
|
||||||
|
<span onClick={onClick} style={{ display: 'inline-flex', alignItems: 'center', gap: 9, cursor: 'pointer' }}>
|
||||||
|
<svg width="26" height="26" viewBox="0 0 48 48" fill="none">
|
||||||
|
<rect x="16" y="8" width="24" height="24" rx="6" stroke="var(--accent)" strokeWidth="3" opacity=".34" />
|
||||||
|
<rect x="8" y="16" width="24" height="24" rx="6" fill="var(--accent)" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 19, letterSpacing: '-0.02em', color: 'var(--text-strong)' }}>
|
||||||
|
<span style={{ color: 'var(--accent)' }}>k</span>Benestad
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { Icon, Button, Badge, Tag, Avatar, Lockup, useState });
|
||||||