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 <noreply@anthropic.com>
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

+2 -2
View File
@@ -358,7 +358,7 @@ export function BlockLists({
if (viewNeed != null) {
return (
<>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<Locked title="Block Lists" need={viewNeed} />
</>
)
@@ -571,7 +571,7 @@ export function BlockLists({
return (
<>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={shared.stack}>
+4
View File
@@ -82,3 +82,7 @@ second frame, with rounded corners showing under the panel's header. Written und
.bad[aria-invalid],
.bad[aria-invalid]:focus { border-color: var(--dan); }
.invalid { color: var(--dan); font-size: var(--t-2); margin-top: var(--s-4); }
/* The canvas's state strip above the tabs. */
.banner { display:flex; flex-wrap:wrap; align-items:center; gap: var(--s-7); padding: var(--s-7) var(--s-9); margin-bottom: var(--s-9); border:1px solid var(--line); border-radius: var(--r-3); background: color-mix(in oklch, var(--pan2) 50%, transparent); font-size: 13px; color: var(--mute); }
.bannerAct { margin-left: auto; }
+68
View File
@@ -1,10 +1,17 @@
import { useEffect, useState } from 'react'
import { getSettings } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Tag } from '../../ui/Tag'
import { missing } from './permissions'
import { untilText, useBlockingState } from './blocking-state'
import { SubTabs } from '../../ui/SubTabs'
import { ruleSearch } from './rules-model'
import { Overview } from './Overview'
import { Rules } from './Rules'
import { BlockLists } from './BlockLists'
import type { Permissions } from './permissions'
import shared from './Blocking.module.css'
/*
The Blocking section, OURS: it replaces Allowed and Blocked with three tabs,
@@ -27,6 +34,61 @@ function dropRule(): void {
}
}
/* What the tabs count: the rules (blocked and allowed zones) and the lists. Each is read
once and only when the session may read it; a failed read leaves the tab without one. */
function useCounts(token: string | null, permissions: Permissions) {
const [counts, setCounts] = useState<{ Rules?: number; Lists?: number }>({})
useEffect(() => {
let alive = true
if (missing(permissions, 'Dashboard.canView') == null) {
void getDashboardStats(token).then((o) => {
if (alive && o.kind === 'ok') {
const s = o.data.stats
setCounts((c) => ({ ...c, Rules: s.blockedZones + s.allowedZones }))
}
})
}
if (missing(permissions, 'Settings.canView') == null) {
void getSettings(token).then((s) => {
if (alive && s != null) setCounts((c) => ({ ...c, Lists: s.blockListUrls?.length ?? 0 }))
})
}
return () => {
alive = false
}
// The permission object is read once: the counts are a hint, not a live figure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
return counts
}
/* The state strip of the canvas, above the tabs. Overview carries the full status block
itself, so the strip is for the other two. */
function Banner({ token, permissions }: { token: string | null; permissions: Permissions }) {
const { state } = useBlockingState(token, permissions)
if (state == null || state === 'failed') return null
const canModify = missing(permissions, 'Settings.canModify') == null
return (
<div className={shared.banner} role="status">
{state.kind === 'on' && <Tag tone="ok">Enabled</Tag>}
{state.kind === 'paused' && <Tag tone="acc">{untilText(state.till)}</Tag>}
{state.kind === 'off' && <Tag>Disabled</Tag>}
<span>
{state.kind === 'on'
? 'Blocking is applied to every query'
: state.kind === 'paused'
? 'Blocking is paused and resumes automatically'
: 'Nothing is being blocked'}
</span>
{canModify && (
<span className={shared.bannerAct}>
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} />
</span>
)}
</div>
)
}
export function Blocking({
token,
sub,
@@ -69,7 +131,11 @@ export function Blocking({
const [listsDirty, setListsDirty] = useState(false)
const counts = useCounts(token, permissions)
const tabs = (
<>
{active !== 'Overview' && <Banner token={token} permissions={permissions} />}
<SubTabs
label="Blocking sections"
section="blocking"
@@ -77,7 +143,9 @@ export function Blocking({
active={active}
onChoose={onSubChange}
marks={listsDirty ? { Lists: 'unsaved changes' } : undefined}
counts={counts}
/>
</>
)
if (active === 'Rules') {
+1 -2
View File
@@ -330,8 +330,7 @@ export function Overview({
</InChrome>
<SectionHeader
section="Blocking"
title="Overview"
title="Blocking"
subtitle={OVERVIEW_SUBTITLE}
tabs={tabs}
actions={
+1 -1
View File
@@ -375,7 +375,7 @@ export function Rules({
return (
<>
<SectionHeader section="Blocking" title="Rules" subtitle={RULES_SUBTITLE} tabs={tabs} />
<SectionHeader title="Blocking" subtitle={RULES_SUBTITLE} tabs={tabs} />
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={shared.stack}>