Боковая панель вместо верхней: разделы, свёртка, данные Jira внизу (#69)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 08:51:23 +04:00
parent cabf8d75f4
commit f8610abc81
13 changed files with 202 additions and 82 deletions

View File

@ -1,5 +1,5 @@
/* выбор проекта, служебные состояния выбора и окно «Новый проект» */
.appbar .project-picker { max-width: 280px; min-width: 160px; }
.sidebar .project-picker { width: 100%; min-width: 0; }
.project-gate { max-width: 640px; margin: var(--space-6) 0; }
.project-gate .ui-notice { align-items: center; }
.project-form { display: flex; flex-direction: column; gap: var(--space-4); }

View File

@ -111,7 +111,7 @@ describe("проект в приложении", () => {
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
// пакет проекта отслеживается по batch_id (track): верхняя панель показывает ход, список пакетов не запрашивается
expect(await within(screen.getByRole("banner")).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
expect(await within(screen.getByRole("complementary", { name: "Панель приложения" })).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
expect(h.get("/batches").length).toBe(listed);
const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!;
expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" });

View File

@ -1,28 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { clearToken } from "../auth/token.ts";
import { withProject } from "../projects/current.ts";
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
import { Menu, MenuItem, Segmented, ThemeSwitch } from "../ui/index.ts";
import { DataStatus } from "./DataStatus.tsx";
export type Section = "/board" | "/issues";
// верхняя панель (design.md, решение 9); section = null на путях вне разделов — активной ссылки нет (T5.1): название, проект, разделы, состояние данных, тема, меню «Ещё»
export function AppBar({ section, project }: { section: Section | null; project: string | null }) {
const qc = useQueryClient();
const href = (to: Section) => (project ? to + withProject("", project) : to);
return (
<header className="appbar">
<span className="appbar__brand">Хаб Jira</span>
<ProjectPicker />
<Segmented label="Раздел" value={section}
options={[{ value: "/board", label: "Доска связей", href: href("/board") }, { value: "/issues", label: "Задачи", href: href("/issues") }]} />
<span className="appbar__spacer" />
<DataStatus project={project} />
<ThemeSwitch />
<Menu label="Ещё">
{(close) => <MenuItem onSelect={() => { close(); clearToken(); qc.clear(); }}>Сменить токен</MenuItem>}
</Menu>
</header>
);
}

View File

@ -1,31 +1,48 @@
import { useBoard } from "../api/queries.ts";
import { fmtStamp } from "../board/model/util.ts";
import { Button, IconRefresh } from "../ui/index.ts";
import { Button, IconButton, IconRefresh } from "../ui/index.ts";
import { useJiraRefresh } from "./refresh.ts";
import { refreshText } from "./refreshStatus.ts";
// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета
export function DataStatus({ project }: { project: string | null }) {
const HINT = "Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira";
// состояние данных: агент, время последнего полного обновления, кнопка «Обновить из Jira» и ход пакета;
// compact — свёрнутая боковая панель: кнопка-значок с точкой агента, тексты в подсказке (#69, design 3)
export function DataStatus({ project, compact = false }: { project: string | null; compact?: boolean }) {
const refresh = useJiraRefresh();
const board = useBoard(project);
const ls = board.data?.last_sync ?? null;
const agent = refresh.agent;
const rt = refresh.event ? refreshText(refresh.event) : null;
const seen = agent?.last_seen_at ? `последний выход на связь: ${fmtStamp(agent.last_seen_at)}` : "выхода на связь ещё не было";
const agentText = agent ? (agent.online ? "агент на связи" : "агент офлайн") : null;
const syncText = board.data ? (ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было") : null;
const event = <span className={`data-status__event${rt ? ` data-status__event--${rt.tone}` : ""}${compact ? " visually-hidden" : ""}`}
role="status" title={rt?.text}>{rt?.text}</span>;
if (compact) {
const title = ["Обновить из Jira", agentText, syncText, rt?.text].filter(Boolean).join(" · ");
return (
<div className="data-status data-status--compact">
<IconButton label="Обновить из Jira" title={title} disabled={refresh.busy} aria-busy={refresh.busy ? "true" : undefined}
icon={refresh.busy ? <span className="ui-spinner" aria-hidden="true" /> : <IconRefresh />} onClick={() => void refresh.start()} />
{agent && <span className={"data-status__dot" + (agent.online ? " is-online" : "")} aria-hidden="true" />}
{event}
</div>
);
}
return (
<div className="data-status">
{agent && (
<span className="data-status__agent" title={seen}>
<span className={"data-status__dot" + (agent.online ? " is-online" : "")} aria-hidden="true" />
{agent.online ? "агент на связи" : "агент офлайн"}
{agentText}
</span>
)}
{board.data && <span className="data-status__sync">{ls ? `данные на ${fmtStamp(ls.finished_at)}` : "полного обновления не было"}</span>}
<Button variant="primary" icon={<IconRefresh />} loading={refresh.busy} onClick={() => void refresh.start()}
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">
{syncText && <span className="data-status__sync">{syncText}</span>}
<Button variant="primary" icon={<IconRefresh />} loading={refresh.busy} onClick={() => void refresh.start()} title={HINT}>
Обновить из Jira
</Button>
<span className={`data-status__event${rt ? ` data-status__event--${rt.tone}` : ""}`} role="status" title={rt?.text}>{rt?.text}</span>
{event}
</div>
);
}

View File

@ -7,10 +7,10 @@ import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts";
import { NewProjectDialog } from "../projects/NewProjectDialog.tsx";
import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts";
import { AppBar } from "./AppBar.tsx";
import { ErrorBoundary } from "./ErrorBoundary.tsx";
import { PAGE_ROUTES } from "./pages.tsx";
import { useJiraRefresh } from "./refresh.ts";
import { Sidebar } from "./Sidebar.tsx";
import "./shell.css";
export function Layout() {
@ -78,10 +78,12 @@ export function Layout() {
const refresh = useJiraRefresh();
return (
<ProjectContext value={ctx}>
<AppBar section={section} project={okLabel} />
<main className={"page" + (section !== "/issues" ? " page--board" : "")}>
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
<div className="shell">
<Sidebar section={section} project={okLabel} />
<main className={"page" + (section !== "/issues" ? " page--board" : "")}>
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
</div>
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p, batchId) => { setNewOpen(false); refresh.track(batchId); select(p.label); }} />}
</ProjectContext>

View File

@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@ -38,9 +38,9 @@ const open = (path: string) => {
render(<App router={router} />);
return router;
};
const bar = () => screen.getAllByRole("banner")[0]; // верхняя панель — первая; PageHeader страницы без <main> тоже даёт banner
const bar = () => screen.getByRole("complementary", { name: "Панель приложения" });
describe("верхняя панель", () => {
describe("боковая панель", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => {
@ -56,7 +56,7 @@ describe("верхняя панель", () => {
hub();
open("/app/nowhere?project=AWPCA");
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
const nav = within(bar()).getByRole("navigation", { name: "Раздел" });
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current");
expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
const main = within(screen.getByRole("main"));
@ -130,4 +130,50 @@ describe("верхняя панель", () => {
await screen.findByText(/задач из/);
expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1);
});
it("разделы — в навигации «Разделы» боковой панели", async () => {
hub();
open("/app/board?project=AWPCA");
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page");
});
it("«Свернуть панель» сворачивает и запоминает в jira-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => {
hub();
open("/app/board?project=AWPCA");
const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" });
expect(btn).toHaveAttribute("aria-expanded", "true");
await userEvent.click(btn);
expect(localStorage.getItem("jira-hub-sidebar")).toBe("collapsed");
expect(bar()).toHaveClass("is-collapsed");
cleanup();
open("/app/board?project=AWPCA");
expect(bar()).toHaveClass("is-collapsed");
expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false");
expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи");
await userEvent.click(within(bar()).getByRole("button", { name: "Развернуть панель" }));
expect(localStorage.getItem("jira-hub-sidebar")).toBeNull();
expect(bar()).not.toHaveClass("is-collapsed");
});
it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => {
localStorage.setItem("jira-hub-sidebar", "collapsed");
const h = hub();
open("/app/issues?project=AWPCA");
const btn = await within(bar()).findByRole("button", { name: "Обновить из Jira" });
await userEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
expect(h.count("POST", "/refresh")).toBe(1);
expect(await within(bar()).findByRole("status")).toHaveTextContent(/пакет #7 выполняется/);
expect(btn.getAttribute("title")).toMatch(/пакет #7 выполняется/);
});
it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => {
localStorage.setItem("jira-hub-sidebar", "collapsed");
hub();
open("/app/board?project=AWPCA");
const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" });
await userEvent.click(tag);
expect(bar()).not.toHaveClass("is-collapsed");
});
});

55
web/src/shell/Sidebar.tsx Normal file
View File

@ -0,0 +1,55 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link } from "react-router";
import { clearToken } from "../auth/token.ts";
import { withProject } from "../projects/current.ts";
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
import { IconBoard, IconButton, IconList, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
import { DataStatus } from "./DataStatus.tsx";
import { readSidebar, saveSidebar } from "./sidebarPref.ts";
export type Section = "/board" | "/issues";
const SECTIONS: { to: Section; label: string; icon: React.ReactNode }[] = [
{ to: "/board", label: "Доска связей", icon: <IconBoard /> },
{ to: "/issues", label: "Задачи", icon: <IconList /> },
];
// боковая панель (#69, design 1–3); section = null на путях вне разделов — активной ссылки нет:
// название и свёртка, проект, разделы, внизу состояние данных, тема, меню «Ещё»
export function Sidebar({ section, project }: { section: Section | null; project: string | null }) {
const qc = useQueryClient();
const [collapsed, setCollapsed] = useState(readSidebar);
const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); };
const href = (to: Section) => (project ? to + withProject("", project) : to);
return (
<aside className={"sidebar" + (collapsed ? " is-collapsed" : "")} aria-label="Панель приложения">
<div className="sidebar__head">
{!collapsed && <span className="sidebar__brand">Хаб Jira</span>}
<IconButton label={collapsed ? "Развернуть панель" : "Свернуть панель"} icon={<IconSidebar />}
aria-expanded={!collapsed} onClick={() => fold(!collapsed)} />
</div>
{collapsed
? project && (
<button type="button" className="sidebar__project-tag" aria-label={`Проект ${project} — развернуть панель`}
title={`Проект ${project}`} onClick={() => fold(false)}>{project.slice(0, 6)}</button>)
: <ProjectPicker />}
<nav className="sidebar__nav" aria-label="Разделы">
{SECTIONS.map((s) => (
<Link key={s.to} to={href(s.to)} className="sidebar__link" aria-current={section === s.to ? "page" : undefined}
title={collapsed ? s.label : undefined}>
{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span>
</Link>
))}
</nav>
<span className="sidebar__spacer" />
<DataStatus project={project} compact={collapsed} />
<div className="sidebar__foot">
<ThemeSwitch />
<Menu label="Ещё" up align={collapsed ? "start" : "end"}>
{(close) => <MenuItem onSelect={() => { close(); clearToken(); qc.clear(); }}>Сменить токен</MenuItem>}
</Menu>
</div>
</aside>
);
}

View File

@ -1,38 +1,48 @@
/* оболочка: верхняя панель разделов (design.md, решение 9) */
.appbar{
position:relative;
display:flex;align-items:center;flex-wrap:wrap;gap:8px 16px;min-height:64px;padding:8px 24px;box-sizing:border-box;
border-bottom:1px solid var(--color-border-default);background:var(--color-bg-surface);
/* оболочка: боковая панель и страница (#69, design 1–3); прокрутка — у окна, панель липнет к его верху */
.shell{display:grid;grid-template-columns:auto minmax(0,1fr);min-height:100vh}
.sidebar{
position:sticky;top:0;height:100vh;height:100dvh;width:240px;box-sizing:border-box;z-index:20;
display:flex;flex-direction:column;gap:var(--space-4);padding:var(--space-4);
border-right:1px solid var(--color-border-default);background:var(--color-bg-surface);
}
.appbar__brand{font-size:16px;line-height:24px;font-weight:700;color:var(--color-text-primary);white-space:nowrap}
.appbar__spacer{flex:1 1 auto}
.data-status{display:flex;align-items:center;flex-wrap:wrap;gap:4px 12px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
.data-status__agent{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning)}
.sidebar.is-collapsed{width:64px;padding-inline:var(--space-2);align-items:center}
.sidebar__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);min-height:var(--control-h)}
.sidebar__brand{font-size:16px;line-height:24px;font-weight:700;color:var(--color-text-primary);white-space:nowrap;padding-left:var(--space-2)}
.sidebar__project-tag{
max-width:48px;padding:2px var(--space-1);border:1px solid var(--color-border-default);border-radius:var(--radius-xs);
background:var(--color-bg-subtle);color:var(--color-text-secondary);font:inherit;font-size:var(--fs-caption);line-height:var(--lh-caption);
font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;
}
.sidebar__project-tag:hover{color:var(--color-text-primary);border-color:var(--color-border-strong)}
.sidebar__nav{display:flex;flex-direction:column;gap:var(--space-1);align-self:stretch}
.sidebar__link{
display:flex;align-items:center;gap:var(--space-3);height:var(--control-h-lg);padding:0 var(--space-3);box-sizing:border-box;
border-radius:var(--control-radius);color:var(--color-text-secondary);text-decoration:none;font-weight:600;white-space:nowrap;
}
.sidebar__link:hover{background:var(--color-bg-hover);color:var(--color-text-primary)}
.sidebar__link[aria-current="page"]{background:var(--color-bg-selected);color:var(--color-action-primary)}
.sidebar__link:focus-visible,.sidebar__project-tag:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
.is-collapsed .sidebar__nav{align-items:center}
.is-collapsed .sidebar__link{width:var(--control-h-lg);padding:0;justify-content:center}
.sidebar__spacer{flex:1 1 auto}
.sidebar__foot{
display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);align-self:stretch;
padding-top:var(--space-3);border-top:1px solid var(--color-border-default);
}
.is-collapsed .sidebar__foot{flex-direction:column}
/* блок данных Jira: в развёрнутой панели — колонкой, событие пакета под кнопкой с переносом */
.data-status{display:flex;flex-direction:column;align-items:stretch;gap:var(--space-2);align-self:stretch;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
.data-status .ui-btn{width:100%;justify-content:center}
.data-status__agent{display:inline-flex;align-items:center;gap:6px}
.data-status__dot{width:8px;height:8px;border-radius:50%;background:var(--color-status-warning);flex:0 0 auto}
.data-status__dot.is-online{background:var(--color-status-success)}
.data-status__sync{white-space:nowrap}
/* I-3: живой регион всегда в DOM; ≥1100 текст события — отдельной строкой у нижней границы панели (кнопка не двигается, высота панели не меняется) */
.data-status__event{font-weight:600;min-width:0}
.data-status__event:empty{position:absolute}
.data-status__event{font-weight:600;overflow-wrap:anywhere}
.data-status__event:empty{position:absolute} /* живой регион всегда в DOM; пустой не занимает места */
.data-status__event--info{color:var(--color-text-secondary)}
.data-status__event--success{color:var(--color-status-success)}
.data-status__event--warning{color:color-mix(in srgb, var(--color-status-warning) 55%, var(--color-text-primary))}
.data-status__event--error{color:var(--color-status-error)}
.data-status--compact{position:relative;align-items:center;align-self:center}
.data-status--compact .data-status__dot{position:absolute;top:2px;right:2px;pointer-events:none;box-shadow:0 0 0 2px var(--color-bg-surface)}
.crash{max-width:640px;margin:48px auto}
.crash h2{margin:0;font:inherit}
@media (min-width:1100px){
.data-status__event{position:absolute;right:24px;bottom:1px;max-width:min(560px,45vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:14px}
}
@media (max-width:1099px){
.appbar .data-status{order:10;flex:1 0 100%}
.appbar .ui-theme,.appbar .more{order:4} /* T10.1: тема и «…» остаются в первой строке, статус данных — второй */
.appbar .appbar__spacer{flex:1 1 0}
}
@media (max-width:639px){
.appbar{padding-inline:16px}
.appbar__brand{display:none}
.appbar .appbar__spacer{display:none}
.appbar .project-picker{flex:1 1 0;width:auto;max-width:none;min-width:0} /* T10.1: picker, тема и «…» — в одной строке */
.appbar .ui-seg{flex:1 1 100%;order:5}
.appbar .ui-seg__item{flex:1 1 0;justify-content:center}
}

View File

@ -0,0 +1,13 @@
// свёртка боковой панели: "collapsed" — свёрнута, без записи — развёрнута; ошибки хранилища глотаем
const KEY = "jira-hub-sidebar";
export function readSidebar(): boolean {
try { return localStorage.getItem(KEY) === "collapsed"; } catch { return false; }
}
export function saveSidebar(collapsed: boolean): void {
try {
if (collapsed) localStorage.setItem(KEY, "collapsed");
else localStorage.removeItem(KEY);
} catch { /* хранилище недоступно — состояние только в памяти */ }
}

View File

@ -17,9 +17,9 @@ export function Button({ variant = "secondary", size = "md", icon, loading, clas
);
}
export function IconButton({ label, icon, className, type = "button", ...rest }: { label: string; icon: ReactNode } & ButtonHTMLAttributes<HTMLButtonElement>) {
export function IconButton({ label, icon, className, title, type = "button", ...rest }: { label: string; icon: ReactNode } & ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button {...rest} type={type} className={["ui-icon-btn", className].filter(Boolean).join(" ")} aria-label={label} title={label}>
<button {...rest} type={type} className={["ui-icon-btn", className].filter(Boolean).join(" ")} aria-label={label} title={title ?? label}>
{icon}
</button>
);

View File

@ -25,11 +25,11 @@ export function Dropdown({ button, className = "", menu = false, children, onOpe
}
// меню «Ещё»: Dropdown с кнопкой-иконкой; пункты — кнопки (aria-pressed для переключателей)
export function Menu({ label, children, align = "end" }: {
label: string; children: (close: () => void) => ReactNode; align?: "start" | "end";
export function Menu({ label, children, align = "end", up = false }: {
label: string; children: (close: () => void) => ReactNode; align?: "start" | "end"; up?: boolean; // up — открывается вверх (низ боковой панели)
}) {
return (
<Dropdown menu className={"more" + (align === "start" ? " dd--start" : "")}
<Dropdown menu className={"more" + (align === "start" ? " dd--start" : "") + (up ? " dd--up" : "")}
button={({ open, toggle }) => <IconButton label={label} icon={<IconMore />} aria-haspopup="menu" aria-expanded={open} onClick={toggle} />}>
{children}
</Dropdown>

View File

@ -32,3 +32,7 @@ export const IconNeighbors = make(<><circle cx="12" cy="12" r="2.5"/><circle cx=
export const IconLink = make(<><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></>);
export const IconLegend = make(<><path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".8"/><circle cx="4.5" cy="12" r=".8"/><circle cx="4.5" cy="18" r=".8"/></>);
export const IconOpen = make(<><path d="M5 12h14"/><path d="M13 6l6 6-6 6"/></>);
export const IconBoard = make(<><rect x="3" y="4" width="7" height="6" rx="1.5"/><rect x="14" y="4" width="7" height="6" rx="1.5"/><rect x="8.5" y="15" width="7" height="6" rx="1.5"/><path d="M10 7h4M6.5 10v2.5h5.5V15"/></>);
export const IconList = make(<><path d="M9 6h11M9 12h11M9 18h11"/><path d="M4 6h1M4 12h1M4 18h1"/></>);
export const IconMenu = make(<><path d="M4 6h16M4 12h16M4 18h16"/></>);
export const IconSidebar = make(<><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></>);

View File

@ -203,6 +203,7 @@
.dd-empty { padding: var(--space-2); font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--color-text-secondary); }
.dd-foot { display: flex; gap: var(--space-2); justify-content: space-between; border-top: 1px solid var(--color-border-default); padding-top: var(--space-2); }
.dd--start > .dd-menu.menu { left: 0; right: auto; }
.dd--up > .dd-menu { top: auto; bottom: calc(100% + var(--space-1)); }
.menu button {
display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%;
min-height: var(--control-h); padding: var(--space-1) var(--space-3); border: 0; border-radius: var(--radius-sm);