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.
13 KiB
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:
- Logo-derived navy palette — a single blue accent on layered navy, replacing the GitHub-dark clone.
- Exception-based status color — nominal state is blue/neutral; amber/red appear only on deviation.
- Vendored assets — fonts and icons ship inside the binary; no CDN dependency (offline customer nodes).
- 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-shadowanywhere. 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)
: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-rangeis preserved from the upstream subsets so latin-ext only loads when needed. - No CDN font loading anywhere.
fonts.googleapis.comis 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>(.ico16px,.ico-sm14px,.ico-spinfor 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
- The controller changes first. It is the surface a household operates daily; the website follows it, never the other way round.
- 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.
- 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.