Skip to content

Commit 8e46865

Browse files
authored
Merge pull request #71 from zestones/feat/36-app-shell
feat(frontend): app shell with resizable chat drawer (M6.3)
2 parents 6a9744c + aa67504 commit 8e46865

33 files changed

Lines changed: 3079 additions & 778 deletions
File renamed without changes.

frontend/docs/DESIGN_PLAN_v2.md

Lines changed: 596 additions & 0 deletions
Large diffs are not rendered by default.

frontend/index.html

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,34 @@
11
<!doctype html>
2-
<html lang="en" class="dark">
2+
<html lang="en">
33

44
<head>
55
<meta charset="UTF-8" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<meta name="color-scheme" content="dark" />
7+
<meta name="color-scheme" content="dark light" />
88
<title>ARIA — Adaptive Runtime Intelligence</title>
99
<link rel="preconnect" href="https://fonts.googleapis.com" />
1010
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1111
<link
1212
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
1313
rel="stylesheet" />
14+
<script>
15+
// Pre-mount theme bootstrap — reads user preference, falls back to system.
16+
// Keep in sync with ThemeProvider.ts.
17+
(function () {
18+
try {
19+
var stored = localStorage.getItem("aria.theme");
20+
var mode = stored === "light" || stored === "dark" || stored === "system"
21+
? stored
22+
: "system";
23+
var resolved = mode === "system"
24+
? (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
25+
: mode;
26+
document.documentElement.setAttribute("data-theme", resolved);
27+
} catch (e) {
28+
document.documentElement.setAttribute("data-theme", "dark");
29+
}
30+
})();
31+
</script>
1432
</head>
1533

1634
<body>

frontend/src/App.tsx

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,5 @@
1-
import { Navigate, Route, Routes } from "react-router-dom";
2-
import RequireAuth from "./components/RequireAuth";
3-
import DataPage from "./pages/DataPage";
4-
import DesignPage from "./pages/DesignPage";
5-
import LoginPage from "./pages/LoginPage";
1+
import { AppRoutes } from "./app/routes";
62

73
export default function App() {
8-
return (
9-
<Routes>
10-
<Route path="/login" element={<LoginPage />} />
11-
<Route path="/design" element={<DesignPage />} />
12-
<Route
13-
path="/data"
14-
element={
15-
<RequireAuth>
16-
<DataPage />
17-
</RequireAuth>
18-
}
19-
/>
20-
<Route path="*" element={<Navigate to="/data" replace />} />
21-
</Routes>
22-
);
4+
return <AppRoutes />;
235
}

frontend/src/app/AppShell.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { useCallback, useEffect, useId } from "react";
2+
import { Outlet } from "react-router-dom";
3+
import type { EquipmentSelection } from "../lib/hierarchy";
4+
import { useLocalStorage } from "../lib/useLocalStorage";
5+
import { DRAWER_DEFAULT_WIDTH, DRAWER_MAX_WIDTH, DRAWER_MIN_WIDTH, Drawer } from "./Drawer";
6+
import { TopBar } from "./TopBar";
7+
8+
interface ChatDrawerState {
9+
open: boolean;
10+
width: number;
11+
}
12+
13+
const CHAT_DRAWER_KEY = "aria.chatDrawer";
14+
const EQUIPMENT_KEY = "aria.selectedEquipment";
15+
16+
const DEFAULT_DRAWER_STATE: ChatDrawerState = {
17+
open: true,
18+
width: DRAWER_DEFAULT_WIDTH,
19+
};
20+
21+
function sanitizeDrawer(state: ChatDrawerState): ChatDrawerState {
22+
return {
23+
open: Boolean(state.open),
24+
width: Math.max(
25+
DRAWER_MIN_WIDTH,
26+
Math.min(DRAWER_MAX_WIDTH, Math.round(state.width ?? DRAWER_DEFAULT_WIDTH)),
27+
),
28+
};
29+
}
30+
31+
function validateEquipmentSelection(raw: unknown): EquipmentSelection | null {
32+
if (!raw || typeof raw !== "object") return null;
33+
const r = raw as Record<string, unknown>;
34+
if (typeof r.cellId !== "number" || typeof r.lineId !== "number") return null;
35+
if (typeof r.cellName !== "string" || typeof r.lineName !== "string") return null;
36+
if (typeof r.areaName !== "string" || typeof r.siteName !== "string") return null;
37+
return raw as EquipmentSelection;
38+
}
39+
40+
function isTypingTarget(target: EventTarget | null) {
41+
if (!(target instanceof HTMLElement)) return false;
42+
const tag = target.tagName;
43+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
44+
if (target.isContentEditable) return true;
45+
return false;
46+
}
47+
48+
export function AppShell() {
49+
const [drawer, setDrawer] = useLocalStorage<ChatDrawerState>(
50+
CHAT_DRAWER_KEY,
51+
DEFAULT_DRAWER_STATE,
52+
);
53+
const [selection, setSelection] = useLocalStorage<EquipmentSelection | null>(
54+
EQUIPMENT_KEY,
55+
null,
56+
{ validator: validateEquipmentSelection },
57+
);
58+
59+
const safeDrawer = sanitizeDrawer(drawer);
60+
const drawerId = useId();
61+
62+
const toggleDrawer = useCallback(() => {
63+
setDrawer((prev) => ({ ...sanitizeDrawer(prev), open: !prev.open }));
64+
}, [setDrawer]);
65+
66+
const setDrawerWidth = useCallback(
67+
(width: number) => {
68+
setDrawer((prev) => ({ ...sanitizeDrawer(prev), width }));
69+
},
70+
[setDrawer],
71+
);
72+
73+
useEffect(() => {
74+
function onKey(e: KeyboardEvent) {
75+
const isMac =
76+
typeof navigator !== "undefined" &&
77+
navigator.platform.toLowerCase().includes("mac");
78+
const comboPressed = (isMac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === "k";
79+
if (!comboPressed) return;
80+
if (isTypingTarget(e.target)) return;
81+
e.preventDefault();
82+
toggleDrawer();
83+
}
84+
window.addEventListener("keydown", onKey);
85+
return () => window.removeEventListener("keydown", onKey);
86+
}, [toggleDrawer]);
87+
88+
return (
89+
<div className="flex h-screen w-screen flex-col overflow-hidden bg-[var(--ds-bg-base)] text-[var(--ds-fg-primary)]">
90+
<TopBar
91+
selection={selection}
92+
onSelectionChange={setSelection}
93+
drawerOpen={safeDrawer.open}
94+
drawerControlsId={drawerId}
95+
onDrawerToggle={toggleDrawer}
96+
/>
97+
<div
98+
className="grid min-h-0 flex-1"
99+
style={{
100+
gridTemplateColumns: safeDrawer.open
101+
? `minmax(0, 1fr) ${safeDrawer.width}px`
102+
: "minmax(0, 1fr) 0",
103+
transition: `grid-template-columns var(--ds-motion-base) var(--ds-ease-out)`,
104+
}}
105+
>
106+
<main className="relative min-h-0 overflow-auto">
107+
<Outlet />
108+
</main>
109+
<Drawer
110+
id={drawerId}
111+
open={safeDrawer.open}
112+
width={safeDrawer.width}
113+
onToggle={toggleDrawer}
114+
onWidthChange={setDrawerWidth}
115+
/>
116+
</div>
117+
</div>
118+
);
119+
}

frontend/src/app/Drawer.tsx

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {
2+
type KeyboardEvent as ReactKeyboardEvent,
3+
type ReactNode,
4+
useCallback,
5+
useEffect,
6+
useId,
7+
useRef,
8+
} from "react";
9+
import { Hairline, Icons, SectionHeader } from "../design-system";
10+
11+
export const DRAWER_MIN_WIDTH = 360;
12+
export const DRAWER_MAX_WIDTH = 640;
13+
export const DRAWER_DEFAULT_WIDTH = 420;
14+
const KEYBOARD_STEP = 10;
15+
16+
export interface DrawerProps {
17+
open: boolean;
18+
width: number;
19+
onToggle: () => void;
20+
onWidthChange: (width: number) => void;
21+
children?: ReactNode;
22+
/** DOM id used by the topbar toggle `aria-controls`. */
23+
id?: string;
24+
}
25+
26+
function clampWidth(width: number) {
27+
return Math.max(DRAWER_MIN_WIDTH, Math.min(DRAWER_MAX_WIDTH, Math.round(width)));
28+
}
29+
30+
/**
31+
* Docked, resizable chat drawer. Sits inside the app-shell grid — not overlay.
32+
* The primitive `design-system/Drawer` remains for modal contexts.
33+
*/
34+
export function Drawer({ open, width, onToggle, onWidthChange, children, id }: DrawerProps) {
35+
const generatedId = useId();
36+
const drawerId = id ?? `chat-drawer-${generatedId}`;
37+
const asideRef = useRef<HTMLElement>(null);
38+
const draggingRef = useRef(false);
39+
const startXRef = useRef(0);
40+
const startWidthRef = useRef(0);
41+
42+
const onPointerMove = useCallback(
43+
(e: PointerEvent) => {
44+
if (!draggingRef.current) return;
45+
const delta = startXRef.current - e.clientX;
46+
onWidthChange(clampWidth(startWidthRef.current + delta));
47+
},
48+
[onWidthChange],
49+
);
50+
51+
const onPointerUp = useCallback(() => {
52+
if (!draggingRef.current) return;
53+
draggingRef.current = false;
54+
document.body.style.cursor = "";
55+
document.body.style.userSelect = "";
56+
window.removeEventListener("pointermove", onPointerMove);
57+
window.removeEventListener("pointerup", onPointerUp);
58+
}, [onPointerMove]);
59+
60+
useEffect(
61+
() => () => {
62+
window.removeEventListener("pointermove", onPointerMove);
63+
window.removeEventListener("pointerup", onPointerUp);
64+
},
65+
[onPointerMove, onPointerUp],
66+
);
67+
68+
const onHandlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
69+
e.preventDefault();
70+
draggingRef.current = true;
71+
startXRef.current = e.clientX;
72+
startWidthRef.current = width;
73+
document.body.style.cursor = "col-resize";
74+
document.body.style.userSelect = "none";
75+
window.addEventListener("pointermove", onPointerMove);
76+
window.addEventListener("pointerup", onPointerUp);
77+
};
78+
79+
const onHandleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
80+
let next: number | null = null;
81+
switch (e.key) {
82+
case "ArrowLeft":
83+
next = clampWidth(width + KEYBOARD_STEP);
84+
break;
85+
case "ArrowRight":
86+
next = clampWidth(width - KEYBOARD_STEP);
87+
break;
88+
case "Home":
89+
next = DRAWER_MIN_WIDTH;
90+
break;
91+
case "End":
92+
next = DRAWER_MAX_WIDTH;
93+
break;
94+
default:
95+
return;
96+
}
97+
e.preventDefault();
98+
onWidthChange(next);
99+
};
100+
101+
return (
102+
<aside
103+
ref={asideRef}
104+
id={drawerId}
105+
aria-label="Chat drawer"
106+
className="relative h-full overflow-hidden border-l border-[var(--ds-border)] bg-[var(--ds-bg-surface)]"
107+
style={{
108+
width: open ? `${width}px` : 0,
109+
transition: `width var(--ds-motion-base) var(--ds-ease-out)`,
110+
}}
111+
>
112+
{open && (
113+
// biome-ignore lint/a11y/useSemanticElements: separator must be interactive (draggable + keyboard resize) — <hr> cannot carry pointer/keyboard handlers
114+
<div
115+
role="separator"
116+
aria-orientation="vertical"
117+
aria-valuenow={width}
118+
aria-valuemin={DRAWER_MIN_WIDTH}
119+
aria-valuemax={DRAWER_MAX_WIDTH}
120+
aria-label="Resize chat drawer"
121+
tabIndex={0}
122+
onPointerDown={onHandlePointerDown}
123+
onKeyDown={onHandleKeyDown}
124+
className="absolute left-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 cursor-col-resize outline-none focus-visible:bg-[var(--ds-accent)]/60 hover:bg-[var(--ds-accent)]/40"
125+
style={{ touchAction: "none" }}
126+
/>
127+
)}
128+
{open && (
129+
<div className="flex h-full w-full flex-col" style={{ width: `${width}px` }}>
130+
<header className="flex items-center justify-between gap-4 border-b border-[var(--ds-border)] px-4 py-3">
131+
<SectionHeader label="Chat" size="sm" />
132+
<button
133+
type="button"
134+
onClick={onToggle}
135+
aria-label="Collapse chat drawer"
136+
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-fg-muted)] transition-colors duration-[var(--ds-motion-fast)] hover:bg-[var(--ds-bg-hover)] hover:text-[var(--ds-fg-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ds-accent-ring)]"
137+
>
138+
<Icons.PanelRightClose className="size-4" />
139+
</button>
140+
</header>
141+
{children ? (
142+
<div className="flex-1 overflow-auto">{children}</div>
143+
) : (
144+
<div className="flex flex-1 flex-col gap-4 overflow-auto p-4">
145+
<Hairline label="Awaiting wire" />
146+
<p className="text-[var(--ds-text-sm)] text-[var(--ds-fg-muted)]">
147+
Chat shell mounts here in M6.5.
148+
</p>
149+
</div>
150+
)}
151+
</div>
152+
)}
153+
</aside>
154+
);
155+
}

0 commit comments

Comments
 (0)