Match the Technitium canvas: palette, shell, KPI strips, framed tables
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:
1 parent
ea98a33b25
commit
87281a87d3
53 files changed
+1011
-487
No files matched your search
@@ -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'; }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user