Match the Technitium canvas: palette, shell, KPI strips, framed tables
CI / check (push) Failing after 37s
CI / image (push) Skipped

The live UI did not follow the canvas because its base layer differed: dark
palette in hex (now the canvas oklch values), 13 px body text, flat Frame panels.

- Tokens: canvas palette, 14 px body, 1.4 line height; Frame without `dense`
  gets the 3 px ring; Panel takes a description line.
- Shell: 56 px brand button with version, 11 px group headings, user card at the
  foot, versions moved into the header status popover, Home / group / section
  crumbs, amber "Attention" status when blocking is off or paused.
- New KpiStrip and PageTabs; Dashboard KPIs in three frames and a Refresh button.
- Zones: KPI strip, tab with count, views, framed table with the canvas columns.
- Apps: Installed / App Store tabs and a grid instead of cards (store inline).
- Blocking: state banner and tab counts; one "Blocking" title.
- Settings: actions in the header, label-and-help rows with the control on the right.
- DHCP, Administration, DNS Client, Logs, Cache, About: subtitles, framed tables,
  KPI strips where the data exists; Login cover with the three-point list.
- docs/compose.shnt.example.yaml: production compose with the console image.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 12:47:27 +07:00
1 parent ea98a33b25
commit 87281a87d3
53 files changed
+1011 -487

No files matched your search

