diff --git a/docs/reui-migration-plan.md b/docs/reui-migration-plan.md
index 2056748..548c4f9 100644
--- a/docs/reui-migration-plan.md
+++ b/docs/reui-migration-plan.md
@@ -79,3 +79,11 @@
| Table | Таблица строится на примитивах ReUI (`TableHeader`, `TableBody`, `TableFooter` и вид DataGrid). Перевод на TanStack `DataGrid` не сделан: строки и ячейки 23 таблиц несут свои атрибуты (классы строк, `colSpan`, `aria-sort` в `
`), а DataGrid отдаёт `
`/`
` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. |
Сборка, `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.
diff --git a/src/app/Shell.module.css b/src/app/Shell.module.css
index 6163ae0..9e4a2ca 100644
--- a/src/app/Shell.module.css
+++ b/src/app/Shell.module.css
@@ -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; }
+}
diff --git a/src/app/Shell.tsx b/src/app/Shell.tsx
index 8cb6716..43735e1 100644
--- a/src/app/Shell.tsx
+++ b/src/app/Shell.tsx
@@ -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(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 = {
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('shell.rail', false))
+ const [shut, setShut] = useState([])
+ const [pins, setPins] = useState(() => stored('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(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 (
+