Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 46 additions & 25 deletions apps/mapgen-studio/src/app/CanvasStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,19 @@ export type CanvasStageProps = {
/**
* `CanvasStage` — the full-bleed deck.gl host (architecture/10 §4).
*
* Purely presentational: it owns the theme-tinted backdrop, the optional
* decorative grid, the `DeckCanvas` mount, and the "Click Run to generate a map"
* empty state. All rendering inputs (layers, effective layer, viewport, bounds,
* deck api ref/ready handler) are passed in by `StudioShell`. This block was
* previously the inline `canvas` JSX inside `AppContent`; it is MOVED here
* verbatim so the DOM, classes, and Deck.gl inputs are unchanged — recoloring the
* hard-coded backdrop hexes is deferred to the design-craft slice, not this
* behavior-preserving decomposition.
* Purely presentational: it owns the token-driven backdrop, the optional
* decorative grid, the `DeckCanvas` mount, and the "awaiting matter" empty state.
* All rendering inputs (layers, effective layer, viewport, bounds, deck api
* ref/ready handler) are passed in by `StudioShell`.
*
* Craft (system.md lever #3): the chrome backdrop is the page substrate
* (`bg-background` — the token the design system names as "the deck.gl canvas
* backdrop"), so it follows the `.dark` theme instead of a hard-coded
* `lightMode` hex ternary. The radial vignette + grid are drawn in luminance
* (`--muted-foreground` at very low alpha), not hex. Before any matter exists,
* the empty stage frames the map with a subtle graticule + contour ring so it
* reads as *ready*, not hollow. `lightMode` is still forwarded to `DeckCanvas`
* because that governs deck.gl scene rendering, not the chrome.
*/
export function CanvasStage(props: CanvasStageProps) {
const {
Expand All @@ -45,31 +50,26 @@ export function CanvasStage(props: CanvasStageProps) {

return (
<div className="absolute inset-0">
<div className={`absolute inset-0 ${lightMode ? "bg-[#f5f5f7]" : "bg-[#0a0a12]"}`} />
{/* Theme-tinted backdrop (kept outside deck.gl so it remains stable and cheap) */}
{/* Page substrate — the design-system token named as the deck.gl backdrop. */}
<div className="absolute inset-0 bg-background" />
{/* Luminance vignette (cool-steel, very low alpha) — depth without chroma. */}
<div
className="absolute inset-0 opacity-30 pointer-events-none"
style={{
backgroundImage: lightMode
? `
radial-gradient(circle at 30% 40%, #cbd5e0 0%, transparent 55%),
radial-gradient(circle at 70% 60%, #cbd5e0 0%, transparent 45%),
radial-gradient(circle at 50% 80%, #cbd5e0 0%, transparent 35%)
`
: `
radial-gradient(circle at 30% 40%, #2d3748 0%, transparent 55%),
radial-gradient(circle at 70% 60%, #2d3748 0%, transparent 45%),
radial-gradient(circle at 50% 80%, #2d3748 0%, transparent 35%)
`,
backgroundImage: `
radial-gradient(circle at 30% 40%, hsl(var(--muted-foreground) / 0.18) 0%, transparent 55%),
radial-gradient(circle at 70% 60%, hsl(var(--muted-foreground) / 0.18) 0%, transparent 45%),
radial-gradient(circle at 50% 80%, hsl(var(--muted-foreground) / 0.18) 0%, transparent 35%)
`,
}}
/>
{backgroundGridEnabled ? (
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `
linear-gradient(${lightMode ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.02)"} 1px, transparent 1px),
linear-gradient(90deg, ${lightMode ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.02)"} 1px, transparent 1px)
linear-gradient(hsl(var(--muted-foreground) / 0.06) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--muted-foreground) / 0.06) 1px, transparent 1px)
`,
backgroundSize: "56px 56px",
}}
Expand All @@ -85,9 +85,30 @@ export function CanvasStage(props: CanvasStageProps) {
lightMode={lightMode}
activeBounds={activeBounds}
/>
{/* Awaiting matter: a graticule field + a centered contour frame so the
empty stage reads as a survey console that is ready, not dead space. */}
{!hasManifest ? (
<div className="absolute inset-0 flex items-center justify-center text-[12px] text-[#7a7a8c]">
Click Run to generate a map
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute inset-0 opacity-40"
style={{
backgroundImage: `
linear-gradient(hsl(var(--muted-foreground) / 0.05) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--muted-foreground) / 0.05) 1px, transparent 1px)
`,
backgroundSize: "120px 120px",
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 rounded-xl border border-border/60 bg-popover/40 px-8 py-6 text-center backdrop-blur-sm">
<span className="text-label uppercase tracking-[0.2em] text-muted-foreground/70">
Awaiting matter
</span>
<span className="text-data font-medium text-muted-foreground">
Click Run to generate a map
</span>
</div>
</div>
</div>
) : null}
</div>
Expand Down
5 changes: 5 additions & 0 deletions apps/mapgen-studio/src/app/ErrorBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export type ErrorBannerProps = {
export function ErrorBanner({ message, top }: ErrorBannerProps) {
if (!message) return null;
return (
// `role="alert"` + `aria-live="assertive"` so assistive tech announces a
// generation/live failure as soon as it appears (it is the one interruptive
// banner). It is dismissed by resolution (the parent passes `null`).
<div
role="alert"
aria-live="assertive"
className="absolute left-1/2 -translate-x-1/2 z-30 max-w-[min(720px,calc(100%-32px))] rounded-lg border border-destructive/40 bg-destructive/15 px-4 py-2 text-xs text-destructive backdrop-blur-sm"
style={{ top }}
>
Expand Down
4 changes: 2 additions & 2 deletions apps/mapgen-studio/src/app/LeftDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export type LeftDockProps = {
*/
export function LeftDock({ top, children }: LeftDockProps) {
return (
<div className="absolute left-4 z-10" style={{ top }}>
<aside aria-label="Recipe and configuration" className="absolute left-4 z-10" style={{ top }}>
{children}
</div>
</aside>
);
}
4 changes: 2 additions & 2 deletions apps/mapgen-studio/src/app/RightDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export type RightDockProps = {
*/
export function RightDock({ top, children }: RightDockProps) {
return (
<div className="absolute right-4 z-10" style={{ top }}>
<aside aria-label="Explore and inspect" className="absolute right-4 z-10" style={{ top }}>
{children}
</div>
</aside>
);
}
52 changes: 41 additions & 11 deletions apps/mapgen-studio/src/app/StudioShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2499,19 +2499,49 @@ export function StudioShell(props: StudioShellProps) {
</>
);

// Polite, visually-hidden mirror of the volatile run/live status so assistive
// tech is told when generation is running, the live Civ7 runtime changes, or a
// Run-in-Game / save-deploy operation moves — without stealing focus. The
// visible chrome (footer status panel) carries the same information.
const liveStatusAnnouncement = [
status === "running" ? "Generating map" : status === "error" ? "Generation error" : "Ready",
liveRuntime.status === "ok"
? `Live Civ7 ${liveRuntime.turn !== undefined || liveRuntime.seed !== undefined ? `turn ${liveRuntime.turn ?? "?"} seed ${liveRuntime.seed ?? "?"}` : liveRuntime.readiness ?? "ready"}`
: liveRuntime.status === "error"
? "Live Civ7 unavailable"
: null,
runInGameOperation ? `Run in Game ${runInGameOperation.phase}` : null,
saveDeployOperation && saveDeployOperation.status === "running" ? `Save/deploy ${saveDeployOperation.phase}` : null,
]
.filter(Boolean)
.join(". ");

return (
<div ref={containerRef} className="relative w-full min-h-screen bg-background">
<CanvasStage
apiRef={deckApiRef}
onApiReady={handleDeckApiReady}
layers={viz.deck.layers}
effectiveLayer={viz.effectiveLayer}
viewportSize={viewportSize}
activeBounds={viz.activeBounds}
lightMode={isLightMode}
backgroundGridEnabled={backgroundGridEnabled}
hasManifest={Boolean(viz.manifest)}
/>
{/* Skip link: first focusable element, visually hidden until focused. */}
<a
href="#map-preview"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:border focus:border-border focus:bg-popover focus:px-3 focus:py-2 focus:text-data focus:font-medium focus:text-foreground focus:shadow-md focus:outline-none focus:ring-1 focus:ring-ring"
>
Skip to map preview
</a>
{/* Polite live region (visually hidden mirror of the run/live status). */}
<div aria-live="polite" className="sr-only">
{liveStatusAnnouncement}
</div>
<main id="map-preview" aria-label="Map preview" tabIndex={-1} className="contents">
<CanvasStage
apiRef={deckApiRef}
onApiReady={handleDeckApiReady}
layers={viz.deck.layers}
effectiveLayer={viz.effectiveLayer}
viewportSize={viewportSize}
activeBounds={viz.activeBounds}
lightMode={isLightMode}
backgroundGridEnabled={backgroundGridEnabled}
hasManifest={Boolean(viz.manifest)}
/>
</main>
{presetDialogs}
<input
ref={importInputRef}
Expand Down
Loading