+19
View File
@@ -189,3 +189,22 @@ and the 39 rows stay aligned.
*/
.dependent { box-shadow: inset 2px 0 0 0 var(--acc-ink); }
.withPill { display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; }
/*
The canvas's row (`.frow`): the label with its help under it on the left, the control
on the right, a hairline between rows. The help stays a sibling of the control, so
the grid puts it under the label without moving it in the DOM.
*/
.row {
grid-template-columns: minmax(0, 1fr) minmax(220px, 440px);
grid-template-areas: 'label ctl' 'help ctl';
gap: var(--s-1) var(--s-10);
padding: 14px var(--s-10);
align-items: center;
}
.row > .rowLabel { grid-area: label; padding-top: 0; font-weight: 500; font-size: 14px; }
.row > .rowCtl { grid-area: ctl; align-self: center; }
.row > .rowHelp { grid-area: help; grid-column: auto; max-width: 640px; font-size: 13px; color: var(--mute); }
@media (max-width: 900px) {
.row { grid-template-columns: minmax(0, 1fr); grid-template-areas: 'label' 'help' 'ctl'; }
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Icon, type IconName } from './Icon'
/*
The canvas's KPI strip: a Frame, one panel, and cells told apart by hairlines. Each
cell is a 40 px icon tile, the figure's name, the figure and, beside it, a small
badge with its hint. A tone colours the icon, the figure and the badge together.
*/
export type KpiTone = 'ok' | 'warn' | 'dan'
export interface Kpi {
label: string
value: ReactNode
icon: IconName
tone?: KpiTone
hint?: string
}
const TEXT: Record<KpiTone, string> = { ok: 'text-success', warn: 'text-warning', dan: 'text-destructive' }
const BADGE = { ok: 'success-light', warn: 'warning-light', dan: 'destructive-light' } as const
export function KpiStrip({ items, label }: { items: Kpi[]; label: string }) {
return (
<Frame role="group" aria-label={label}>
<FramePanel className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] -mb-px p-0">
{items.map((k) => (
<div
key={k.label}
className="flex min-w-0 items-center gap-3 border-r border-b border-border px-[18px] py-3.5 last:border-r-0"
>
<span
className={`flex size-10 flex-none items-center justify-center rounded-lg border border-border bg-muted shadow-sm ${k.tone ? TEXT[k.tone] : 'text-foreground'}`}
aria-hidden="true"
>
<Icon name={k.icon} size={18} />
</span>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-[13px] text-muted-foreground">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={`text-2xl font-semibold tabular-nums ${k.tone ? TEXT[k.tone] : ''}`}>{k.value}</span>
{k.hint != null && <Badge variant={k.tone ? BADGE[k.tone] : 'outline'}>{k.hint}</Badge>}
</div>
</div>
</div>
))}
</FramePanel>
</Frame>
)
}
+4
View File
@@ -55,6 +55,7 @@ export function Menu({
onOpen,
anchor = 'right',
asRow = false,
lead,
quiet = false,
icon = 'plus',
alignTo,
@@ -75,6 +76,8 @@ export function Menu({
anchor?: 'right' | 'left'
/** The trigger fills the width of its column, with the label on the left. */
asRow?: boolean
/** Before the label of a row trigger: the account's avatar. */
lead?: ReactNode
/**
* The compact `⋮` with no frame at rest, for a list where one menu repeats on
* every short row (the Dashboard's tops, Blocking's top tables): fifteen framed
@@ -207,6 +210,7 @@ export function Menu({
aria-expanded={open}
onKeyDown={onTriggerKeyDown}
>
{lead}
{text}
<Icon name="chevronDown" size={12} />
</button>
+5
View File
@@ -0,0 +1,5 @@
.tabs { display:flex; gap:2px; border-bottom:1px solid var(--line); margin: 0 0 var(--s-10); overflow-x:auto; }
.tab { display:inline-flex; gap:6px; align-items:center; padding:10px 16px; border:0; border-bottom:2px solid transparent; margin-bottom:-1px; background:transparent; color:var(--mute); font:500 14px/1.4 var(--font-ui); cursor:pointer; white-space:nowrap; }
.tab:hover { color:var(--ink); }
.tab[aria-selected='true'] { color:var(--ink); border-bottom-color:var(--ink); }
.tab em { font:400 10px/1 var(--font-mono); opacity:.6; font-style:normal; }
+36
View File
@@ -0,0 +1,36 @@
import styles from './PageTabs.module.css'
/*
The canvas's page tabs: an underline under the active one and a small count after
its name. They switch a panel on the same screen (an ARIA tablist), unlike
`SubTabs`, whose entries are routes.
*/
export function PageTabs<T extends string>({
label,
tabs,
active,
onChoose,
}: {
label: string
tabs: { id: T; label: string; count?: number }[]
active: T
onChoose: (id: T) => void
}) {
return (
<div className={styles.tabs} role="tablist" aria-label={label}>
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={t.id === active}
className={styles.tab}
onClick={() => onChoose(t.id)}
>
{t.label}
{t.count != null && <em>{t.count}</em>}
</button>
))}
</div>
)
}
+19 -8
View File
@@ -21,6 +21,7 @@ is also what upstream does, using `div.panel` with its title and no `fieldset`.
*/
export function Panel({
title,
description,
actions,
groups2 = false,
children,
@@ -35,6 +36,8 @@ export function Panel({
exactly what this exists to avoid.
*/
title?: ReactNode
/** One muted line under the title, as the canvas draws it. */
description?: ReactNode
/** What goes on the right of the header. */
actions?: ReactNode
/*
@@ -53,11 +56,11 @@ export function Panel({
}) {
const id = useId()
/* ReUI's Frame: a tinted outer frame with the panel inside. `dense` removes the
frame's own padding, so what sits in the panel (tables, rows) keeps its edges. */
/* ReUI's Frame as the canvas draws it: a tinted outer frame, 3 px of ring and
the panel inside. */
if (title == null) {
return (
<Frame dense className={className}>
<Frame className={className}>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
@@ -65,15 +68,23 @@ export function Panel({
return (
<Frame
dense
className={className}
role={groups2 ? 'group' : undefined}
aria-labelledby={groups2 ? id : undefined}
>
<FrameHeader className="flex-row items-center justify-between gap-3 px-4 py-2.5">
<h2 className="m-0 text-sm leading-5 font-semibold text-foreground" id={id}>
{title}
</h2>
<FrameHeader className="flex-row items-center justify-between gap-3 px-4 py-3">
{description == null ? (
<h2 className="m-0 text-sm leading-5 font-semibold text-foreground" id={id}>
{title}
</h2>
) : (
<div className="min-w-0 flex-1">
<h2 className="m-0 text-sm leading-5 font-semibold text-foreground" id={id}>
{title}
</h2>
<p className="m-0 text-sm text-muted-foreground">{description}</p>
</div>
)}
{actions}
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
+1 -1
View File
@@ -40,7 +40,7 @@ export function Segmented<T extends string>({
type="button"
className={cn(
'flex-none rounded-md border-0 bg-transparent px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50',
'aria-pressed:bg-card aria-pressed:text-foreground aria-pressed:shadow-xs aria-selected:bg-card aria-selected:text-foreground aria-selected:shadow-xs',
'aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-xs aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-xs',
compact ? 'h-6 text-xs' : 'h-[30px]',
)}
role={asTabs ? 'tab' : undefined}
+2
View File
@@ -59,3 +59,5 @@ marked with an edge and the rest read as ordinary links.
border-radius: var(--r-full);
background: var(--acc-ink);
}
.count { margin-left: var(--s-3); font: 400 10px/1 var(--font-mono); font-style: normal; opacity: .6; }
+4
View File
@@ -45,6 +45,7 @@ export function SubTabs({
active,
onChoose,
marks,
counts,
}: {
/** The accessible name of the navigation: "Settings sections". */
label: string
@@ -55,6 +56,8 @@ export function SubTabs({
onChoose: (tab: string) => void
/** A tab's mark: the words a screen reader hears after its name. */
marks?: Partial<Record<string, string>>
/** A small count after a tab's name, as the canvas draws it (`Rules 42`). */
counts?: Partial<Record<string, number>>
}) {
return (
<nav className={styles.bar} aria-label={label}>
@@ -71,6 +74,7 @@ export function SubTabs({
}}
>
{t}
{counts?.[t] != null && <em className={styles.count}>{counts[t]}</em>}
{marks?.[t] != null && (
<>
<span className={text.sr}>, {marks[t]}</span>