80659232bc
gates / gates (push) Successful in 6m58s
The direction the operator chose: the controller's FRAME around the website's own
content. Staged at /preview/m3/, noindex, nothing live.
website/assets/shell.css - 117 rules COPIED VERBATIM from felhom-controller
style.css @ dcd89364a4 (sidebar, mobile top bar, off-canvas drawer, and the
component atoms the restyled pages use: btn, alert, app-info-card, launch-*,
stack-detail-card, form-*). Exactly ONE rule is not verbatim and says so:
body.shell { display:flex; min-height:100vh }, which lives in the controller's
base rather than in a component, scoped to .shell so other pages are untouched.
The generator NAMES every rule it copies and REFUSES to write the file if one has
disappeared upstream. That is not ceremony: it already caught two things this
session. It refused because .sidebar::-webkit-scrollbar does not exist (only the
-track override does), and because prefers-reduced-motion is split across FIVE
separate blocks in the controller - reading only the first would have silently
lost four rules. The M1 round lost a rule exactly this way.
The bare `body { flex-direction: column }` that broke M1's phone layout is copied
this time, by name, as are body.nav-open and the no-js/js pair.
Load order is deliberate: shell.css AFTER site.css. Thirteen selectors exist in
both files with different meanings (.nav-links, .page-header, .lang-globe*, .ico)
- a horizontal site nav there, a vertical sidebar here. Later wins, and a page
that does not load shell.css is completely unaffected.
website/preview/m3/index.html - the home page in the shell. Content is /preview/'s
cleaned text; body carries `shell page-index` so the existing .page-index content
rules (caps, flow, vs, band, who-mini) are reused rather than duplicated. Six
sections, left-aligned headers, the controller's cards. Head tags (title, meta
description, canonical, hreflang) are already the FINAL values, so the switch in
part D needs no head edit.
Four changes from the operator's mockup, each with its reason in the generator:
1. the active item follows the page (the mockups show Inditopult active on all)
2. Teszteles is ALSO a primary button at the top of the lower group - it is the
one action we want and the mockup buries it at the bottom
3. the Alkalmazasok categories are anchors on /alkalmazasok: real URLs, no
JS-only state
4. ADDED GYIK. It is in today's nav; dropping it silently would take a page out
of navigation, which is not a change a restyle gets to make.
scripts/site_gates.py - gate 3 extended rather than duplicated, as asked: a SHELL
set whose pages must carry an IDENTICAL <nav class="sidebar"> after the active
marker is normalised. RED-PROOFED: a one-character change to one menu label on a
second shell page convicts. It also prints "DIFFER" rather than a reassuring
"identical" count when it fails - a false reassurance printed beside a conviction
is the exact defect this repo files rows about.
documentation/design/design-system.md - new "Shell" section: what is shared (tokens
+ shell + atoms), what each side owns, how a change flows (controller first, the
website's copy the same week), and that the cross-repo gate is deliberately NOT
built yet.
223 lines
13 KiB
Markdown
223 lines
13 KiB
Markdown
# Felhom design system v2
|
||
|
||
**Status:** canonical reference. Shipped in the controller UI with **TASK-D0** (controller v0.96.0,
|
||
2026-07-02); D1–D4 (settings IA split, Tárhely nav promotion, website, hub) build from this document.
|
||
|
||
The system was approved via two mockup rounds. Four pillars:
|
||
|
||
1. **Logo-derived navy palette** — a single blue accent on layered navy, replacing the GitHub-dark clone.
|
||
2. **Exception-based status color** — nominal state is blue/neutral; amber/red appear **only on deviation**.
|
||
3. **Vendored assets** — fonts and icons ship inside the binary; no CDN dependency (offline customer nodes).
|
||
4. **Flat metadata** — no pills, no shadows, no bubbles; one 2px radius everywhere.
|
||
|
||
## The rule set
|
||
|
||
- **One radius: 2px** (`--radius`). The only exceptions are true circles (state dots, spinners) at 50%.
|
||
- **No shadows.** No `box-shadow` anywhere. Depth comes from background layering (`--bg-0/1/2`) and
|
||
hairlines (`--line`, `--line-soft`).
|
||
- **Boxless sections.** Section headers are an h3 + muted hint over a 1px hairline (`.section-h`),
|
||
not a card. Panels (`.panel`) exist for genuinely grouped content, flat: bg-1 + 1px line.
|
||
- **Color = exception.** Anything operating normally is blue or neutral. Green does not exist in the
|
||
system. Amber (`--warn`) and red (`--crit`) are reserved for deviations that need attention.
|
||
Categorical **data series** (charts) are exempt — they may use distinct hues; *status* may not.
|
||
|
||
## Tokens (canonical, verbatim)
|
||
|
||
```css
|
||
:root {
|
||
--bg-0: #0A1220; --bg-1: #0F1B2E; --bg-2: #16263F;
|
||
--line: #22344F; --line-soft: #1A2A42;
|
||
--text-1: #EDF2F9; --text-2: #94A6BF; --text-3: #5E7392;
|
||
--blue: #0083D8; --blue-bright: #2EA8F5; --blue-dim: rgba(0,131,216,.13);
|
||
--warn: #E0A93E; --warn-dim: rgba(224,169,62,.12);
|
||
--crit: #E5534B; --crit-dim: rgba(229,83,75,.12);
|
||
--radius: 2px;
|
||
--font-ui: 'Plus Jakarta Sans', -apple-system, sans-serif;
|
||
--font-data: 'JetBrains Mono', monospace;
|
||
}
|
||
```
|
||
|
||
Semantics: `--bg-0` page, `--bg-1` panel, `--bg-2` elevated/nested surface (buttons, inputs, table
|
||
stripes). `--text-1` primary, `--text-2` secondary, `--text-3` muted/labels. `--line` visible
|
||
hairline, `--line-soft` row separators and meter tracks. `--blue` fills and primary actions,
|
||
`--blue-bright` text-on-dark accent and hover, `--blue-dim` selection/hover washes.
|
||
|
||
## Typography
|
||
|
||
- **UI face:** Plus Jakarta Sans (weights 400–800). **Data face:** JetBrains Mono (400–500) for
|
||
every numeric/technical value: sizes, times, percentages, paths, IDs, versions.
|
||
- Both are **vendored variable woff2 files** embedded in the binary and served from
|
||
`/static/fonts/` (immutable cache). **latin-ext is mandatory** — Hungarian ő/ű break without it.
|
||
`unicode-range` is preserved from the upstream subsets so latin-ext only loads when needed.
|
||
- No CDN font loading anywhere. `fonts.googleapis.com` is a banned string in the codebase.
|
||
|
||
## Iconography
|
||
|
||
- **Vendored Lucide sprite** (`templates/icons.html`, 30 icons as `<symbol id="i-<name>">`),
|
||
included once at the top of `<body>`. Stroke = `currentColor`, stroke-width 2, round caps.
|
||
- Usage convention everywhere: `<svg class="ico"><use href="#i-hard-drive"/></svg>`
|
||
(`.ico` 16px, `.ico-sm` 14px, `.ico-spin` for progress).
|
||
- **Emoji are banned** in templates and JS-built strings (✅❌⚠🔒⏳… — see the D0 grep gate list).
|
||
- Standalone pages that don't include the layout (catchall, setup wizard) get inline SVGs or plain
|
||
text, not the sprite.
|
||
|
||
## Components
|
||
|
||
### Meter (`.meter` + `.meter-track` / `.meter-fill`)
|
||
The capacity readout — replaces every zone-colored bar. A 3px hairline track (`--line-soft`) with a
|
||
fill; **the fill is blue in nominal state**. Two 1px *neutral* ticks are baked into the track at 70%
|
||
and 85% (`.meter-track::before/::after`; `.no-ticks` disables). The wrapper carries the state class
|
||
from `usageColor`: `nominal` (blue), `warn` (amber fill), `crit` (red fill). Under warn/crit a
|
||
`.meter-flag` renders (dim background chip + triangle-alert icon) with the exact copy:
|
||
warn → **„Fogyóban a hely"**, crit → **„Kritikusan kevés hely"** (storage meters).
|
||
Disconnected/unknown: `.meter-empty` — empty track, value `—`, no color, no flag.
|
||
Deploy-page variant `.meter-track-stacked`: two segments — committed = solid `--blue`, planned/new =
|
||
`--blue` at 35% opacity.
|
||
|
||
### Tag (`.tag`)
|
||
The state chip — replaces pills. Inline-flex, **2px radius**, 1px border, 6px round dot. Modifiers:
|
||
`tag-run` (blue border/text/dot), `tag-progress` (blue + pulsing dot; `prefers-reduced-motion`
|
||
respected), `tag-warn` (amber + dim bg), `tag-crit` (red + dim bg), `tag-neutral`/`tag-off`
|
||
(gray, hollow dot). Protected/locked content uses a neutral tag with the `i-lock` icon.
|
||
|
||
### Metadata row (`.metarow` / `.metarows`)
|
||
Informational chips ("~512M", "Pi kompatibilis", "HDD szükséges") become icon + text with **no
|
||
container**: 14px muted-stroke icon, `--text-2` text, mono for values.
|
||
|
||
### Panel / List (`.panel`, `.list .row`, `.stack-list`)
|
||
Panel: `--bg-1`, 1px `--line`, 2px radius, no shadow. List: one panel whose rows are separated by
|
||
`border-top: 1px solid var(--line-soft)`; each row has a 2px left state edge via `::before` —
|
||
blue for run/progress, amber for warn, transparent for neutral/off.
|
||
|
||
### Section header (`.section-h`)
|
||
h3 + right-aligned muted hint over `border-bottom: 1px solid var(--line)`. No box.
|
||
|
||
### Stats (`.stats-grid` / `.stat-card`)
|
||
Boxless figures with 1px right hairline dividers; numbers in `--font-data` at 1.9rem. A zero
|
||
"Leállítva" renders `--text-3` (`.stat-zero`); a nonzero stopped count renders `--warn`
|
||
(`.stat-warn`) — the exception rule applied to numbers.
|
||
|
||
### Buttons
|
||
2px radius. Default: `--bg-2` + `--line` border, hover border `--blue`. `.btn-primary`: solid blue.
|
||
`.btn-danger`: **transparent background, `--crit` border + text** — danger is outline until the
|
||
confirm step (filled red only inside confirmation modals). `:focus-visible` = 2px `--blue-bright`
|
||
outline on all interactive elements.
|
||
|
||
## State truth tables (funcmap, controller `internal/web/funcmap.go`)
|
||
|
||
`stateColor(ContainerState)` → class suffix (consumed as `tag-*`, `stack-state-*`, `state-text-*`):
|
||
|
||
| ContainerState | token | rendering |
|
||
|---|---|---|
|
||
| running | `run` | blue tag + blue row edge — nominal |
|
||
| starting, deploying | `progress` | blue tag, pulsing dot, blue edge |
|
||
| unhealthy | `warn` | amber tag + amber edge |
|
||
| restarting | `warn` | amber (a restart loop is a problem, not progress) |
|
||
| stopped, exited | `neutral` | gray tag, hollow dot, **no** colored edge |
|
||
| paused | `neutral` | gray |
|
||
| not_deployed, unknown | `off` | gray tag, no row edge |
|
||
|
||
> Deliberate semantic change (operator-approved): **stopped/exited is neutral, not red.** A
|
||
> customer-stopped app is not an emergency; genuine failures surface via unhealthy state + alerts.
|
||
|
||
`usageColor(percent)`: `<70 → nominal`, `70–85 → warn`, `≥85 → crit`.
|
||
`tempColor(celsius)`: `>75 → crit`, `≥60 → warn`, else `nominal` (thresholds unchanged from v1).
|
||
Hungarian `stateLabel` copy is frozen (guarded byte-identical by unit test).
|
||
|
||
Text color classes: `.state-text-{run,progress,warn,crit,neutral,off,nominal}` —
|
||
`nominal` = `--text-1` (no color), `run`/`progress` = `--blue-bright`, `neutral`/`off` = `--text-3`.
|
||
|
||
## Chart palette (Chart.js, monitoring)
|
||
|
||
Categorical series (data, not status): cpu `#2EA8F5`, memory `#8E7CE8`, temp `#E0A93E`,
|
||
load `#5EC4B6`. Chrome: tooltip bg `#0F1B2E`, title `#EDF2F9`, body `#94A6BF`, border `#22344F`,
|
||
ticks `#94A6BF`, axis titles `#5E7392`, grid `rgba(34,52,79,.5)`.
|
||
|
||
## `.tag` vs `.badge` (D3 addendum — resolves the D1 parallel-component drift)
|
||
|
||
Both exist in the controller; they are distinct components, not variants:
|
||
|
||
- **`.tag`** — outline + dot; expresses **state** (run/progress/warn/crit/neutral/off). Use it
|
||
whenever the thing shown can change at runtime: container state, drive activity, health.
|
||
The dot is part of the semantics (pulsing = in progress, hollow = inert).
|
||
- **`.badge`** — filled chip (dim background, no dot); expresses a **static informational label**:
|
||
tier names, protocol/type markers (USB, NFS), feature keywords on catalog cards. Nothing about
|
||
it changes at runtime.
|
||
- If the label has an icon-and-text metadata character ("~512M", "Pi kompatibilis"), prefer
|
||
**`.metarow`** (no container at all) over either chip.
|
||
- On the website, catalog feature keywords are `.badge`-class chips (filled, 2px, blue-dim) —
|
||
informational labels, not states.
|
||
|
||
## Website (TASK-D3)
|
||
|
||
The seven felhom.eu pages share ONE stylesheet (`website/assets/site.css?v=N`) built on this
|
||
document's tokens: self-hosted fonts (`/assets/fonts/`, no CDN — GDPR), a 70-symbol external
|
||
Lucide sprite (`/assets/icons.svg?v=N#i-name`; feature tiles = `.ico-tile` 48px bg-2 squares),
|
||
the two-tone heading as the brand signature (accent span = solid `--blue-bright`), and
|
||
mask-based ✓/✗ marks for CSS-generated content (sprites can't reach `::before`). Marketing-only
|
||
scale addition: hero H1 up to ~3.5rem/800. Assets are cache-busted with `?v=N` (nginx `expires
|
||
7d`) — bump every reference together. Enforcement: `scripts/site_gates.py` (BOM, emoji=0,
|
||
nav/footer consistency, analytics presence, no-CDN, banned tokens, zero `<style>` blocks,
|
||
cache-busting).
|
||
|
||
## Shell (M3, 2026-10-11)
|
||
|
||
**The website and the controller now share two things: the tokens, and the shell.** Everything else
|
||
each owns.
|
||
|
||
| | Shared | Website owns | Controller owns |
|
||
|---|---|---|---|
|
||
| Tokens | **yes** — 18 names, verified byte-identical in both `:root` blocks | — | — |
|
||
| Shell: sidebar, mobile top bar, off-canvas drawer | **yes** — `website/assets/shell.css` is a COPY | the menu's items, the wordmark under the logo, the sidebar's tester button, the language globe's targets | the menu's items, the language globe's POST form |
|
||
| Component atoms: `.btn*`, `.alert*`, `.app-info-card`, `.launch-*`, `.stack-detail-card`, `.form-*` | **yes** — same copy | — | — |
|
||
| Page content and its layout | no | all of it (`site.css`) | all of it (`style.css`) |
|
||
|
||
**`website/assets/shell.css` is a copy, not a library.** It is generated from
|
||
`felhom-controller/controller/internal/web/templates/style.css` and carries that commit in its
|
||
header. Exactly one rule in it is not verbatim: `body.shell { display: flex; min-height: 100vh }`,
|
||
restated because it lives in the controller's base rather than in a component, and scoped to
|
||
`.shell` so a page without the shell is untouched.
|
||
|
||
**Load order matters and is deliberate.** `shell.css` loads AFTER `site.css`. Thirteen selectors
|
||
exist in both with different meanings — `.nav-links`, `.nav-links a`, `.page-header`, `.lang-globe*`,
|
||
`.ico`, `.ico-sm` — a horizontal site nav in one, a vertical sidebar in the other. Later wins, so a
|
||
shell page gets the shell's meaning and a page that does not load `shell.css` is unaffected.
|
||
|
||
### How a change flows
|
||
|
||
1. **The controller changes first.** It is the surface a household operates daily; the website
|
||
follows it, never the other way round.
|
||
2. **The website's copy is re-generated in the same week**, by re-running the generator against the
|
||
controller's new commit and updating the header. A copy that silently falls behind is the failure
|
||
this section exists to prevent.
|
||
3. **A later gate may enforce step 2. One is NOT built today** (operator's brief, 2026-10-11) — the
|
||
cross-repo check waits until the shell has settled.
|
||
|
||
### The trap that makes this a copy with a generator, rather than a copy by hand
|
||
|
||
Copying components by class name **silently drops the element-level rules those components depend
|
||
on.** Measured on 2026-10-11: copying the whole `@media(max-width: 768px)` block *except* its bare
|
||
`body { flex-direction: column }` left the sticky top bar as a row flex item, pushed the content
|
||
135 px right, collapsed the launcher grid to one column, made a page 15 597 px tall on a phone and
|
||
overflowed two others. Everything looked copied. Full account:
|
||
`documentation/audits/mockups-2026-10-11/SHARED-DESIGN.md` §2.
|
||
|
||
So the generator **names every rule it copies and refuses to write the file if one has disappeared
|
||
upstream** — an incomplete copy fails loudly instead of rendering wrong.
|
||
|
||
### Enforcement for the shell
|
||
|
||
`scripts/site_gates.py` gate 3b: every page in the `SHELL` set must carry a
|
||
`<nav class="sidebar">` block **identical** to the others' after the active marker is normalised
|
||
away. The site is static with no build step, so the shell is duplicated into each page; this is what
|
||
keeps the duplication honest. Red-proofed 2026-10-11: a one-character difference in one menu label
|
||
convicts.
|
||
|
||
## Enforcement
|
||
|
||
The D0 grep gate (all counts must be 0 in `controller/internal/{web,setup}`):
|
||
`fonts.googleapis.com`, `border-radius: 999px`, `box-shadow`, the GitHub-dark hexes
|
||
(`#0d1117 #161b22 #1c2128 #30363d #238636 #da3633 #d29922 #db6d28`),
|
||
`system-bar-{green,yellow,red}`, `state-text-{green,red,yellow,orange}`, and the emoji list
|
||
(⚙ 🔧 ✅ ❌ 🔒 ✏ 📦 🔗 ✕ ⏳ 🔴 🟡 ℹ️ ⚠ 💾). Run it after any UI change.
|