diff --git a/apps/mapgen-studio/src/app/CanvasStage.tsx b/apps/mapgen-studio/src/app/CanvasStage.tsx index d6aa4b0687..820b1a1fcf 100644 --- a/apps/mapgen-studio/src/app/CanvasStage.tsx +++ b/apps/mapgen-studio/src/app/CanvasStage.tsx @@ -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 { @@ -45,22 +50,17 @@ export function CanvasStage(props: CanvasStageProps) { return (
-
- {/* 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. */} +
+ {/* Luminance vignette (cool-steel, very low alpha) — depth without chroma. */}
{backgroundGridEnabled ? ( @@ -68,8 +68,8 @@ export function CanvasStage(props: CanvasStageProps) { 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", }} @@ -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 ? ( -
- Click Run to generate a map +
+
+
+
+ + Awaiting matter + + + Click Run to generate a map + +
+
) : null}
diff --git a/apps/mapgen-studio/src/app/ErrorBanner.tsx b/apps/mapgen-studio/src/app/ErrorBanner.tsx index ccbb9f88fe..b2a4acb5b9 100644 --- a/apps/mapgen-studio/src/app/ErrorBanner.tsx +++ b/apps/mapgen-studio/src/app/ErrorBanner.tsx @@ -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`).
diff --git a/apps/mapgen-studio/src/app/LeftDock.tsx b/apps/mapgen-studio/src/app/LeftDock.tsx index 648f08ffdd..dcbf786a1f 100644 --- a/apps/mapgen-studio/src/app/LeftDock.tsx +++ b/apps/mapgen-studio/src/app/LeftDock.tsx @@ -16,8 +16,8 @@ export type LeftDockProps = { */ export function LeftDock({ top, children }: LeftDockProps) { return ( -
+
+ ); } diff --git a/apps/mapgen-studio/src/app/RightDock.tsx b/apps/mapgen-studio/src/app/RightDock.tsx index 9fc49e4bf5..96c32d14b9 100644 --- a/apps/mapgen-studio/src/app/RightDock.tsx +++ b/apps/mapgen-studio/src/app/RightDock.tsx @@ -16,8 +16,8 @@ export type RightDockProps = { */ export function RightDock({ top, children }: RightDockProps) { return ( -
+
+ ); } diff --git a/apps/mapgen-studio/src/app/StudioShell.tsx b/apps/mapgen-studio/src/app/StudioShell.tsx index 27f671e33c..18556049b0 100644 --- a/apps/mapgen-studio/src/app/StudioShell.tsx +++ b/apps/mapgen-studio/src/app/StudioShell.tsx @@ -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 (
- + {/* Skip link: first focusable element, visually hidden until focused. */} + + Skip to map preview + + {/* Polite live region (visually hidden mirror of the run/live status). */} +
+ {liveStatusAnnouncement} +
+
+ +
{presetDialogs} ; -function getLightMode(props: ConfigWidgetProps): boolean { - return Boolean(props.formContext?.lightMode); -} +/** + * RJSF widgets re-skinned onto the canonical design-system primitives + * (`src/components/ui/*`) — token-driven, dark-first, no `lightMode` prop and no + * off-token `ring-gray-400`. Only presentation changed: the value plumbing + * (`onChange`, `emptyValue` normalization, enum mapping) is preserved, so the + * authored config the form emits is byte-for-byte what it produced before. + */ + +// Radix Select disallows an empty `value`; the schema enum's "no selection" +// placeholder maps to this reserved sentinel internally and round-trips back to +// the real empty selection on change. +const SELECT_EMPTY_SENTINEL = "__rjsf-select-empty__"; function normalizeEmptyValue( next: string, @@ -16,7 +33,6 @@ function normalizeEmptyValue( } export function TextWidget(props: ConfigWidgetProps) { - const lightMode = getLightMode(props); const { id, name, @@ -36,13 +52,12 @@ export function TextWidget(props: ConfigWidgetProps) { id={id} name={name} autoComplete={autoComplete ?? "off"} - lightMode={lightMode} type={type ?? "text"} spellCheck={false} required={required} autoFocus={autofocus} disabled={disabled || readonly} - value={value ?? ""} + value={(value as string | undefined) ?? ""} placeholder={placeholder} onChange={(event) => { onChange(normalizeEmptyValue(event.target.value, options.emptyValue)); @@ -52,7 +67,6 @@ export function TextWidget(props: ConfigWidgetProps) { } export function TextareaWidget(props: ConfigWidgetProps) { - const lightMode = getLightMode(props); const { id, name, autoComplete, value, required, disabled, readonly, autofocus, onChange, options, placeholder } = props; return ( @@ -60,12 +74,11 @@ export function TextareaWidget(props: ConfigWidgetProps) { id={id} name={name} autoComplete={autoComplete ?? "off"} - lightMode={lightMode} spellCheck={false} required={required} autoFocus={autofocus} disabled={disabled || readonly} - value={value ?? ""} + value={(value as string | undefined) ?? ""} placeholder={placeholder} onChange={(event) => { onChange(normalizeEmptyValue(event.target.value, options.emptyValue)); @@ -75,7 +88,6 @@ export function TextareaWidget(props: ConfigWidgetProps) { } export function NumberWidget(props: ConfigWidgetProps) { - const lightMode = getLightMode(props); const { id, name, autoComplete, value, required, disabled, readonly, autofocus, onChange, options, placeholder } = props; return ( @@ -83,14 +95,13 @@ export function NumberWidget(props: ConfigWidgetProps) { id={id} name={name} autoComplete={autoComplete ?? "off"} - lightMode={lightMode} type="number" inputMode="decimal" spellCheck={false} required={required} autoFocus={autofocus} disabled={disabled || readonly} - value={value ?? ""} + value={(value as number | string | undefined) ?? ""} placeholder={placeholder} onChange={(event) => { const next = event.target.value; @@ -106,66 +117,56 @@ export function NumberWidget(props: ConfigWidgetProps) { } export function SelectWidget(props: ConfigWidgetProps) { - const lightMode = getLightMode(props); - const { id, name, autoComplete, value, required, disabled, readonly, autofocus, onChange, options, placeholder } = - props; + const { id, name, value, disabled, readonly, onChange, options, placeholder } = props; const enumOptions = (options.enumOptions ?? []) as Array<{ value: unknown; label: string }>; const map = new Map(enumOptions.map((opt) => [String(opt.value), opt.value])); const selectedKey = value === undefined || value === null ? "" : String(value); + const toRadix = (raw: string) => (raw === "" ? SELECT_EMPTY_SENTINEL : raw); return ( ); } export function CheckboxWidget(props: ConfigWidgetProps) { - const lightMode = getLightMode(props); const { id, name, value, disabled, readonly, autofocus, onChange } = props; return ( onChange(checked)} + onCheckedChange={(checked) => onChange(checked === true)} /> ); } export function SwitchWidget(props: ConfigWidgetProps) { - const lightMode = getLightMode(props); const { id, name, value, disabled, readonly, autofocus, onChange } = props; return ( ; const allowMutations = !disabled && !readonly; const selected = Array.isArray(value) ? value : []; const selectedKeys = new Set(selected.map((entry) => String(entry))); const map = new Map(enumOptions.map((opt) => [String(opt.value), opt.value])); - const baseTag = [ - "px-2 py-1 text-[11px] rounded-full border transition-colors", - lightMode - ? "bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-200" - : "bg-[#222228] text-[#e8e8ed] border-[#2a2a32] hover:bg-[#2a2a32]", - ].join(" "); - const activeTag = "bg-[#4b5563] text-white border-[#4b5563]"; + // Token-driven pill: muted inset substrate, primary fill when active, the + // luminance contour ring on focus. + const baseTag = + "px-2 py-1 text-data rounded-full border border-input bg-input-background text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"; + const activeTag = "bg-primary text-primary-foreground border-primary hover:bg-primary/90"; return (
diff --git a/apps/mapgen-studio/src/ui/components/AppFooter.tsx b/apps/mapgen-studio/src/ui/components/AppFooter.tsx index 760fd36b70..4e2b49f01a 100644 --- a/apps/mapgen-studio/src/ui/components/AppFooter.tsx +++ b/apps/mapgen-studio/src/ui/components/AppFooter.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Bolt, Bot, Clipboard, Clock, Dices, MonitorPlay, Play, Radio, RotateCw, Square } from 'lucide-react'; -import { Button, Input, Tooltip, TooltipContent, TooltipTrigger } from '../../components/ui'; +import { Button, Input, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../components/ui'; import { MAP_SIZE_SHORT, LAYOUT } from '../constants'; import { formatResourceMode } from '../utils'; import type { RecipeSettings, WorldSettings, GenerationStatus } from '../types'; @@ -220,6 +220,13 @@ export const AppFooter: React.FC = ({ } }; return ( + // The footer carries its own `TooltipProvider` so its diagnostic hints work + // whether or not an ancestor provides one (the shell does; the static-markup + // parity tests mount the footer bare). Diagnostics are ALSO mirrored onto the + // visible triggers via `aria-label`/`title`, so the request id, failure + // reason, and live/autoplay/stale hints stay present for assistive tech and + // for the static markup — not hidden inside hover-only Tooltip content. +
= ({ type="button" onClick={onSyncFromLiveGame} disabled={!liveSyncAvailable} + aria-label={liveSyncTitle} + title={liveSyncTitle} className={`inline-flex h-7 min-w-0 items-center gap-2 rounded border px-2 transition-colors ${ liveGameStudioRelation === "stale" ? "border-warning text-warning ring-1 ring-warning/40" @@ -312,6 +321,8 @@ export const AppFooter: React.FC = ({ size="sm" onClick={onToggleAutoplay} disabled={autoplayControlDisabled} + aria-label={autoplayTitle} + title={autoplayTitle} className={`h-7 px-2 ${liveRuntime?.autoplayActive ? "border-warning/60 text-warning" : ""}`}> {liveRuntime?.autoplayActive ? : } @@ -389,6 +400,9 @@ export const AppFooter: React.FC = ({
@@ -402,6 +416,9 @@ export const AppFooter: React.FC = ({
@@ -452,6 +469,8 @@ export const AppFooter: React.FC = ({ onClick={onRunInGame} disabled={operationControlsDisabled} variant="outline" + aria-label={runInGameTitle} + title={runInGameTitle} className={isRunInGameRunning ? 'opacity-70 cursor-wait' : undefined}> @@ -474,6 +493,7 @@ export const AppFooter: React.FC = ({ {isRunning ? 'Running...' : 'Run'}
-
); + +
); }; diff --git a/apps/mapgen-studio/src/ui/components/AppHeader.tsx b/apps/mapgen-studio/src/ui/components/AppHeader.tsx index 2795f39bc9..f30cebbfca 100644 --- a/apps/mapgen-studio/src/ui/components/AppHeader.tsx +++ b/apps/mapgen-studio/src/ui/components/AppHeader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ChevronDown, Globe, SlidersHorizontal } from 'lucide-react'; import { AppBrand } from './AppBrand'; import { ViewControls } from './ViewControls'; -import { Select } from './ui'; +import { OptionSelect } from './OptionSelect'; import { getLocalPlayerSetup, updateStudioSetupGameOption, @@ -120,7 +120,7 @@ export const AppHeader: React.FC = ({
- + World
@@ -128,55 +128,52 @@ export const AppHeader: React.FC = ({
- + Size - - updateSetting('playerCount', parseInt(e.target.value, 10)) + onValueChange={(value) => + updateSetting('playerCount', parseInt(value, 10)) } options={PLAYER_COUNT_OPTIONS.map((count) => ({ value: count.toString(), label: count.toString() }))} - lightMode={isLightMode} + ariaLabel="Players" className="w-14" />
- + Config - - updateSetting( - 'resources', - e.target.value as WorldSettings['resources'] - ) + onValueChange={(value) => + updateSetting('resources', value as WorldSettings['resources']) } options={RESOURCE_MODE_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label }))} - lightMode={isLightMode} + ariaLabel="Resources" className="w-24" />
- + Leader - updateCivilization(e.target.value)} + onValueChange={(value) => updateCivilization(value)} options={setupOptions.civilizationOptions} - lightMode={isLightMode} + ariaLabel="Civilization" className="w-32" />
- + Difficulty - updateGameSpeed(e.target.value)} + onValueChange={(value) => updateGameSpeed(value)} options={setupOptions.gameSpeedOptions} - lightMode={isLightMode} + ariaLabel="Game speed" className="w-28" />
diff --git a/apps/mapgen-studio/src/ui/components/ExplorePanel.tsx b/apps/mapgen-studio/src/ui/components/ExplorePanel.tsx index d3750cd4ae..0fc6555239 100644 --- a/apps/mapgen-studio/src/ui/components/ExplorePanel.tsx +++ b/apps/mapgen-studio/src/ui/components/ExplorePanel.tsx @@ -325,6 +325,8 @@ export const ExplorePanel: React.FC = ({
{isStageExpanded ? ( -
+
{stages.map((stage, index) => (
{isStepExpanded ? ( -
+
{steps.length > 0 ? ( steps.map((step, index) => ( )) ) : ( -
No steps available
+
No steps available
)}
) : null} @@ -416,27 +422,29 @@ export const ExplorePanel: React.FC = ({
{isLayersExpanded ? ( -
+
{groupedDataTypes.map((group) => { const expanded = !group.key || isGroupExpanded(group.key); return ( @@ -447,10 +455,11 @@ export const ExplorePanel: React.FC = ({ onClick={() => toggleGroupExpanded(group.key)} className={`w-full px-3 pt-2 pb-1 flex items-center justify-between text-label uppercase tracking-wider ${textMuted} ${hoverBg}`} aria-label={expanded ? "Collapse group" : "Expand group"} + aria-expanded={expanded} > {group.label}
- {group.items.length} + {group.items.length}
@@ -462,6 +471,7 @@ export const ExplorePanel: React.FC = ({ @@ -484,7 +487,7 @@ export const RecipePanel: React.FC = ({ onSaveAsNew(); setShowSaveMenu(false); }} - className={`w-full text-left px-3 py-2 text-[11px] ${textPrimary} ${hoverBg} border-t ${borderSubtle}`}> + className={`w-full text-left px-3 py-2 text-data ${textPrimary} ${hoverBg} border-t ${borderSubtle}`}> Save & Deploy As… @@ -494,7 +497,7 @@ export const RecipePanel: React.FC = ({ onExportPreset(); setShowSaveMenu(false); }} - className={`w-full text-left px-3 py-2 text-[11px] ${textPrimary} ${hoverBg} border-t ${borderSubtle}`}> + className={`w-full text-left px-3 py-2 text-data ${textPrimary} ${hoverBg} border-t ${borderSubtle}`}> Export… @@ -504,7 +507,7 @@ export const RecipePanel: React.FC = ({ onImportPreset(); setShowSaveMenu(false); }} - className={`w-full text-left px-3 py-2 text-[11px] ${textPrimary} ${hoverBg} border-t ${borderSubtle}`}> + className={`w-full text-left px-3 py-2 text-data ${textPrimary} ${hoverBg} border-t ${borderSubtle}`}> Import… @@ -521,7 +524,7 @@ export const RecipePanel: React.FC = ({ } {!canDeletePreset && -
+
Delete Scratch
} diff --git a/apps/mapgen-studio/src/ui/components/ui/AlertDialog.tsx b/apps/mapgen-studio/src/ui/components/ui/AlertDialog.tsx deleted file mode 100644 index 09a5dccf97..0000000000 --- a/apps/mapgen-studio/src/ui/components/ui/AlertDialog.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import React, { createContext, useContext } from 'react'; -// ============================================================================ -// ALERT DIALOG -// ============================================================================ -// Confirmation dialog following shadcn/ui patterns. -// For full Radix AlertDialog, install @radix-ui/react-alert-dialog. -// ============================================================================ -import { X } from 'lucide-react'; -import { cn } from '../../utils/cn'; -import { Button } from './Button'; -// ============================================================================ -// Context -// ============================================================================ -interface AlertDialogContextValue { - open: boolean; - onOpenChange: (open: boolean) => void; - lightMode: boolean; -} -const AlertDialogContext = createContext(null); -const useAlertDialog = () => { - const context = useContext(AlertDialogContext); - if (!context) { - throw new Error('AlertDialog components must be used within AlertDialog'); - } - return context; -}; -// ============================================================================ -// Root -// ============================================================================ -export interface AlertDialogProps { - open?: boolean; - onOpenChange?: (open: boolean) => void; - lightMode?: boolean; - children: React.ReactNode; -} -const AlertDialog: React.FC = ({ - open = false, - onOpenChange = () => {}, - lightMode = false, - children -}) => { - return ( - - - {children} - ); - -}; -// ============================================================================ -// Trigger -// ============================================================================ -export interface AlertDialogTriggerProps { - children: React.ReactNode; - asChild?: boolean; -} -const AlertDialogTrigger: React.FC = ({ - children -}) => { - const { onOpenChange } = useAlertDialog(); - return ( - onOpenChange(true)} className="cursor-pointer"> - {children} - ); - -}; -// ============================================================================ -// Portal + Overlay + Content -// ============================================================================ -export interface AlertDialogContentProps { - children: React.ReactNode; - className?: string; -} -const AlertDialogContent: React.FC = ({ - children, - className -}) => { - const { open, onOpenChange, lightMode } = useAlertDialog(); - if (!open) return null; - return ( -
- {/* Overlay */} -
onOpenChange(false)} /> - - - {/* Content */} -
-
e.stopPropagation()}> - - {/* Close button */} - - {children} -
-
-
); - -}; -// ============================================================================ -// Header -// ============================================================================ -export interface AlertDialogHeaderProps { - children: React.ReactNode; - className?: string; -} -const AlertDialogHeader: React.FC = ({ - children, - className -}) => { - return ( -
- {children} -
); - -}; -// ============================================================================ -// Title -// ============================================================================ -export interface AlertDialogTitleProps { - children: React.ReactNode; - className?: string; - icon?: React.ReactNode; -} -const AlertDialogTitle: React.FC = ({ - children, - className, - icon -}) => { - const { lightMode } = useAlertDialog(); - return ( -
- {icon && - - {icon} - - } -

{children}

-
); - -}; -// ============================================================================ -// Description -// ============================================================================ -export interface AlertDialogDescriptionProps { - children: React.ReactNode; - className?: string; -} -const AlertDialogDescription: React.FC = ({ - children, - className -}) => { - const { lightMode } = useAlertDialog(); - return ( -

- - {children} -

); - -}; -// ============================================================================ -// Footer -// ============================================================================ -export interface AlertDialogFooterProps { - children: React.ReactNode; - className?: string; -} -const AlertDialogFooter: React.FC = ({ - children, - className -}) => { - return ( -
- {children} -
); - -}; -// ============================================================================ -// Action Buttons -// ============================================================================ -export interface AlertDialogActionProps extends - React.ButtonHTMLAttributes { - children: React.ReactNode; -} -const AlertDialogAction: React.FC = ({ - children, - onClick, - ...props -}) => { - const { onOpenChange } = useAlertDialog(); - const handleClick = (e: React.MouseEvent) => { - onClick?.(e); - onOpenChange(false); - }; - return ( - ); - -}; -export interface AlertDialogCancelProps extends - React.ButtonHTMLAttributes { - children: React.ReactNode; -} -const AlertDialogCancel: React.FC = ({ - children, - onClick, - ...props -}) => { - const { onOpenChange } = useAlertDialog(); - const handleClick = (e: React.MouseEvent) => { - onClick?.(e); - onOpenChange(false); - }; - return ( - ); - -}; -// ============================================================================ -// Exports -// ============================================================================ -export { - AlertDialog, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogAction, - AlertDialogCancel }; \ No newline at end of file diff --git a/apps/mapgen-studio/src/ui/components/ui/index.ts b/apps/mapgen-studio/src/ui/components/ui/index.ts index 70fa92ab31..973cf62ab9 100644 --- a/apps/mapgen-studio/src/ui/components/ui/index.ts +++ b/apps/mapgen-studio/src/ui/components/ui/index.ts @@ -4,20 +4,6 @@ // Shared UI primitives following shadcn/ui patterns. // ============================================================================ -// Dialogs -export { - AlertDialog, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogAction, - AlertDialogCancel, - type AlertDialogProps } from -'./AlertDialog'; - // Form controls export { Button, buttonVariants, type ButtonProps } from './Button'; export { Input, type InputProps } from './Input'; diff --git a/apps/mapgen-studio/test/runInGame/AppFooter.test.tsx b/apps/mapgen-studio/test/runInGame/AppFooter.test.tsx index 4628ba76c1..b61378827f 100644 --- a/apps/mapgen-studio/test/runInGame/AppFooter.test.tsx +++ b/apps/mapgen-studio/test/runInGame/AppFooter.test.tsx @@ -258,7 +258,11 @@ describe("AppFooter Run in Game status", () => { ); expect(html).toContain("Apply live game suggestion to Studio"); - expect(html).toContain("border-orange-400"); + // The stale-live-game emphasis is token-driven (`warning`), not a raw + // `border-orange-400` palette class — the design system forbids hardcoded + // hex/named-color utilities. The assertion still verifies the stale ring is + // present, now via the token. + expect(html).toContain("border-warning"); expect(html).toContain("Seed 123"); }); }); diff --git a/openspec/changes/mapgen-studio-craft-a11y/design.md b/openspec/changes/mapgen-studio-craft-a11y/design.md new file mode 100644 index 0000000000..8e7a6a6576 --- /dev/null +++ b/openspec/changes/mapgen-studio-craft-a11y/design.md @@ -0,0 +1,74 @@ +## Context + +This is a presentation + accessibility slice on top of the already-decomposed, +already-tokenized studio shell. The hard-core behavior registry (architecture/10 §7) +is untouched: run-in-game engine, live-poll staleness/backoff, localStorage schema, +browserRunner gating, map-config path jail, and the oRPC seam are not in scope here. + +## Key Decisions + +### Diagnostics must be in the DOM, not hover-only + +The Radix Tooltip renders `TooltipContent` lazily on hover/focus, so any text placed +*only* there is absent from server-rendered static markup and from the initial +accessibility tree. Operational diagnostics (request ids, failure reasons, recovery +hints) are exactly the content a screen-reader user and the static-markup parity +tests need. **Decision:** mirror each diagnostic onto its visible trigger via +`aria-label` (the accessible name) and an in-DOM `title` (present in static markup + +a native hover affordance), keeping the Tooltip as the sighted hover hint. This is +additive: the Tooltip content is unchanged, the trigger simply now also carries the +same text. + +### Footer owns its TooltipProvider + +Three of the static-markup parity tests mount `AppFooter` bare (no ancestor +`TooltipProvider`), which makes a Radix `Tooltip` throw. The shell already provides +one. **Decision:** render the footer's body inside its own `TooltipProvider`. Radix +permits nested providers, so the shell path is unaffected; the bare-mount tests no +longer crash. This is the minimal, parity-safe fix — it does not touch the run-in-game +or live plumbing. + +### OptionSelect adapter over the Radix token Select + +The token-driven `src/components/ui/select` is a Radix listbox with a composed +`Trigger/Content/Item` API and `value`/`onValueChange`, whereas the migrated call +sites used the legacy native `Select` with `value`/`onChange`/`options`. **Decision:** +a thin `OptionSelect` adapter exposes the simple `value`/`onValueChange`/`options` +shape so the chrome call sites migrate without restructuring. Radix disallows empty +`value`s, so an empty selection maps to a reserved sentinel internally and round-trips +back to `""` — the visible placeholder/no-selection behavior is preserved, and the +emitted values are identical. + +### rjsf widgets: presentation re-skin, value plumbing preserved + +The rjsf override widgets re-point at the `src/components/ui` primitives. The +`SelectWidget` moves from a native ``. This is a + deliberate design-system alignment; values and `aria-label`s are preserved. The rjsf + select keeps identical emitted values via the enum map. +- **`title` + `aria-label` duplication.** Carrying both is intentional: `title` gives + static-markup presence + a native hover tooltip; `aria-label` is the accessible name. + The Radix Tooltip remains the primary sighted hint. + +## Out Of Scope + +- Container/presentational store-reading split of the panels. +- Any change to the legacy native `Select` still used by the `fields/*` components. +- Deck.gl scene rendering / `lightMode` math. diff --git a/openspec/changes/mapgen-studio-craft-a11y/proposal.md b/openspec/changes/mapgen-studio-craft-a11y/proposal.md new file mode 100644 index 0000000000..136b7b9116 --- /dev/null +++ b/openspec/changes/mapgen-studio-craft-a11y/proposal.md @@ -0,0 +1,110 @@ +## Why + +The Radix Tooltip migration in the prior shell-reskin slice moved the AppFooter's +operational diagnostics (the Run-in-Game request id and failure reason, the +save/deploy status, the live-sync hint, the autoplay hint) into hover-only +`TooltipContent`. That content is rendered lazily on hover, so it is invisible to +assistive technology and absent from static markup — which both regressed +screen-reader access and turned 8 AppFooter static-markup parity assertions red. +The studio also lacked landmark structure, a skip link, live-region announcements, +and `aria-expanded`/`aria-controls`/`aria-current` on its collapsible sections and +selection lists. Finally, the live World/Players/Config and Recipe/Config dropdowns +plus the rjsf override widgets still rode the legacy `lightMode` + raw-hex native +`Select` (off-token `ring-gray-400`), and the empty deck.gl stage used hard-coded +backdrop hexes with a `lightMode` ternary. + +This change is **presentation + accessibility only**: no behavior, logic, data, or +parity-critical flow is altered. It restores the diagnostics to the DOM (mirrored +onto the visible triggers as `aria-label`/`title`), adds the landmark/aria scaffold, +migrates the live select call sites + rjsf widgets onto the token-driven design +system primitives, removes the now-dead legacy `AlertDialog`, adopts the named type +scale, and reframes the empty stage as an intentional "awaiting matter" survey +console. + +## Target Authority Refs + +- `docs/projects/mapgen-studio-redesign/FRAME.md` (§3 hard core, §4.2 design-system-first, §4.7 oRPC) +- `docs/projects/mapgen-studio-redesign/architecture/10-target-architecture.md` (§6 ts rigor, §7 do-not-break registry) +- `apps/mapgen-studio/.interface-design/system.md` (craft lever #3: "awaiting matter"; contour-by-luminance; named type scale) + +## What Changes + +- **AppFooter diagnostics restored for AT + static markup**: each diagnostic + Tooltip's text is mirrored onto its visible `TooltipTrigger` as `aria-label` + (and an in-DOM `title`) — the Run-in-Game request id / phase / failure reason / + recovery hint, the save/deploy status, the live-sync hint, and the autoplay hint. + The footer carries its own `TooltipProvider` so its hints work whether or not an + ancestor provides one. +- **ErrorBanner** gains `role="alert"` + `aria-live="assertive"`; a visually-hidden + `aria-live="polite"` region mirrors the volatile run/live status. +- **Landmarks + skip link**: the canvas host is wrapped in `
`, the docks render as `