Files
astrolabe/src/app/App.tsx
T
oleh 807d3c8e3c Add global keyboard EventRouter and unify publish (M6, §01D)
One module owns the document keydown listener and dispatches the shortcut map
(Cmd/Ctrl+Shift+N / +K / +S / +, / Escape), platform-aware, via the single-source
focus-utils interactive-context gate. Escape and Cmd/Ctrl+S run before the gate so
save works while editing; the rest are suppressed mid-typing. Publish + its toast
move into services/snippet-actions so the button and Cmd/Ctrl+S behave identically.
Removes the ad-hoc keydown handler from App and Monaco's Cmd+S command.
2026-06-07 17:18:55 +03:00

157 lines
6.2 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { ConfirmDialog } from './components/ConfirmDialog';
import { LivePreview } from './components/LivePreview';
import { ModalShell } from './components/ModalShell';
import { PaneToggleStrip } from './components/PaneToggleStrip';
import { ResizeHandle } from './components/ResizeHandle';
import { SnippetLibrary } from './components/SnippetLibrary';
import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle';
import { Toaster } from './components/Toaster';
import { openModal, setConfirm } from './modals/ModalCoordinator';
import { confirm } from './stores/ConfirmStore';
import { usePanesStore } from './stores/PanesStore';
import { exportWorkspace, importWorkspace } from './services/transfer';
import styles from './App.module.css';
/**
* Application shell — the three-pane workspace from spec §01A
* (library · editor · preview) under a fixed header.
*
* The center editor flexes; the library and preview carry remembered widths and
* are resized via the drag handles between them (spec §01A). Each pane can be
* shown/hidden from the always-present toggle strip (the leftmost rail); a hidden
* pane frees its space and the rest redistribute — when the editor is hidden the
* two side panes flex proportionally to their remembered widths.
*/
export function App() {
const libraryWidth = usePanesStore((s) => s.libraryWidth);
const previewWidth = usePanesStore((s) => s.previewWidth);
const libraryVisible = usePanesStore((s) => s.libraryVisible);
const editorVisible = usePanesStore((s) => s.editorVisible);
const previewVisible = usePanesStore((s) => s.previewVisible);
// Side-pane sizing: fixed remembered width while the editor (the flex filler) is
// present; when it's hidden, the side panes grow proportionally to those widths
// so they fill the freed space (spec §01A → "redistributing proportionally").
const sideStyle = (width: number): React.CSSProperties =>
editorVisible ? { width } : { flex: `${width} 1 0` };
// Hidden file input driving Import — the header button proxies its click so the
// browser file picker is the only chrome (spec §08 → no intermediate dialog).
const fileInputRef = useRef<HTMLInputElement>(null);
// Route the modal coordinator's discard prompt through the in-app confirm
// dialog (docs/architecture/03 → "The coordinator seam").
useEffect(() => {
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
}, []);
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file again still fires onChange.
e.target.value = '';
if (file) void importWorkspace(file);
};
return (
<div className={styles.app}>
{/* Skip link (WCAG 2.4.1 / GOV.UK): the first focusable element, hidden
until focused, lets keyboard users bypass the header into the work area. */}
<a className={styles.skipLink} href="#main">
Skip to content
</a>
<header className={styles.header}>
<h1 className={styles.title}>Astrolabe</h1>
<span className={styles.version}>v{__APP_VERSION__}</span>
<span className={styles.spacer} />
<button
type="button"
className={styles.headerButton}
onClick={() => openModal('datasets')}
aria-keyshortcuts="Meta+K Control+K"
title="Datasets (⌘/Ctrl+K)"
>
Datasets
</button>
<button
type="button"
className={styles.headerButton}
onClick={() => fileInputRef.current?.click()}
title="Import a workspace JSON file"
>
Import
</button>
<button
type="button"
className={styles.headerButton}
onClick={() => exportWorkspace()}
title="Export your workspace to a JSON file"
>
Export
</button>
<ThemeToggle />
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className={styles.hiddenInput}
onChange={handleImportFile}
aria-hidden="true"
tabIndex={-1}
/>
</header>
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
<main id="main" className={styles.panes} tabIndex={-1}>
{/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */}
<PaneToggleStrip />
{libraryVisible && (
<section
id="pane-library"
className={styles.pane}
style={sideStyle(libraryWidth)}
aria-label="Snippet library"
>
<SnippetLibrary />
</section>
)}
{/* A resize handle only sits between two visible panes that flank the editor. */}
{libraryVisible && editorVisible && (
<ResizeHandle side="library" label="Resize snippet library" />
)}
{editorVisible && (
<section id="pane-editor" className={styles.paneEditor} aria-label="Spec editor">
<SpecEditor />
</section>
)}
{editorVisible && previewVisible && (
<ResizeHandle side="preview" label="Resize live preview" />
)}
{previewVisible && (
<section
id="pane-preview"
className={styles.pane}
style={sideStyle(previewWidth)}
aria-label="Live preview"
>
<LivePreview />
</section>
)}
</main>
{/* The one feature modal (Datasets / Extract / …), rendered from the
registry by the shared shell. At most one open at a time (spec §01C). */}
<ModalShell />
{/* Global confirmation layer — sits above the feature-modal shell so a
discard-changes prompt can appear over an open modal. */}
<ConfirmDialog />
{/* Non-blocking notifications (failed saves, etc.) — top-right toasts,
layered above the confirm backdrop so a failure stays visible. */}
<Toaster />
</div>
);
}