Andrey Epifancev d2b4ff2018 feat: skill aura-design — дизайн-система AURA для личных проектов (#17)
Скилл переносится из ~/.claude/skills/aura-design в репозиторий обвязки:
SKILL.md, справочники reference/ и набор kit/ (токены, base.css, компоненты
React, оболочка AppShell, тест токенов, test-setup, примеры входа).
install.sh ставит его в ~/.claude/skills/aura-design, --check его видит.
tests/test-aura-design.sh проверяет структуру и инварианты набора.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:10:34 +04:00

121 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { IconButton, IconClose, IconMenu, IconSidebar, Menu, ThemeSwitch } from "../ui/index.ts";
import "./shell.css";
// Оболочка AURA: закреплённая боковая панель (240 px, свёртка до 64 px) и страница справа; прокрутка — у окна.
// Узкий экран (<640 px): панели в потоке нет — закреплённая строка 56 px с кнопкой «Меню», панель выезжает поверх.
export type NavItem = { href: string; label: string; icon: ReactNode };
// ссылка по умолчанию — <a>; с роутером: link={({ href, ...p }) => <Link to={href} {...p} />}
export type ShellLink = (p: {
href: string; className: string; "aria-current"?: "page"; title?: string; onClick?: () => void; children: ReactNode;
}) => ReactNode;
const plainLink: ShellLink = ({ href, ...rest }) => <a href={href} {...rest} />;
type Props = {
brand: string; // название приложения, Bold 16/24
nav: NavItem[];
current: string | null; // href активного раздела; null — активного нет
link?: ShellLink;
top?: (collapsed: boolean) => ReactNode; // под названием: выбор проекта/аккаунта и т. п.
bottom?: (collapsed: boolean) => ReactNode; // над подвалом: состояние данных, кнопка синхронизации
topbarEnd?: ReactNode; // правый край строки на телефоне (компактное состояние)
menu?: (close: () => void) => ReactNode; // пункты меню «Ещё» в подвале (MenuItem …)
storageKey?: string; // ключ свёртки панели в localStorage
wide?: boolean; // страница во всю ширину (доски, графики), иначе до 1440 px
children: ReactNode;
};
const NARROW = "(max-width: 639px)";
const subscribe = (cb: () => void) => {
if (typeof matchMedia !== "function") return () => {};
const m = matchMedia(NARROW);
m.addEventListener?.("change", cb);
return () => m.removeEventListener?.("change", cb);
};
const isNarrow = () => typeof matchMedia === "function" && matchMedia(NARROW).matches;
export function AppShell({ wide, children, ...p }: Props) {
const narrow = useSyncExternalStore(subscribe, isNarrow);
return (
<div className="shell">
{narrow ? <NarrowBar {...p} /> : <Sidebar {...p} />}
<main className={"page" + (wide ? " page--wide" : "")}>{children}</main>
</div>
);
}
type PanelProps = Omit<Props, "wide" | "children">;
function NarrowBar(p: PanelProps) {
const [open, setOpen] = useState(false);
const menuBtn = useRef<HTMLButtonElement>(null);
// объект стабилен, пока панель открыта: эффект фокуса и Escape не перезапускается на каждой отрисовке
const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []);
return (
<>
<header className="topbar" aria-label="Строка приложения">
<button ref={menuBtn} type="button" className="ui-icon-btn" aria-label="Меню" title="Меню" aria-expanded={open}
onClick={() => setOpen(true)}><IconMenu /></button>
<span className="sidebar__brand">{p.brand}</span>
<span className="sidebar__spacer" />
{p.topbarEnd}
</header>
{open && <>
<div className="sidebar-backdrop" onClick={drawer.close} />
<Sidebar {...p} drawer={drawer} />
</>}
</>
);
}
function readFold(key: string): boolean {
try { return localStorage.getItem(key) === "collapsed"; } catch { return false; }
}
function saveFold(key: string, collapsed: boolean): void {
try { if (collapsed) localStorage.setItem(key, "collapsed"); else localStorage.removeItem(key); } catch { /* только в памяти */ }
}
function Sidebar({ brand, nav, current, link = plainLink, top, bottom, menu, storageKey = "app-sidebar", drawer }:
PanelProps & { drawer?: { close(): void; navigated(): void } }) {
const [stored, setStored] = useState(() => readFold(storageKey));
const collapsed = !drawer && stored;
const fold = (c: boolean) => { setStored(c); saveFold(storageKey, c); };
const aside = useRef<HTMLElement>(null);
useEffect(() => {
if (!drawer) return;
aside.current?.querySelector<HTMLElement>(".sidebar__link")?.focus();
const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); };
document.addEventListener("keydown", esc);
return () => document.removeEventListener("keydown", esc);
}, [drawer]);
return (
<aside ref={aside} className={"sidebar" + (collapsed ? " is-collapsed" : "") + (drawer ? " is-drawer" : "")} aria-label="Панель приложения">
<div className="sidebar__head">
{!collapsed && <span className="sidebar__brand">{brand}</span>}
{drawer
? <IconButton label="Закрыть меню" icon={<IconClose />} onClick={drawer.close} />
: <IconButton label={collapsed ? "Развернуть панель" : "Свернуть панель"} icon={<IconSidebar />}
aria-expanded={!collapsed} onClick={() => fold(!collapsed)} />}
</div>
{top?.(collapsed)}
<nav className="sidebar__nav" aria-label="Разделы">
{nav.map((s) => (
<Fragment key={s.href}>
{link({
href: s.href, className: "sidebar__link", "aria-current": current === s.href ? "page" : undefined,
title: collapsed ? s.label : undefined, onClick: drawer?.navigated,
children: <>{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span></>,
})}
</Fragment>
))}
</nav>
<span className="sidebar__spacer" />
{bottom?.(collapsed)}
<div className="sidebar__foot">
<ThemeSwitch />
{menu && <Menu label="Ещё" up align={collapsed ? "start" : "end"}>{menu}</Menu>}
</div>
</aside>
);
}