dorfteich/apps/web/src/layout/AppLayout.tsx
Claude Fable 5 c8ec549fa5
All checks were successful
CD / Build and push images (push) Successful in 1m11s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m5s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m35s
CI / Import/export fidelity gate (push) Successful in 47s
Release / Build release images and notes (push) Successful in 1m6s
Release / Release-candidate operations QA (push) Successful in 40s
Prod deploy / Deploy the released images to Prod (push) Successful in 15s
UI polish: frameless plugin blocks in read mode, sticky toolbar, pinned footer, icon uninstall
Three refinements from Stefan's review of the plugin work:

- read mode integrates plugin output like normal content: no border, no
  name bar, no selection outline around plugin blocks — same principle
  as the frameless reading shell (M10)
- the editor toolbar pins to the top of the scrolling content area on
  long articles instead of scrolling away (position: sticky within the
  main scroll container)
- the app footer (connection status + legal links) moved out of the
  scroll container into a main-column wrapper — always visible at the
  bottom edge of the window on every view
- the plugin uninstall buttons in the admin list are icon buttons now
  (Trash2, house pattern: aria-label keeps the accessible name)
- lint hygiene: eslint/prettier ignore packages/plugins/*/vendor —
  the unpacked drawio webapp drove eslint out of memory

Verified in the browser against a local stack (5/5 scripted checks:
icon buttons, toolbar sticky at scroll bottom, footer pinned in edit
and read mode, plugin block computed border/outline none in read mode)
plus 8 layout-sensitive e2e packs re-run individually, all green
(comments, collab, legal, content, plugin-admin, plugin-blocks,
page-tools, plugins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 15:33:32 +02:00

80 lines
3.1 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { usePersistentState } from '../lib/use-persistent-state';
import { Footer } from './Footer';
import { PageActionsSlotContext } from './page-actions';
import { SidebarChromeContext } from './sidebar-chrome';
import { Sidebar } from './Sidebar';
import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './SidebarResizer';
import { TopBar } from './TopBar';
export function AppLayout(): React.JSX.Element {
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
const [sidebarWidth, setSidebarWidth] = usePersistentState(
'ui.sidebar.width',
SIDEBAR_DEFAULT_WIDTH_REM,
);
const [forcedHidden, setForcedHidden] = useState(false);
const collapsed = sidebarCollapsed || forcedHidden;
// Clamp on read too — the persisted value may predate a bounds change.
const widthRem = clampSidebarWidth(sidebarWidth);
// The TopBar registers its page-scoped elements here; the active page
// portals its icon actions (#101) and presence strip (#102) into them.
const [actionsElement, setActionsElement] = useState<HTMLElement | null>(null);
const [presenceElement, setPresenceElement] = useState<HTMLElement | null>(null);
const [statusElement, setStatusElement] = useState<HTMLElement | null>(null);
const actionsSlot = useMemo(
() => ({
element: actionsElement,
setElement: setActionsElement,
presenceElement,
setPresenceElement,
statusElement,
setStatusElement,
}),
[actionsElement, presenceElement, statusElement],
);
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of
// which element has focus.
useEffect(() => {
function handleKeyDown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key === '\\') {
event.preventDefault();
setSidebarCollapsed(!sidebarCollapsed);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [sidebarCollapsed, setSidebarCollapsed]);
return (
<SidebarChromeContext.Provider value={setForcedHidden}>
<PageActionsSlotContext.Provider value={actionsSlot}>
<div className="app">
<TopBar
sidebarCollapsed={collapsed}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<div
className="app-body"
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
>
<Sidebar collapsed={collapsed} />
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
<div className="main-column">
<main className="main">
<Outlet />
</main>
{/* Outside the scroll container: always visible at the bottom
edge of the window (M10 follow-up 3). */}
<Footer />
</div>
</div>
</div>
</PageActionsSlotContext.Provider>
</SidebarChromeContext.Provider>
);
}