App shell as the canvas draws it, full lazy Cache tree, styles aligned with the parent projects

Shell: collapsible rail, DNS/Server groups with pins, always-on header with
breadcrumbs and server status, Ctrl/Cmd+K command palette.
Cache: left column is a lazily expanded tree of every zone, right column the
selected node's records; both columns are ReUI Frames.
Styles: dark variant follows data-theme (field and outline fills), 32 px selects,
36 px segmented strip, 40 px caption table heads, 56 px rows, 20/24/32 page padding.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 09:35:41 +07:00
1 parent 44b6dc246c
commit f4513533c4
22 files changed
+509 -534

No files matched your search

+8
View File
@@ -79,3 +79,11 @@
| Table | Таблица строится на примитивах ReUI (`TableHeader`, `TableBody`, `TableFooter` и вид DataGrid). Перевод на TanStack `DataGrid` не сделан: строки и ячейки 23 таблиц несут свои атрибуты (классы строк, `colSpan`, `aria-sort` в `<th>`), а DataGrid отдаёт `<tr>`/`<td>` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. |
Сборка, `tsc`, `lint:colours`, `oxlint` проходят. Тесты (`npx vitest run --maxWorkers=3`): 28 падений из 2076, все они падали и до миграции (локаль Windows и отсутствие коммита).
## App shell as the canvas draws it
`src/app/Shell.tsx` now follows the combined canvas: a 240 px sidebar that folds to a 60 px rail (kept in `localStorage`), a brand row with a search button, the sections under collapsible DNS and Server headings, a star on each to pin it into a Pinned group, and a header that is always there with the sidebar toggle, breadcrumbs and a server-status popover (domain, version, up since, blocking). Ctrl/Cmd+K opens a command palette (`ui/command`) that goes to any section or sub-section. Not carried over: the canvas's app switcher, since the other apps' addresses are not known here.
## Styles aligned with the parent projects
Measured against `reui.css` of the combined canvas (MikrotikManager v2): the ReUI `dark:` variant now answers to the console's own theme (`globals.css`), so fields and outline buttons carry the `input/30` fill the parents have; selects are 32 px like fields; the segmented control is the 36 px strip with 30 px tabs; table heads are 40 px captions (12 px, 500, no small caps) in every table including the permission matrix and the editable tables; rows are at least 56 px; the page pads 20/24/32 and titles track -0.01em. Cache's two columns are ReUI Frames with the full lazy tree on the left.
+74 -2
View File
@@ -50,12 +50,12 @@ panel, which had 377 px free (42 % of its height). On narrow screens it is
needed, because the side panel is a closed drawer and its trigger has to be
outside it.
*/
.rtop { display:none; }
/* At the foot of the side panel, hence `margin-top:auto`: that is what pushes it
down however short the list of sections is. */
.footSide { margin-top:auto; padding: var(--s-8) var(--s-9); border-top:1px solid var(--line); display:flex; flex-direction:column; gap: var(--s-4); }
/* It opens upwards: it sits at the foot of the window, not under a header. */
.body { padding: var(--s-11) var(--s-11) var(--s-11); min-width:0; flex:1; }
.body { padding: 20px var(--s-11) 32px; min-width:0; flex:1; }
/* A screen that reads a file (View Logs) is as tall as the window and not as its
content: it marks its root and gets a flex column to grow into, down to the
footer. No other screen is touched. */
@@ -172,3 +172,75 @@ seven of eight screens would gain a blank gap.
*/
.chromeSlot { display: flex; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
.chromeSlot:empty { display: none; }
/*
The canvas's shell. The sidebar is a collapsible rail (240 px, 60 with the labels
gone), its sections sit under collapsible group headings and can be pinned with a
star, and the header is always there: toggle, breadcrumbs and the server's status.
*/
.shell { grid-template-columns: 240px 1fr; transition: grid-template-columns 0.18s ease; }
.shell[data-rail='true'] { grid-template-columns: 60px 1fr; }
.sbtag { margin-left: auto; font: 400 var(--t-2)/1 var(--font-mono); color: var(--mute); }
.sbsearch {
display: flex; align-items: center; gap: var(--s-6);
margin: var(--s-7) var(--s-6) 0; height: 32px; padding: 0 var(--s-7);
border: 1px solid var(--line); border-radius: var(--r-2); background: var(--pan2);
color: var(--mute); font: 400 var(--t-3)/1 var(--font-ui); cursor: pointer;
}
.sbsearch:hover { color: var(--ink); }
.sbsearch kbd { margin-left: auto; font: 400 var(--t-2)/1 var(--font-mono); }
.grph {
display: flex; align-items: center; justify-content: space-between; width: 100%;
padding: var(--s-4) var(--s-5); background: none; border: 0; cursor: pointer;
font: 500 var(--t-2)/1.3 var(--font-ui); letter-spacing: .04em; text-transform: uppercase; color: var(--mute);
}
.caret { display: inline-flex; transition: transform var(--t-fast); }
.caret[data-shut='true'] { transform: rotate(-90deg); }
.nrow { position: relative; display: flex; align-items: center; }
.star {
position: absolute; right: var(--s-3); width: 24px; height: 24px; padding: 0;
background: none; border: 0; border-radius: var(--r-2); color: var(--mute); cursor: pointer;
opacity: 0; transition: opacity var(--t-fast);
}
.nrow:hover .star, .star:focus-visible, .star[data-on='true'] { opacity: 1; }
.star[data-on='true'] { color: var(--acc); }
.rtop {
display: flex; align-items: center; gap: var(--s-7);
padding: 0 var(--s-9); height: var(--top-bar);
border-bottom: 1px solid var(--line); background: var(--pan);
position: sticky; top: 0; z-index: var(--layer-sticky);
}
.shell { --stuck-top: var(--top-bar); }
.hamburger {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; margin: 0;
background: none; border: 1px solid var(--line); border-radius: var(--r-2);
color: var(--ink); cursor: pointer;
}
.crumbs { display: flex; align-items: center; gap: var(--s-6); min-width: 0; font: 400 var(--t-4)/1 var(--font-ui); color: var(--mute); }
.crumbs span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.crumbs span + span::before { content: '/'; margin-right: var(--s-6); color: var(--line2); }
.crumbs span[data-last='true'] { color: var(--ink); font-weight: 500; }
.status {
margin-left: auto; display: inline-flex; align-items: center; gap: var(--s-5);
height: 32px; padding: 0 var(--s-7); background: none; border: 1px solid var(--line);
border-radius: var(--r-2); color: var(--ink); font: 400 var(--t-3)/1 var(--font-mono); cursor: pointer;
}
.pdot { width: 8px; height: 8px; border-radius: 99px; background: var(--ok, currentColor); }
.pdot[data-tone='warn'], .pdot[data-tone='warning'] { background: var(--warn, currentColor); }
.pdot[data-tone='bad'], .pdot[data-tone='danger'] { background: var(--bad, currentColor); }
.pop dl { display: grid; grid-template-columns: auto 1fr; gap: var(--s-4) var(--s-8); margin: 0; font-size: var(--t-3); }
.pop dt { color: var(--mute); }
.pop dd { margin: 0; text-align: right; font-family: var(--font-mono); }
.shell[data-rail='true'] .lbl, .shell[data-rail='true'] .grph, .shell[data-rail='true'] .star { display: none; }
.shell[data-rail='true'] .s, .shell[data-rail='true'] .sbsearch, .shell[data-rail='true'] .sbrand { justify-content: center; }
.shell[data-rail='true'] .footSide { display: none; }
@media (max-width: 900px) {
.shell, .shell[data-rail='true'] { grid-template-columns: 1fr; }
.shell[data-rail='true'] .lbl, .shell[data-rail='true'] .grph, .shell[data-rail='true'] .footSide { display: revert; }
.shell[data-rail='true'] .s, .shell[data-rail='true'] .sbsearch, .shell[data-rail='true'] .sbrand { justify-content: flex-start; }
.status .lbl { display: none; }
}
+178 -50
View File
@@ -29,6 +29,8 @@ import styles from './Shell.module.css'
import { Icon, type IconName } from '../ui/Icon'
import { publicUrl } from './base'
import { Menu } from '../ui/Menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { ThisServerName } from '../ui/ClusterNodeSelect'
import { FooterLinks } from '../ui/FooterLinks'
import { Versions } from './Versions'
@@ -54,12 +56,29 @@ The sidebar's three groups. It is not a new taxonomy: it is upstream's own order
with a gap where the kind of task already changed. What you operate daily, what you
configure and what you consult.
*/
const GROUPS: string[][] = [
['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'],
['settings', 'dhcp', 'admin'],
['logs', 'about'],
const GROUPS: { name: string; ids: string[] }[] = [
{ name: 'DNS', ids: ['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'] },
{ name: 'Server', ids: ['settings', 'dhcp', 'admin', 'logs', 'about'] },
]
/* The sidebar's own preferences, per browser, as the canvas keeps them: the rail
and the pinned sections. A browser that refuses storage starts from the defaults. */
function stored<T>(key: string, fallback: T): T {
try {
const v = localStorage.getItem(key)
return v == null ? fallback : (JSON.parse(v) as T)
} catch {
return fallback
}
}
function keep(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* nothing to do */
}
}
const ICONS: Record<string, IconName> = {
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
@@ -120,6 +139,33 @@ export function Shell({
})
const [active, setActive] = useState(() => initialRoute?.section ?? sections[0]?.id ?? 'about')
const [drawer, setDrawer] = useState(false)
const [rail, setRail] = useState(() => stored<boolean>('shell.rail', false))
const [shut, setShut] = useState<string[]>([])
const [pins, setPins] = useState<string[]>(() => stored<string[]>('shell.pins', []))
const [palette, setPalette] = useState(false)
function toggleRail() {
setRail((v) => {
keep('shell.rail', !v)
return !v
})
}
function togglePin(id: string) {
setPins((v) => {
const next = v.includes(id) ? v.filter((x) => x !== id) : [...v, id]
keep('shell.pins', next)
return next
})
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPalette((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange ? 'password' : null)
/* The factory password travels only with the opening the login caused; from the
user menu `showChangePasswordModal()` is called with nothing (main.js). */
@@ -299,12 +345,61 @@ export function Shell({
return () => window.removeEventListener('popstate', onChanged)
}, [sections])
const pinned = sections.filter((sec) => pins.includes(sec.id))
function go(id: string, to: string | null) {
setActive(id)
setSub(to)
setDrawer(false)
setPalette(false)
}
function navRow(sec: (typeof sections)[number], where: 'pin' | 'main') {
const here = sec.id === current?.id
const first = sec.subs?.[0] ?? null
const pin = pins.includes(sec.id)
return (
<div className={styles.nrow} key={`${where}-${sec.id}`}>
<a
className={styles.s}
href={toTrail({ section: sec.id, sub: first })}
data-active={here}
aria-current={here ? 'page' : undefined}
title={sec.id === 'blocking' ? blockingNow?.label : rail ? sec.label : undefined}
onClick={(e) => {
if (!plainClick(e)) return
e.preventDefault()
go(sec.id, first)
}}
>
<span className={styles.ico}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
</span>
<span className={styles.lbl}>{sec.label}</span>
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
</a>
<button
type="button"
className={styles.star}
data-on={pin}
aria-pressed={pin}
aria-label={`${pin ? 'Unpin' : 'Pin'} ${sec.label}`}
onClick={() => togglePin(sec.id)}
>
{pin ? '\u2605' : '\u2606'}
</button>
</div>
)
}
const crumbs = [info?.dnsServerDomain, current?.label, currentSub].filter((x): x is string => !!x)
return (
<HandoffContext.Provider value={handoff}>
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<SlotProvider>
{(slot) => (
<div className={styles.shell}>
<div className={styles.shell} data-rail={rail}>
{drawer && (
<button
type="button"
@@ -316,8 +411,15 @@ export function Shell({
<aside className={styles.side} data-open={drawer}>
<div className={styles.sbrand}>
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} />
<span className={styles.lbl}>Technitium</span>
<span className={`${styles.lbl} ${styles.sbtag}`}>console</span>
</div>
<button type="button" className={styles.sbsearch} onClick={() => setPalette(true)} aria-label="Search">
<Icon name="menu" size={14} />
<span className={styles.lbl}>Search</span>
<kbd className={styles.lbl}>{'\u2318K'}</kbd>
</button>
<nav className={styles.slist} role="navigation" aria-label="Sections">
{/*
This is a navigation menu, and until now it claimed to be a `tablist`.
@@ -338,47 +440,31 @@ export function Shell({
`aria-current="page"` is reserved for ONE thing: the page you are on,
which is the sub-section when there is one.
*/}
{GROUPS.map((group, g) => (
<div className={styles.group} key={g}>
{sections.filter((sec) => group.includes(sec.id)).map((sec) => {
const active = sec.id === current?.id
const first = sec.subs?.[0] ?? null
return (
<div key={sec.id}>
<a
className={styles.s}
href={toTrail({ section: sec.id, sub: first })}
data-active={active}
aria-current={active ? 'page' : undefined}
title={sec.id === 'blocking' ? blockingNow?.label : undefined}
onClick={(e) => {
if (!plainClick(e)) return
e.preventDefault()
setActive(sec.id)
setSub(first)
setDrawer(false)
}}
>
<span className={styles.ico}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
</span>
{sec.label}
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
</a>
{/*
The sub-items are NOT here any more. The design puts a
section's sub-navigation under its title, for a reason:
nested here, nine panes run out of room the moment the rail
drops to 60 px. What the sidebar gets back is
its own shape: twelve entries, always the same twelve, instead
of fourteen, eighteen or twenty-one depending on where you are.
See `ui/SubTabs`.
*/}
</div>
)
})}
{pinned.length > 0 && (
<div className={styles.group}>
<div className={styles.grph}>Pinned</div>
{pinned.map((sec) => navRow(sec, 'pin'))}
</div>
))}
)}
{GROUPS.map((group) => {
const items = sections.filter((sec) => group.ids.includes(sec.id))
if (items.length === 0) return null
const closed = shut.includes(group.name)
return (
<div className={styles.group} key={group.name}>
<button
type="button"
className={styles.grph}
aria-expanded={!closed}
onClick={() => setShut((v) => (closed ? v.filter((x) => x !== group.name) : [...v, group.name]))}
>
{group.name}
<span className={styles.caret} data-shut={closed}><Icon name="chevronDown" size={12} /></span>
</button>
{!closed && items.map((sec) => navRow(sec, 'main'))}
</div>
)
})}
</nav>
{/* The server and the account, at the foot. When narrow you reach it via the drawer. */}
@@ -475,13 +561,29 @@ export function Shell({
className={styles.hamburger}
aria-label="Menu"
aria-expanded={drawer}
onClick={() => setDrawer((v) => !v)}
onClick={() => (window.matchMedia('(max-width: 900px)').matches ? setDrawer((v) => !v) : toggleRail())}
>
<Icon name="menu" size={18} />
</button>
<span className={styles.topBrand}>
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
</span>
<div className={styles.crumbs} role="group" aria-label="Breadcrumb">
{crumbs.map((c, i) => (
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
))}
</div>
<Popover>
<PopoverTrigger className={styles.status} aria-label="Server status">
<i className={styles.pdot} data-tone={blockingNow?.tone ?? 'ok'} />
<span className={styles.lbl}>{info?.dnsServerDomain ?? 'Server'}</span>
</PopoverTrigger>
<PopoverContent align="end" className={styles.pop}>
<dl>
<dt>Server</dt><dd>{info?.dnsServerDomain ?? '-'}</dd>
<dt>Version</dt><dd>{info?.version ?? '-'}</dd>
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
</dl>
</PopoverContent>
</Popover>
</header>
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its
@@ -606,6 +708,32 @@ export function Shell({
</footer>
</div>
<CommandDialog open={palette} onOpenChange={setPalette} title="Search" description="Go to a section or an account action">
<Command>
<CommandInput placeholder="Go to..." />
<CommandList>
<CommandEmpty>Nothing found.</CommandEmpty>
<CommandGroup heading="Sections">
{sections.flatMap((sec) => [
<CommandItem key={sec.id} value={sec.label} onSelect={() => go(sec.id, sec.subs?.[0] ?? null)}>
<Icon name={ICONS[sec.id] ?? 'about'} size={16} /> {sec.label}
</CommandItem>,
...(sec.subs ?? []).map((sb) => (
<CommandItem key={`${sec.id}/${sb}`} value={`${sec.label} ${sb}`} onSelect={() => go(sec.id, sb)}>
<Icon name="chevronRight" size={14} /> {sec.label} / {sb}
</CommandItem>
)),
])}
</CommandGroup>
<CommandGroup heading="Account">
<CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>
<CommandItem value="Change Theme" onSelect={() => { setPalette(false); open('theme') }}>Change Theme</CommandItem>
<CommandItem value="Logout" onSelect={() => { setPalette(false); onLogout() }}>Logout</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
<MyProfile
open={modal === 'profile'}
onOpenChange={(o) => setModal(o ? 'profile' : null)}
+1 -1
View File
@@ -34,7 +34,7 @@ the button that acts on it.
.kpiPaired { display: grid; grid-template-columns: repeat(2, max-content); column-gap: var(--s-9); }
.kpiRow { display: contents; }
.kpiPaired > .kpiLabel { grid-column: 1 / -1; }
.kpiLabel { font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); text-transform: uppercase; color: var(--mute); }
.kpiLabel { font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); color: var(--mute); }
/*
A table inside a panel: the panel is already its frame. ui/Table draws a border,
+1 -1
View File
@@ -70,7 +70,7 @@ the other lacks.
/* The ring, with the share written in its hole. */
.ring { position: relative; }
.ringValue { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; font-family: var(--font-mono); font-size: var(--t-6); font-weight: 600; pointer-events: none; }
.ringLabel { display: block; font-family: var(--font-ui); font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); text-transform: uppercase; color: var(--mute); }
.ringLabel { display: block; font-family: var(--font-ui); font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); color: var(--mute); }
/*
The status block and "Check, block or allow a domain" share a row, and the grid
+1 -1
View File
@@ -162,7 +162,7 @@ The three breakdowns: each a title, a stacked bar and its legend (`Breakdown.tsx
.breakdown { padding-top: var(--s-8); }
.parts { display:flex; flex-direction:column; gap: var(--s-8); padding: 0 var(--s-8) var(--s-7); }
.part { min-width: 0; }
.partTitle { margin: 0 0 var(--s-3); font-size: var(--t-1); font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: var(--mute); }
.partTitle { margin: 0 0 var(--s-3); font-size: var(--t-1); font-weight: 600; color: var(--mute); }
.stack { display:flex; gap: var(--s-1); height: var(--s-6); border-radius: var(--r-1); overflow:hidden; }
.segment { display:block; flex-basis: 0; min-width: var(--s-1); height: 100%; padding: 0; border: 0; border-radius: 0; }
button.segment { cursor: pointer; }
-80
View File
@@ -1,80 +0,0 @@
/*
Cache's one panel (2026-10-16): the crumbs go up, the chips go down, and both
send exactly what the tree sent. The tree stays the navigation of Allowed and
Blocked, which `Lists.tree.test.tsx` keeps covering.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Cache } from './Lists'
import * as api from '../../api/zonelists'
import type { ListNode } from '../../api/zonelists'
afterEach(() => vi.restoreAllMocks())
function node(p: Partial<ListNode> = {}): ListNode {
return { domain: '', zones: [], records: [], ...p }
}
function answering(n: ListNode) {
return vi.spyOn(api, 'listNode').mockResolvedValue({ kind: 'ok', data: n })
}
describe('Cache moves through one band', () => {
it('has no tree any more', async () => {
answering(node({ zones: ['com'] }))
render(<Cache token="t" />)
await screen.findByRole('button', { name: 'com' })
expect(screen.queryByLabelText('Domain tree')).not.toBeInTheDocument()
})
it('an ancestor in the crumbs goes up with direction=up, as the [up] link does', async () => {
const spy = answering(node({ domain: 'www.google.com' }))
render(<Cache token="t" />)
const crumbs = await screen.findByRole('navigation', { name: 'Node' })
await userEvent.click(within(crumbs).getByRole('button', { name: 'google.com' }))
const up = spy.mock.calls.find((c) => c[2] === 'google.com')
expect(up?.[3]).toBe('up')
})
it('<ROOT> in the crumbs goes back to the root, also upwards', async () => {
const spy = answering(node({ domain: 'com' }))
render(<Cache token="t" />)
const crumbs = await screen.findByRole('navigation', { name: 'Node' })
await userEvent.click(within(crumbs).getByRole('button', { name: '<ROOT>' }))
const calls = spy.mock.calls.filter((c) => c[2] === '')
expect(calls[calls.length - 1][3]).toBe('up')
})
it('the open node is the last crumb and is not a button', async () => {
answering(node({ domain: 'google.com' }))
render(<Cache token="t" />)
const crumbs = await screen.findByRole('navigation', { name: 'Node' })
expect(within(crumbs).queryByRole('button', { name: 'google.com' })).toBeNull()
expect(crumbs.querySelector('[aria-current="location"]')).toHaveTextContent('google.com')
})
it('a chip opens its child with the full name and no direction', async () => {
const spy = answering(node({ domain: 'google.com', zones: ['www.google.com', 'l.google.com'] }))
render(<Cache token="t" />)
expect(await screen.findByText('2 zones')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'www.google.com' }))
const down = spy.mock.calls.find((c) => c[2] === 'www.google.com')
expect(down?.[3]).toBeUndefined()
})
it('a leaf draws no zone band', async () => {
answering(node({ domain: 'www.google.com' }))
render(<Cache token="t" />)
await screen.findByText(/0 records at/)
expect(screen.queryByText('0 zones')).not.toBeInTheDocument()
})
it('the empty text no longer points at a tree', async () => {
answering(node({ domain: 'google.com', zones: ['www.google.com'] }))
render(<Cache token="t" />)
expect(
await screen.findByText('This node only contains sub-domains. Open one to see its records.'),
).toBeInTheDocument()
})
})
-118
View File
@@ -1,118 +0,0 @@
import { ancestors } from './Tree'
import { DomainName } from '../../ui/DomainName'
import { Icon } from '../../ui/Icon'
import styles from './Lists.module.css'
/*
How Cache moves through its tree, in one band instead of three places.
Until 2026-10-16 Cache had the tree of Allowed and Blocked: a 328 px column with
the field, `<ROOT>`, the chain of ancestors and the children, and beside it a
second, non-clickable path over the records. Two drawings of the same chain, and
the one you could click was the one furthest from what you were reading. At
`com` the tree was 35 rows tall and the four records sat 1,371 px down the page.
Upstream draws a flat list (other-zones.js:120-137): `[refresh]`, `[up]` and the
children. The same three moves are here, each where it reads:
· up: the crumbs. Every ancestor is upstream's `[up]` taken one or more times,
so it is sent with `direction=up`, exactly as the tree sent it;
· down: the children, as chips under the node;
· refresh: the `Refresh` button beside the crumbs, which reads the node again.
Nothing is sent that the tree did not send, and the tree is still the
navigation of Allowed and Blocked inside Rules, which this does not touch.
*/
/** Where you are, and the way back up. The open node is the last crumb and is
* not a button: there is nowhere to go from it but where you already are. */
export function Crumbs({
domain,
label,
onNavigate,
}: {
/** The open node. Empty string is the root. */
domain: string
/** What to call it: the IDN form when there is one. */
label: string
onNavigate: (domain: string, up?: boolean) => void
}) {
const chain = ancestors(domain)
const atRoot = domain === ''
return (
<nav className={styles.crumbs} aria-label="Node">
{/* `Node` is the word the path already used: no new one. */}
<span className={styles.pathLabel}>Node</span>
<ol className={styles.crumbList}>
<li>
{atRoot ? (
<span className={styles.crumbHere} aria-current="location">&lt;ROOT&gt;</span>
) : (
<button type="button" className={styles.crumb} onClick={() => onNavigate('', true)}>
&lt;ROOT&gt;
</button>
)}
</li>
{chain.map((parent) => (
<li key={parent}>
<span className={styles.pathSep} aria-hidden="true">&rsaquo;</span>
<button type="button" className={styles.crumb} onClick={() => onNavigate(parent, true)}>
<DomainName name={parent} />
</button>
</li>
))}
{!atRoot && (
<li>
<span className={styles.pathSep} aria-hidden="true">&rsaquo;</span>
<span className={styles.crumbHere} aria-current="location">
<DomainName name={label} />
</span>
</li>
)}
</ol>
</nav>
)
}
/*
The children of the open node, as chips that open it.
The server sends them as FULL names (`clients4.google.com` under `google.com`),
and the full name is what is sent back, as the tree did. What changes is the
weight: the part that is new at this level is drawn in ink and the parent's
suffix, the same on every chip, in the faint grey, so a row of 35 children reads
as 35 different words and not as 35 copies of `.com`. The accessible name, the
copied text and find-in-page still get the whole name.
*/
export function Children({
domain,
zones,
onNavigate,
}: {
domain: string
zones: string[]
onNavigate: (domain: string) => void
}) {
const suffix = domain === '' ? '' : `.${domain}`
return (
<ul className={styles.chips}>
{zones.map((z) => {
const own = suffix !== '' && z.endsWith(suffix) ? z.slice(0, -suffix.length) : z
const rest = own === z ? '' : suffix
return (
<li key={z}>
<button type="button" className={styles.chipNode} onClick={() => onNavigate(z)}>
<span className={styles.chipName}>
<DomainName name={own} />
{rest !== '' && <span className={styles.chipRest}><DomainName name={rest} /></span>}
</span>
<Icon name="chevronRight" size={12} className={styles.chipCh} />
</button>
</li>
)
})}
</ul>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { listNode } from '../../api/zonelists'
import { Icon } from '../../ui/Icon'
import { Loading } from '../../ui/Empty'
import styles from './Lists.module.css'
import { ancestors } from './Tree'
/*
The cache as a whole tree, the way the screen reads best: every zone opens in
place, and what the selected node holds is on the right.
The server answers one level at a time (`cache/list` with a domain), so the tree
asks for a node's children when it is opened for the first time and keeps them
until `epoch` changes (a flush, a delete, a refresh or another cluster node). The
selected domain is always open down to itself, so Browse and the records' own
links land on a visible row.
*/
type Level = string[] | 'loading' | 'failed'
/** The name under its parent: `www` under `google.com`, the whole name under the root. */
function short(domain: string, parent: string): string {
return parent !== '' && domain.endsWith(`.${parent}`) ? domain.slice(0, -(parent.length + 1)) : domain
}
export function CacheTree({
token,
clusterNode,
selected,
current,
epoch,
onSelect,
}: {
token: string | null
clusterNode: string
selected: string
/** The selected node as the screen has already read it: its children are not asked for twice. */
current: { domain: string; zones: string[] } | null
/** Changes when what the tree has read is out of date. */
epoch: number
onSelect: (domain: string) => void
}) {
const [levels, setLevels] = useState<Record<string, Level>>({})
const [open, setOpen] = useState<Set<string>>(() => new Set(['']))
const seen = useRef<Record<string, Level>>({})
const lastEpoch = useRef(epoch)
const own = useRef(current)
own.current = current
const levelsNow: Record<string, Level> = current == null ? levels : { ...levels, [current.domain]: current.zones }
const read = useCallback(
async (domain: string) => {
seen.current = { ...seen.current, [domain]: 'loading' }
setLevels(seen.current)
const outcome = await listNode('cache', token, domain, undefined, clusterNode)
seen.current = { ...seen.current, [domain]: outcome.kind === 'ok' ? outcome.data.zones : 'failed' }
setLevels(seen.current)
},
[token, clusterNode],
)
/* A stale tree is forgotten and the open nodes are read again; the selected
node's chain is opened and read if it has not been. */
useEffect(() => {
const fresh = lastEpoch.current !== epoch
lastEpoch.current = epoch
if (fresh) seen.current = {}
if (own.current != null) seen.current = { ...seen.current, [own.current.domain]: own.current.zones }
setLevels(seen.current)
const chain = selected === '' ? [''] : ['', ...ancestors(selected), selected]
const wanted = new Set([...(fresh ? open : []), ...chain])
setOpen((v) => new Set([...(fresh ? v : v), ...chain]))
for (const d of wanted) if (seen.current[d] == null && d !== own.current?.domain) void read(d)
// `open` is read only to refresh what was already open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [epoch, selected, read])
function toggle(domain: string) {
const opening = !open.has(domain)
setOpen((v) => {
const next = new Set(v)
if (opening) next.add(domain)
else next.delete(domain)
return next
})
if (opening && seen.current[domain] == null && domain !== own.current?.domain) void read(domain)
}
function rows(parent: string) {
const level = levelsNow[parent]
if (level === 'loading' || level == null) return <div className={styles.lvl}><Loading announce={false} /></div>
if (level === 'failed') {
return (
<div className={styles.lvl}>
<button type="button" className={styles.node} onClick={() => void read(parent)}>
<span className={styles.label}>Could not read this node. Retry</span>
</button>
</div>
)
}
if (level.length === 0) return null
return (
<div className={styles.lvl} role="group">
{level.map((z) => {
const isOpen = open.has(z)
return (
<div key={z} role="treeitem" aria-expanded={isOpen} aria-selected={z === selected}>
<div className={styles.treeRow}>
<button
type="button"
className={styles.treeToggle}
aria-label={`${isOpen ? 'Collapse' : 'Expand'} ${z}`}
onClick={() => toggle(z)}
>
<Icon name={isOpen ? 'chevronDown' : 'chevronRight'} size={12} />
</button>
<button
type="button"
className={styles.node}
aria-current={z === selected ? 'true' : undefined}
title={z}
onClick={() => onSelect(z)}
>
<span className={styles.label}>{short(z, parent)}</span>
</button>
</div>
{isOpen && rows(z)}
</div>
)
})}
</div>
)
}
return (
<div role="tree" aria-label="Cached zones">
<div className={styles.treeRow}>
<button type="button" className={styles.treeToggle} aria-label={`${open.has('') ? 'Collapse' : 'Expand'} <ROOT>`} onClick={() => toggle('')}>
<Icon name={open.has('') ? 'chevronDown' : 'chevronRight'} size={12} />
</button>
<button type="button" className={styles.node} aria-current={selected === '' ? 'true' : undefined} onClick={() => onSelect('')}>
<span className={styles.label}>&lt;ROOT&gt;</span>
</button>
</div>
{open.has('') && rows('')}
</div>
)
}
+15 -143
View File
@@ -26,7 +26,7 @@ inside itself rather than growing the page.
align-items: stretch;
}
.split > * { min-width: 0; display: flex; flex-direction: column; }
.column { composes: panel from '../../ui/Panel.module.css'; flex: 1; display: flex; flex-direction: column; min-height: 0; }
.column { flex: 1; min-height: 0; }
.body { flex: 1; min-height: 0; overflow: auto; padding: var(--s-8); }
.bodyFlush { flex: 1; min-height: 0; overflow: auto; }
@@ -57,15 +57,14 @@ seven.
.cache { --kind: var(--ch-cache); }
.allowed { --kind: var(--ch-ok); }
.blocked { --kind: var(--ch-block); }
.records > .column { border-top: 3px solid var(--kind); }
.identity {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
text-align: left;
gap: var(--s-6);
padding: var(--s-6) var(--s-9);
border-bottom: 1px solid var(--line);
background: var(--pan);
}
.emblem {
width: 26px;
@@ -105,7 +104,7 @@ second clickable path would be behaviour upstream does not have. So it is a
}
.pathLabel {
font: 650 var(--t-1) var(--font-ui);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
margin-right: var(--s-1);
@@ -115,143 +114,6 @@ second clickable path would be behaviour upstream does not have. So it is a
.pathSep { color: var(--faint); opacity: 0.6; }
.pathHere { color: var(--ink); font-weight: 650; }
/*
CACHE, ONE PANEL (2026-10-16). Cache leaves the two-column split: the tree
column becomes two bands inside the records panel (the crumbs that go up and the
chips that go down, `CacheNav.tsx`), so the screen has one place to look and
the records get the full width. The identity colour stays as the panel's top
rule, the same `--ch-cache` as before; the band that repeated "Cache" under the
section title is gone, and its sentence is the header's subtitle.
*/
.cachePanel {
composes: panel from '../../ui/Panel.module.css';
border-top: 3px solid var(--ch-cache);
}
/* The field takes the width; Browse stays its own size beside it. */
.search {
display: flex;
gap: var(--s-5);
align-items: flex-end;
padding: var(--s-8) var(--s-9);
border-bottom: 1px solid var(--line);
}
.search > div { flex: 1; min-width: 0; }
/* The node's line: where you are on the left, what you can do to it on the right. */
.where {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--s-5) var(--s-8);
padding: var(--s-5) var(--s-9);
border-bottom: 1px solid var(--line2);
background: var(--pan2);
}
.crumbs {
display: flex;
align-items: center;
gap: var(--s-3);
min-width: 0;
font: var(--t-4) var(--font-mono);
}
.crumbList {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1) var(--s-3);
margin: 0;
padding: 0;
list-style: none;
min-width: 0;
}
.crumbList > li { display: flex; align-items: center; gap: var(--s-3); min-width: 0; }
/*
An ancestor is a link-weight button: muted until pointed at, never boxed. Boxing
it would make three ancestors look like three actions on the same footing as
Refresh and Delete beside them. `.hitArea` gives the 24 px target without
growing the line.
*/
.crumb {
composes: hitArea from '../../ui/interaction.module.css';
border: 0;
padding: 0;
background: transparent;
font: inherit;
color: var(--mute);
cursor: pointer;
text-align: left;
}
.crumb:hover { color: var(--acc-ink); text-decoration: underline; }
.crumbHere { color: var(--ink); font-weight: 650; }
/*
The children. A chip per name, wrapping, with the count in front as its label.
They scroll inside the band past five rows and a half: the showcase's `com` has
35 children and fits in five at 1440, but a production `com` can hold thousands,
and the records must not be pushed off the screen by the way down to them. The
half row is the signal: a band cut exactly between two rows of chips looks
complete, and the ones below it would never be found.
*/
.children {
display: flex;
align-items: flex-start;
gap: var(--s-5) var(--s-8);
padding: var(--s-6) var(--s-9);
border-bottom: 1px solid var(--line2);
}
.childrenCount {
flex: none;
padding-top: var(--s-2);
font-size: var(--t-3);
color: var(--faint);
white-space: nowrap;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
margin: 0;
padding: 0;
list-style: none;
min-width: 0;
/* Five and a half chips tall: each is its line (1.4em), its padding and its
two borders, and the gaps between them. */
font-size: var(--t-4);
line-height: 1.4;
max-height: calc(5.5 * (1.4em + 2 * var(--s-2) + var(--s-1)) + 5 * var(--s-3));
overflow: auto;
}
.chips > li { display: flex; min-width: 0; max-width: 100%; }
.chipNode {
display: inline-flex;
align-items: center;
gap: var(--s-1);
max-width: 100%;
padding: var(--s-2) var(--s-4) var(--s-2) var(--s-5);
border: 1px solid var(--line);
border-radius: var(--r-2);
background: var(--pan2);
font: inherit;
font-family: var(--font-mono);
color: var(--ink);
cursor: pointer;
text-align: left;
}
.chipNode:hover { border-color: var(--line-hover); background: var(--hover-strong); }
.chipNode:hover .chipCh { color: var(--acc-ink); }
.chipName { min-width: 0; }
.chipRest { color: var(--faint); }
.chipCh { flex: none; margin-left: var(--s-1); color: var(--faint); }
/* Below the rail the crumbs take their own line over the node's buttons, and
the count sits over its chips instead of beside them. */
@media (max-width: 560px) {
.children { flex-direction: column; gap: var(--s-3); }
.chips { max-height: calc(6.5 * (1.4em + 2 * var(--s-2) + var(--s-1)) + 6 * var(--s-3)); }
}
/* 4 · The tree itself, at the weight it deserves. */
.browse { display: flex; gap: var(--s-3); align-items: flex-end; padding: var(--s-7) var(--s-8); border-bottom: 1px solid var(--line); }
.browse > div { flex: 1; min-width: 0; }
@@ -358,3 +220,13 @@ and scrolls inside), so the records are not always below the fold.
.split { grid-template-columns: minmax(0, 1fr); gap: var(--s-8); }
.tree .body { max-height: 250px; }
}
/* The cache tree: a row is the node and, before it, the button that opens it. */
.treeRow { display: flex; align-items: center; gap: var(--s-1); }
.treeRow > .node { flex: 1; min-width: 0; }
.treeToggle {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px; padding: 0; border: 0; border-radius: var(--r-2);
background: transparent; color: var(--mute); cursor: pointer;
}
.treeToggle:hover { background: var(--hover-strong); color: var(--ink); }
+5 -5
View File
@@ -164,13 +164,15 @@ describe('Cache', () => {
const spy = vi.spyOn(api, 'flushCache').mockResolvedValue(OK)
const list = withNode(node({ domain: 'home.test', records: [REG_CACHE] }))
render(<Cache token="t" />)
await userEvent.click(await screen.findByRole('button', { name: 'Flush Cache' }))
await screen.findByRole('button', { name: 'Flush Cache' })
const before = list.mock.calls.filter((c) => c[2] === '').length
await userEvent.click(screen.getByRole('button', { name: 'Flush Cache' }))
await confirm('Flush Cache')
expect(spy).toHaveBeenCalled()
expect(
await screen.findByText('DNS Server cache was flushed successfully.'),
).toBeInTheDocument()
expect(list.mock.calls[list.mock.calls.length - 1][2]).toBe('')
expect(list.mock.calls.filter((c) => c[2] === '').length).toBeGreaterThan(before)
})
/* In cache the Delete button depends on the NODE, not on it having records
@@ -316,9 +318,7 @@ describe('cache: the cluster node', () => {
render(<Cache token="t" nodes={NODES} clusterInitialised />)
await screen.findByRole('button', { name: 'Delete' })
await pickB()
const last = list.mock.calls[list.mock.calls.length - 1]
expect(last[2]).toBe('home.test')
expect(last[4]).toBe('b.example')
expect(list.mock.calls.some((c) => c[2] === 'home.test' && c[4] === 'b.example')).toBe(true)
})
it('Flush and Delete go to the chosen node', async () => {
+2 -2
View File
@@ -88,7 +88,7 @@ describe('cache and policy are told apart', () => {
])('%#: the identity band names the screen and what it holds', async (list, embedded, title, sub) => {
withNode(node())
render(<Lists list={list} token="t" embedded={embedded} />)
expect(await screen.findByText(sub)).toBeInTheDocument()
expect((await screen.findAllByText(sub)).length).toBeGreaterThan(0)
expect(screen.getAllByText(title).length).toBeGreaterThan(0)
})
})
@@ -153,7 +153,7 @@ describe('loading is not emptiness, on either side', () => {
vi.spyOn(client, 'apiRequest').mockImplementation(() => pending() as never)
render(<Cache token="t" />)
await screen.findByRole('status')
expect(screen.getAllByText('Loading…')).toHaveLength(1)
expect(screen.getAllByRole('status')).toHaveLength(1)
})
})
+55 -112
View File
@@ -22,7 +22,8 @@ import { Empty, Loading } from '../../ui/Empty'
import { StaleData } from '../StaleData'
import { Tree } from './Tree'
import { Path } from './Path'
import { Crumbs, Children } from './CacheNav'
import { CacheTree } from './CacheTree'
import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame'
import { ResourceRecords } from './Records'
import styles from './Lists.module.css'
import { noticeFromFailure, type Notice } from '../../lib/notice'
@@ -280,6 +281,9 @@ export function Lists({
primary.current = primaryNodeName(nodes, clusterInitialised)
}, [nodes, clusterInitialised])
/* Bumped whenever what the cache tree has read is out of date. */
const [treeEpoch, setTreeEpoch] = useState(0)
const load = useCallback(
async (domain: string, up?: boolean, fromPrimary = false) => {
/* other-zones.js:269 and 434: after a change, Allowed and Blocked are read
@@ -374,7 +378,10 @@ export function Lists({
text: 'DNS Server cache was flushed successfully.',
},
// Upstream leaves the list at `<ROOT>` and hides the viewer.
() => load(''),
async () => {
setTreeEpoch((v) => v + 1)
await load('')
},
),
})
}
@@ -392,7 +399,10 @@ export function Lists({
title: 'Deleted!',
text: `Cached zone '${nodeTitle}' was deleted successfully.`,
},
() => load(parentDomain(nodeTitle) ?? '', true),
async () => {
setTreeEpoch((v) => v + 1)
await load(parentDomain(nodeTitle) ?? '', true)
},
),
})
}
@@ -447,6 +457,7 @@ export function Lists({
onChange={(n) => {
cacheNode.current = n
setClusterNode(n)
setTreeEpoch((v) => v + 1)
void load(domain)
}}
label="Cluster Node"
@@ -459,7 +470,6 @@ export function Lists({
{!embedded && (
<SectionHeader
title={TITLE[list]}
subtitle={isCache ? SUBTITLE.cache : undefined}
actions={
isCache ? (
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
@@ -474,96 +484,11 @@ export function Lists({
{stale && <StaleData since={lastGood} onRetry={() => void load(node?.domain ?? '')} />}
{isCache ? (
/*
Cache, one panel (2026-10-16). Top to bottom it is the order you use it in:
type a name, see where you are and go up, see what hangs below and go down,
read the records. The tree column is gone from this screen only; see
`CacheNav.tsx` for why each move is the one upstream makes.
Measured on the showcase. At `com`, 1440: the page was 1,371 px tall (the
35-row tree set it) and is 990, with the four records rows at 85 to 106
px instead of 140 to 160. At the root the page went from 3,307 to 2,125.
At 768 the table started at y=619, under the tree, and starts at 359; at
390 on `google.com` at 663 and now 496, with no sideways scroll.
*/
<section className={styles.cachePanel} aria-label="Cache">
<div className={styles.search}>
<Field label={fieldName ?? 'Domain'}>
{(id) => (
<Input
id={id}
mono
placeholder="example.com"
value={field}
onChange={(e) => setField(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') navigate(field)
}}
/>
)}
</Field>
<Button variant="primary" onClick={() => navigate(field)}>
Browse
</Button>
</div>
<div className={styles.where}>
<Crumbs domain={domain} label={nodeTitle} onNavigate={navigate} />
{/* Refresh and Delete act on the node, so they sit on the node's line.
Hidden while the first read is in flight, as the count bar was. */}
{!loading && (
<div className={styles.countActs}>
<Button size="sm" onClick={() => navigate(domain)}>
Refresh
</Button>
{mayDelete && (
<PermissionButton size="sm" variant="danger" disabled={busy} onClick={askDeleteCacheNode}>
Delete
</PermissionButton>
)}
</div>
)}
</div>
{loading ? (
<div className={styles.empty}><Loading /></div>
) : (
<>
{/* No chips, no band: a leaf saying `0 zones` on a line of its own
was a strip of nothing above the records. */}
{zones.length > 0 && (
<div className={styles.children}>
<span className={styles.childrenCount}>
{zones.length === 1 ? '1 zone' : `${zones.length} zones`}
</span>
<Children domain={domain} zones={zones} onNavigate={navigate} />
</div>
)}
<div className={styles.count}>
<span>
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
</span>
</div>
{records.length > 0 ? (
<ResourceRecords records={records} withDnssec node={domain} />
) : (
<div className={styles.empty}>
{/* "in the tree" left with the tree: the chips are right above. */}
<Empty title="No records at this node">
{zones.length > 0
? 'This node only contains sub-domains. Open one to see its records.'
: 'This node has no records and no sub-domains.'}
</Empty>
</div>
)}
</>
)}
</section>
) : (
{(
<div className={styles.split}>
<div className={styles.tree}>
<section className={styles.column} aria-label="Domain tree">
<Frame dense className={styles.column} role="region" aria-label="Domain tree">
<FramePanel className="flex min-h-0 flex-1 flex-col p-0">
<div className={styles.browse}>
{/* The field is called "Domain" and the button "Browse". Upstream puts
no label here (only the `placeholder`), so this one is an
@@ -609,24 +534,40 @@ export function Lists({
<>
{/* The count lives in the count bar, like the records one: in the
header it looked exactly like a status pill. */}
<div className={styles.count}>
<span>{zones.length === 1 ? '1 zone' : `${zones.length} zones`}</span>
</div>
<div className={styles.body}>
<Tree
domain={domain}
domainIdn={node?.domainIdn}
zones={zones}
onNavigate={navigate}
/>
</div>
{isCache ? (
<div className={styles.body}>
<CacheTree
token={token}
clusterNode={clusterNode}
selected={domain}
current={node == null ? null : { domain: node.domain, zones: node.zones }}
epoch={treeEpoch}
onSelect={navigate}
/>
</div>
) : (
<>
<div className={styles.count}>
<span>{zones.length === 1 ? '1 zone' : `${zones.length} zones`}</span>
</div>
<div className={styles.body}>
<Tree
domain={domain}
domainIdn={node?.domainIdn}
zones={zones}
onNavigate={navigate}
/>
</div>
</>
)}
</>
)}
</section>
</FramePanel>
</Frame>
</div>
<div className={[styles.records, styles[list]].join(' ')}>
<section className={styles.column} aria-label="Records">
<Frame dense className={styles.column} role="region" aria-label="Records">
{/*
Cache is what the server resolved and stored; Allowed and Blocked are
the administrator's decisions. Three screens, one component, and until
@@ -637,7 +578,7 @@ export function Lists({
rule `theme/tokens.css` already applies to itself in the seven
conditions of Logs: colour reinforces, it is never the only channel.
*/}
<div className={styles.identity}>
<FrameHeader className={styles.identity}>
<span className={styles.emblem}>
<Icon name={list} size={15} />
</span>
@@ -645,7 +586,8 @@ export function Lists({
<span className={styles.identityTitle}>{TITLE[list]}</span>
<span className={styles.identitySub}>{SUBTITLE[list]}</span>
</span>
</div>
</FrameHeader>
<FramePanel className="flex min-h-0 flex-1 flex-col p-0">
<Path domain={domain} label={nodeTitle} />
@@ -659,9 +601,10 @@ export function Lists({
<div className={styles.count}>
<span>
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
{isCache && zones.length > 0 && ` \u00b7 ${zones.length === 1 ? '1 sub-zone' : `${zones.length} sub-zones`}`}
</span>
<div className={styles.countActs}>
<Button size="sm" onClick={() => navigate(domain)}>
<Button size="sm" onClick={() => { setTreeEpoch((v) => v + 1); navigate(domain) }}>
Refresh
</Button>
{/*
@@ -677,7 +620,7 @@ export function Lists({
variant="danger"
disabled={busy}
permission={canDelete ? undefined : `${TITLE[list]}.canDelete`}
onClick={askDeleteDomain}
onClick={isCache ? askDeleteCacheNode : askDeleteDomain}
>
Delete
</PermissionButton>
@@ -692,7 +635,7 @@ export function Lists({
`role="status"` for one request read it out twice. */
<div className={styles.empty}><Loading announce={false} /></div>
) : records.length > 0 ? (
<ResourceRecords records={records} withDnssec={false} node={domain} />
<ResourceRecords records={records} withDnssec={isCache} node={domain} />
) : (
<div className={styles.empty}>
<Empty title="No records at this node">
@@ -703,10 +646,10 @@ export function Lists({
</div>
)}
</div>
</section>
</FramePanel>
</Frame>
</div>
</div>
)}
<Confirm
+2 -2
View File
@@ -105,7 +105,7 @@ its edge together).
display: flex; align-items: center; gap: var(--s-6) var(--s-8); flex-wrap: wrap;
min-height: var(--s-11);
font-size: var(--t-1); font-weight: 600; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--mute);
color: var(--mute);
}
.groupCtl { display: flex; align-items: flex-end; gap: var(--s-7); flex-wrap: wrap; }
.field { display: flex; flex-direction: column; gap: var(--s-3); width: var(--ql-field, auto); }
@@ -268,7 +268,7 @@ to become a summary of what is in front of you.
}
.legendTitle {
font-size: var(--t-1); font-weight: 600; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--mute);
color: var(--mute);
}
.legendItem { display: inline-flex; align-items: center; gap: var(--s-5); font-size: var(--t-2); }
/* Each entry is the pill itself, the same one the Response Type cell carries,
+1 -1
View File
@@ -11,7 +11,7 @@ the System / Light / Dark theme without a second palette.
@import 'tailwindcss/utilities.css' layer(utilities);
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@custom-variant dark (&:not(:is([data-theme='light'], [data-theme='light'] *)));
:root {
--radius: 0.625rem;
+4 -4
View File
@@ -657,11 +657,11 @@ wherever the attribute says `light`.
In a table, `height` on a cell is a MINIMUM: a header that genuinely needs two
lines still grows.
*/
--row-header: 36px;
--row-header: 40px;
--text-size: 10.5px;
--text-weight: 600;
--text-tracking: 0.05em;
--text-size: 12px;
--text-weight: 500;
--text-tracking: 0;
--t-1: 12px; /* small-caps label, table header, pill (10) */
--t-2: 12px; /* metadata, help under a field */
+6 -5
View File
@@ -19,12 +19,13 @@ Administration, with the same values copied by hand.
.table th {
text-align: left;
font-size: var(--text-size);
font-weight: var(--text-weight);
letter-spacing: var(--text-tracking);
text-transform: uppercase;
/* The DataGrid head the other tables wear: a caption, not small caps. */
font-size: var(--t-1);
font-weight: 500;
color: var(--mute);
padding: 0 var(--s-5) var(--s-4) 0;
height: var(--row-header);
padding: 0 var(--s-5) 0 var(--s-5);
background: color-mix(in oklab, var(--pan2) 30%, transparent);
border-bottom: 1px solid var(--line);
}
+1 -1
View File
@@ -29,7 +29,7 @@ magazine kicker, which is decoration, and this is navigation.
margin: 0;
font-size: var(--t-7);
font-weight: 600;
letter-spacing: -0.025em;
letter-spacing: -0.01em;
color: var(--ink);
word-break: break-word;
}
+2 -2
View File
@@ -30,7 +30,7 @@ export function Segmented<T extends string>({
}) {
return (
<div
className="inline-flex max-w-full min-w-0 gap-0.5 overflow-x-auto rounded-lg bg-muted p-0.5 [scrollbar-width:none]"
className="inline-flex max-w-full min-w-0 gap-0.5 overflow-x-auto rounded-lg bg-muted p-[3px] [scrollbar-width:none]"
role={asTabs ? 'tablist' : 'group'}
aria-label={label}
>
@@ -41,7 +41,7 @@ export function Segmented<T extends string>({
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',
compact ? 'h-6 text-xs' : 'h-7',
compact ? 'h-6 text-xs' : 'h-[30px]',
)}
role={asTabs ? 'tab' : undefined}
aria-selected={asTabs ? o.id === active : undefined}
+1 -1
View File
@@ -10,7 +10,7 @@ to weigh the same. What changes is that this one opens.
gap: var(--s-6);
width: 100%;
min-height: var(--field-height);
padding: var(--s-5) var(--s-7);
padding: calc((var(--field-height) - var(--field-line) - 2px) / 2) var(--s-7);
border: 1px solid var(--line-field);
border-radius: var(--r-3);
background-color: var(--field);
+3 -2
View File
@@ -61,7 +61,7 @@ the tint.
/* The last row carries no line: the panel border closes it. */
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr { transition: background var(--t-fast); }
.table tbody tr { transition: background var(--t-fast); height: 56px; }
.table tbody tr:hover { background: var(--hover); }
/*
@@ -383,6 +383,7 @@ line and count twice.
*/
.stack[data-stacked] > tbody > tr,
.stack[data-stacked] > tfoot > tr {
height: auto;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
@@ -452,7 +453,7 @@ empty alternative text keeps a screen reader from reading it twice.
font-size: var(--text-size);
font-weight: var(--text-weight);
letter-spacing: var(--text-tracking);
text-transform: uppercase;
color: var(--mute);
white-space: nowrap;
}
+1 -1
View File
@@ -18,6 +18,6 @@ It is applied with `composes:`, not by copying it.
font-size: var(--text-size);
font-weight: var(--text-weight);
letter-spacing: var(--text-tracking);
text-transform: uppercase;
color: var(--mute);
}