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 (
{
- const next = event.target.value;
- onChange(map.has(next) ? map.get(next) : next);
+ value={toRadix(selectedKey)}
+ onValueChange={(next) => {
+ const key = next === SELECT_EMPTY_SENTINEL ? "" : next;
+ onChange(map.has(key) ? map.get(key) : key);
}}
>
- {placeholder ? (
-
- {placeholder}
-
- ) : null}
- {enumOptions.map((opt) => (
-
- {opt.label}
-
- ))}
+
+
+
+
+ {enumOptions.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
);
}
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(
- 'mapSize',
- e.target.value as WorldSettings['mapSize']
- )
+ onValueChange={(value) =>
+ updateSetting('mapSize', value as WorldSettings['mapSize'])
}
options={MAP_SIZE_OPTIONS.map((opt) => ({
value: opt.value,
label: opt.label
}))}
- lightMode={isLightMode}
+ ariaLabel="World size"
className="w-24" />
-
+
Players
-
- 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
- onSavedConfigChange(e.target.value)}
+ onValueChange={(value) => onSavedConfigChange(value)}
options={setupOptions.savedConfigOptions}
- lightMode={isLightMode}
+ ariaLabel="Saved config"
className="w-44 max-w-[34vw]" />
@@ -186,6 +183,7 @@ export const AppHeader: React.FC = ({
type="button"
className={setupButtonClassName}
aria-expanded={setupOpen}
+ aria-controls="app-header-setup-panel"
onClick={() => setSetupOpen((open) => !open)}>
Setup
@@ -195,81 +193,79 @@ export const AppHeader: React.FC = ({
{setupOpen ? (
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 = ({
setIsStageExpanded(!isStageExpanded)}
+ aria-expanded={isStageExpanded}
+ aria-controls="explore-stage-list"
className={`w-full flex items-center justify-between px-3 py-2.5 transition-colors ${hoverBg}`}>
@@ -340,7 +342,7 @@ export const ExplorePanel: React.FC = ({
-
+
{stages.length}
= ({
{isStageExpanded ? (
-
+
{stages.map((stage, index) => (
handleSelectStage(stage.value)}
+ aria-current={stage.value === selectedStage ? "true" : undefined}
className={`${stageItemBase} ${stage.value === selectedStage ? stageItemActive : stageItemInactive}`}>
@@ -371,32 +374,35 @@ export const ExplorePanel: React.FC = ({
setIsStepExpanded(!isStepExpanded)}
+ aria-expanded={isStepExpanded}
+ aria-controls="explore-step-list"
className={`w-full flex items-center justify-between px-3 py-2 transition-colors ${hoverBg}`}>
-
+
Step
{!isStepExpanded ? (
-
+
{currentStep?.label ?? ""}
) : null}
- {steps.length}
+ {steps.length}
{isStepExpanded ? (
-
+
{steps.length > 0 ? (
steps.map((step, index) => (
handleSelectStep(step.value)}
+ aria-current={step.value === selectedStep ? "true" : undefined}
className={`${stepItemBase} ${step.value === selectedStep ? stepItemActive : stepItemInactive}`}>
@@ -406,7 +412,7 @@ export const ExplorePanel: React.FC = ({
))
) : (
-
No steps available
+
No steps available
)}
) : null}
@@ -416,27 +422,29 @@ export const ExplorePanel: React.FC
= ({
setIsLayersExpanded(!isLayersExpanded)}
+ aria-expanded={isLayersExpanded}
+ aria-controls="explore-layers-list"
className={`w-full flex items-center justify-between px-3 py-2 transition-colors ${hoverBg}`}>
-
+
Data
{!isLayersExpanded ? (
-
+
{currentLayer?.label ?? ""}
) : null}
- {dataTypeOptions.length}
+ {dataTypeOptions.length}
{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 = ({
handleSelectLayer(dataType.value)}
+ aria-current={dataType.value === selectedDataType ? "true" : undefined}
className={`${stepItemBase} ${
dataType.value === selectedDataType ? stepItemActive : stepItemInactive
}`}
@@ -517,6 +527,7 @@ export const ExplorePanel: React.FC = ({
onSelectedRenderModeChange(option.value)}
aria-label={option.label}
+ aria-pressed={selectedRenderMode === option.value}
className={selectedRenderMode === option.value ? iconBtnActive : iconBtn}
>
{getRenderModeIcon(option.value)}
@@ -540,6 +551,7 @@ export const ExplorePanel: React.FC = ({
onSelectedSpaceChange(option.value)}
aria-label={option.label}
+ aria-pressed={selectedSpace === option.value}
className={selectedSpace === option.value ? iconBtnActive : iconBtn}
>
{getSpaceIcon(option.value)}
diff --git a/apps/mapgen-studio/src/ui/components/OptionSelect.tsx b/apps/mapgen-studio/src/ui/components/OptionSelect.tsx
new file mode 100644
index 0000000000..adb17c6c24
--- /dev/null
+++ b/apps/mapgen-studio/src/ui/components/OptionSelect.tsx
@@ -0,0 +1,68 @@
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../../components/ui";
+
+export interface OptionSelectProps {
+ /** Current selected value (controlled). */
+ value: string;
+ /** Called with the next value when the selection changes. */
+ onValueChange: (value: string) => void;
+ /** Options to render. */
+ options: ReadonlyArray<{ value: string; label: string }>;
+ /** Accessible label for the trigger. */
+ ariaLabel: string;
+ /** Placeholder shown when no value is selected. */
+ placeholder?: string;
+ /** Trigger width / sizing classes. */
+ className?: string;
+ /** Whether the control is disabled. */
+ disabled?: boolean;
+}
+
+/**
+ * `OptionSelect` — a thin, token-driven adapter over the design-system Radix
+ * `Select` (`src/components/ui/select`). It preserves the simple
+ * `value` / `onValueChange` / `options` shape the studio chrome used with the
+ * legacy native ``, so call sites migrate without restructuring while
+ * dropping the off-token hex + `lightMode` prop. Radix requires non-empty
+ * `value`s, so an empty option value is mapped to a reserved sentinel internally
+ * (it round-trips back to `""` on change) — the visible behavior (placeholder +
+ * "no selection") is unchanged.
+ */
+const EMPTY_VALUE_SENTINEL = "__option-select-empty__";
+
+export function OptionSelect({
+ value,
+ onValueChange,
+ options,
+ ariaLabel,
+ placeholder,
+ className,
+ disabled,
+}: OptionSelectProps) {
+ const toRadix = (raw: string) => (raw === "" ? EMPTY_VALUE_SENTINEL : raw);
+ const fromRadix = (raw: string) => (raw === EMPTY_VALUE_SENTINEL ? "" : raw);
+
+ return (
+ onValueChange(fromRadix(next))}
+ disabled={disabled}
+ >
+
+
+
+
+ {options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/mapgen-studio/src/ui/components/RecipePanel.tsx b/apps/mapgen-studio/src/ui/components/RecipePanel.tsx
index 34e670c97a..a8d246b680 100644
--- a/apps/mapgen-studio/src/ui/components/RecipePanel.tsx
+++ b/apps/mapgen-studio/src/ui/components/RecipePanel.tsx
@@ -34,7 +34,7 @@ import {
DialogFooter,
DialogClose } from
'../../components/ui';
-import { Select } from './ui';
+import { OptionSelect } from './OptionSelect';
import type {
PipelineConfig,
Theme,
@@ -229,6 +229,8 @@ export const RecipePanel: React.FC = ({
setRecipeCollapsed(!recipeCollapsed)}
+ aria-expanded={!recipeCollapsed}
+ aria-controls="recipe-panel-recipe-section"
className={`w-full flex items-center justify-between px-3 py-2.5 transition-colors ${hoverBg}`}>
@@ -248,42 +250,41 @@ export const RecipePanel: React.FC
= ({
{/* Recipe & Preset Selection */}
{!recipeCollapsed &&
+ className={`text-label font-medium uppercase tracking-wider w-14 shrink-0 ${textMuted}`}>
Recipe
- updateSetting('recipe', e.target.value)}
- aria-label="Recipe"
+ onValueChange={(value) => updateSetting('recipe', value)}
+ ariaLabel="Recipe"
options={recipeOptions.map((opt) => ({
value: opt.value,
label: opt.label
}))}
- lightMode={lightMode}
className="flex-1" />
+ className={`text-label font-medium uppercase tracking-wider w-14 shrink-0 ${textMuted}`}>
Config
- updateSetting('preset', e.target.value)}
- aria-label="Config"
+ onValueChange={(value) => updateSetting('preset', value)}
+ ariaLabel="Config"
options={presetOptions.map((opt) => ({
value: opt.value,
label: opt.label
}))}
- lightMode={lightMode}
className="flex-1" />
@@ -295,6 +296,8 @@ export const RecipePanel: React.FC
= ({
setConfigCollapsed(!configCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -355,7 +358,7 @@ export const RecipePanel: React.FC
= ({
{/* Config Content */}
{!configCollapsed &&
-
+
{/* Config Actions */}
@@ -474,7 +477,7 @@ export const RecipePanel: React.FC
= ({
onSaveToCurrent();
setShowSaveMenu(false);
}}
- className={`w-full text-left px-3 py-2 text-[11px] ${textPrimary} ${hoverBg} rounded-t-lg`}>
+ className={`w-full text-left px-3 py-2 text-data ${textPrimary} ${hoverBg} rounded-t-lg`}>
Save & Deploy
@@ -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 */}
- onOpenChange(false)}
- className={cn(
- 'absolute right-3 top-3 p-1 rounded transition-colors',
- lightMode ?
- 'text-gray-400 hover:text-gray-600 hover:bg-gray-100' :
- 'text-[#5a5a66] hover:text-[#8a8a96] hover:bg-[#1a1a1f]'
- )}>
-
-
-
- {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 (
-
- {children}
- );
-
-};
-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 (
-
- {children}
- );
-
-};
-// ============================================================================
-// 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 `` with `` children to the Radix
+Select via the same sentinel mapping, while the enum→typed-value `map` and
+`emptyValue` normalization stay byte-for-byte — so the authored config the form emits
+is unchanged. Checkbox/Switch already used `onCheckedChange`; the only behavioral
+nuance is coercing the checkbox's `boolean | "indeterminate"` to a strict boolean.
+
+### Empty stage = "awaiting matter" (system.md craft lever #3)
+
+The backdrop is the page substrate token (`bg-background`, named in the tokens as the
+deck.gl backdrop), with the vignette and grid drawn in `--muted-foreground` at low
+alpha (luminance, not chroma). The empty state gains a centered contour-framed panel
+labelled "Awaiting matter" over a coarse graticule so the stage reads as a ready
+survey console rather than dead space. `lightMode` is dropped from the chrome but
+still forwarded to `DeckCanvas` (it governs scene rendering, out of scope).
+
+## Risks / Trade-offs
+
+- **Radix Select vs native select keyboard model.** The migrated dropdowns now use the
+ Radix listbox interaction model instead of the OS-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 `` with labels, and a visually-hidden
+ skip-to-main link is the first focusable element.
+- **aria-expanded/aria-controls** on the collapsible section headers (ExplorePanel
+ Stage/Step/Layers, RecipePanel Recipe/Config, AppHeader Setup); **aria-current**
+ on active Stage/Step/Layer items; **aria-pressed** on the render-mode/space
+ toggles.
+- **Token-driven selects**: the live World Size / Players / Config dropdowns
+ (AppHeader) and the Recipe / Config dropdowns (RecipePanel) migrate from the
+ legacy `lightMode` native `Select` to the token-driven `src/components/ui/select`
+ via a thin `OptionSelect` adapter; the rjsf override widgets re-skin onto the
+ `src/components/ui` Input/Textarea/Checkbox/Switch/Select primitives.
+- **Dead code**: the unused legacy `src/ui/components/ui/AlertDialog.tsx` is removed
+ (no importers).
+- **Type scale + stage craft**: ad-hoc `text-[10px]`/`text-[11px]` adopt the named
+ `text-label`/`text-data` tokens in AppHeader/RecipePanel/ExplorePanel; the
+ CanvasStage backdrop is token-referenced (`bg-background`, luminance vignette/grid)
+ with the `lightMode` chrome ternary dropped, and the empty stage frames the map
+ with a graticule + contour panel that reads as ready.
+
+## Requires
+
+- Design-system foundation, ui-primitives, shell-reskin, app-decompose, client-data,
+ and app-shell slices — all already lower in the stack.
+
+## Enables Parallel Work
+
+- Subsequent rigor / dead-code / comment passes operate on an a11y-complete,
+ fully token-driven chrome.
+
+## Affected Owners
+
+- `apps/mapgen-studio/src/ui/components/AppFooter.tsx`
+- `apps/mapgen-studio/src/ui/components/AppHeader.tsx`
+- `apps/mapgen-studio/src/ui/components/RecipePanel.tsx`
+- `apps/mapgen-studio/src/ui/components/ExplorePanel.tsx`
+- `apps/mapgen-studio/src/ui/components/OptionSelect.tsx` (new adapter)
+- `apps/mapgen-studio/src/app/{StudioShell,CanvasStage,LeftDock,RightDock,ErrorBanner}.tsx`
+- `apps/mapgen-studio/src/features/configOverrides/rjsfWidgets.tsx`
+- `apps/mapgen-studio/src/ui/components/ui/{index.ts}` (drop AlertDialog re-export)
+- `apps/mapgen-studio/test/runInGame/AppFooter.test.tsx` (token assertion)
+
+## Forbidden Owners
+
+- No change to map-generation, Deck.gl math, recipe semantics, or the run-in-game flow.
+- No change to the live-runtime poll request-key staleness / adaptive backoff gating.
+- No change to the localStorage schema or any data contract.
+- No new hand-rolled `fetch`; live reads continue through the typed oRPC client.
+- No change to the value plumbing of any control (selects/widgets emit the same
+ authored values).
+
+## Stop Conditions
+
+- Any a11y/token change would require altering control value semantics or a
+ parity-critical flow rather than presentation.
+
+## Consumer Impact
+
+Screen-reader and keyboard users get landmarks, a skip link, live-region
+announcements, labelled controls, and disclosed expand/select state; the footer's
+operational diagnostics are present in the DOM again. Sighted users see a
+token-consistent chrome and an intentional empty stage. No behavior changes.
+
+## Verification Gates
+
+- `bun run check` (tsc --noEmit) clean.
+- `bun run build` succeeds, including the worker-bundle check.
+- `bun run test` fully green (the 8 previously-red AppFooter assertions pass without
+ weakening; the diagnostics are genuinely in the DOM).
+- Live preview renders with no console errors.
+- OpenSpec strict validation.
diff --git a/openspec/changes/mapgen-studio-craft-a11y/specs/mapgen-studio/spec.md b/openspec/changes/mapgen-studio-craft-a11y/specs/mapgen-studio/spec.md
new file mode 100644
index 0000000000..d46239a358
--- /dev/null
+++ b/openspec/changes/mapgen-studio-craft-a11y/specs/mapgen-studio/spec.md
@@ -0,0 +1,81 @@
+## ADDED Requirements
+
+### Requirement: Footer Operational Diagnostics Are Present For Assistive Tech And Static Markup
+
+The AppFooter SHALL keep its operational diagnostics — the Run-in-Game request id,
+phase, failure reason, and recovery hint; the save/deploy status; the live-sync hint;
+and the autoplay hint — present in the rendered DOM and on the accessible name of the
+visible control, not exclusively inside hover-only tooltip content.
+
+#### Scenario: Run-in-Game diagnostics are in the static markup
+- **WHEN** the footer renders with a Run-in-Game operation that has a request id and a failure reason
+- **THEN** the request id and the failure reason appear in the server-rendered static markup of the footer
+- **AND** they are exposed as the `aria-label`/`title` of the visible Run-in-Game status control
+- **AND** the AppFooter static-markup parity tests pass without weakening their assertions
+
+#### Scenario: Footer hints work without an ancestor tooltip provider
+- **WHEN** the footer is rendered standalone (no ancestor `TooltipProvider`)
+- **THEN** it renders without throwing and still exposes the live-sync, autoplay, save/deploy, and run-in-game hints as accessible names
+- **AND** when rendered inside the studio shell (which provides a tooltip provider) the behavior is unchanged
+
+### Requirement: Studio Chrome Exposes Landmarks, A Skip Link, And Live Regions
+
+Mapgen Studio SHALL present a landmark structure (main, complementary asides, header,
+footer), a visually-hidden skip-to-main link as the first focusable element, an
+assertive alert region for errors, and a polite live region mirroring volatile
+run/live status.
+
+#### Scenario: Landmarks and skip link are present
+- **WHEN** the studio shell renders
+- **THEN** the deck.gl canvas host is wrapped in a `main` landmark labelled "Map preview"
+- **AND** the left and right docks are `aside` landmarks with descriptive labels
+- **AND** a visually-hidden skip link targeting the map preview is the first focusable element
+
+#### Scenario: Status changes are announced politely and errors assertively
+- **WHEN** the run/live status changes or an error message appears
+- **THEN** the error banner is an assertive alert region (`role="alert"`, `aria-live="assertive"`)
+- **AND** a visually-hidden polite live region reflects the current generation/live/run-in-game status
+
+### Requirement: Collapsible Sections And Selection Lists Expose Their State
+
+Mapgen Studio collapsible section headers SHALL expose `aria-expanded` and
+`aria-controls`, and active items in single-select lists/toolbars SHALL expose
+`aria-current` or `aria-pressed`.
+
+#### Scenario: Disclosure state is exposed
+- **WHEN** the ExplorePanel Stage/Step/Layers, RecipePanel Recipe/Config, or AppHeader Setup headers render
+- **THEN** each header reports `aria-expanded` reflecting its open state and `aria-controls` referencing the controlled region
+
+#### Scenario: Active selection is exposed
+- **WHEN** a Stage, Step, or Layer item is the current selection
+- **THEN** that item reports `aria-current`
+- **AND** the active render-mode and space toggles report `aria-pressed`
+
+### Requirement: Live Selects And RJSF Widgets Use The Token-Driven Design System
+
+The live dropdowns and the rjsf override widgets SHALL render through the token-driven `src/components/ui` primitives, with no `lightMode` prop and no off-token raw-hex or named-color utilities, while emitting the same authored values as before. This covers the AppHeader World Size, Players, and Config dropdowns, the RecipePanel Recipe and Config dropdowns, and every rjsf override widget.
+
+#### Scenario: Dropdowns are token-driven and value-preserving
+- **WHEN** the AppHeader World Size/Players/Config or RecipePanel Recipe/Config dropdown changes selection
+- **THEN** it is rendered by the token-driven Select (via the `OptionSelect` adapter) with a labelled trigger
+- **AND** the value emitted to the settings callback is identical to the legacy native select for the same selection
+
+#### Scenario: RJSF widgets re-skin without changing emitted config
+- **WHEN** an rjsf text/number/textarea/select/checkbox/switch/tag override widget renders and is edited
+- **THEN** it is built from the `src/components/ui` primitives with no `lightMode` prop and no `ring-gray-400`
+- **AND** the value it passes to RJSF `onChange` (including enum mapping and empty-value normalization) matches the prior native-widget behavior
+
+### Requirement: Empty Stage And Type Scale Are Intentional And Token-Referenced
+
+The empty deck.gl stage SHALL be token-referenced and framed as an intentional
+"awaiting matter" survey console, and the chrome SHALL adopt the named type scale in
+place of ad-hoc pixel sizes for the label/data tiers.
+
+#### Scenario: Empty stage frames the map and is token-driven
+- **WHEN** no viz manifest is present
+- **THEN** the stage backdrop uses the `bg-background` token (no `lightMode` hex ternary in the chrome)
+- **AND** the empty state shows an intentional graticule + contour-framed "awaiting matter" panel rather than bare centered text
+
+#### Scenario: Named type scale replaces ad-hoc pixel sizes
+- **WHEN** AppHeader, RecipePanel, or ExplorePanel render their label/data text
+- **THEN** the 10px/11px tiers use the named `text-label`/`text-data` tokens instead of `text-[10px]`/`text-[11px]`
diff --git a/openspec/changes/mapgen-studio-craft-a11y/tasks.md b/openspec/changes/mapgen-studio-craft-a11y/tasks.md
new file mode 100644
index 0000000000..87f277ee9f
--- /dev/null
+++ b/openspec/changes/mapgen-studio-craft-a11y/tasks.md
@@ -0,0 +1,39 @@
+## 1. Restore AppFooter diagnostics for AT + static markup
+
+- [x] 1.1 Mirror each diagnostic Tooltip's text onto its visible `TooltipTrigger` via `aria-label` + in-DOM `title` (run-in-game request id/phase/failure/recovery, save/deploy status, live-sync hint, autoplay hint)
+- [x] 1.2 Wrap the footer render in its own `TooltipProvider` so hints work with or without an ancestor provider (and bare static-markup mounts do not crash)
+- [x] 1.3 Make `test/runInGame/AppFooter.test.tsx` fully green without weakening assertions (token-align the one stale `border-orange-400` assertion to `border-warning`)
+
+## 2. Landmarks, live regions, and skip link
+
+- [x] 2.1 ErrorBanner: `role="alert"` + `aria-live="assertive"`
+- [x] 2.2 StudioShell: visually-hidden `aria-live="polite"` mirror of run/live status
+- [x] 2.3 Wrap the canvas host in ``; docks render as `` with labels
+- [x] 2.4 Visually-hidden skip-to-main link as the first focusable element
+
+## 3. Disclosure + selection aria
+
+- [x] 3.1 `aria-expanded` + `aria-controls` on ExplorePanel Stage/Step/Layers, RecipePanel Recipe/Config, AppHeader Setup headers
+- [x] 3.2 `aria-current` on active Stage/Step/Layer items; `aria-pressed` on render-mode/space toggles
+
+## 4. Token-driven controls + dead code
+
+- [x] 4.1 Add `OptionSelect` adapter over the token-driven `src/components/ui/select`
+- [x] 4.2 Migrate AppHeader World Size/Players/Config + setup selects to `OptionSelect`
+- [x] 4.3 Migrate RecipePanel Recipe/Config selects to `OptionSelect`
+- [x] 4.4 Re-skin rjsf widgets onto `src/components/ui` Input/Textarea/Checkbox/Switch/Select (drop `lightMode`, off-token `ring-gray-400`)
+- [x] 4.5 Remove dead `src/ui/components/ui/AlertDialog.tsx` + its barrel re-export (confirmed no importers)
+
+## 5. Type scale + stage craft
+
+- [x] 5.1 Adopt `text-data`/`text-label` over `text-[11px]`/`text-[10px]` in AppHeader/RecipePanel/ExplorePanel
+- [x] 5.2 Token-reference the CanvasStage backdrop (`bg-background`, luminance vignette/grid), drop the `lightMode` chrome ternary
+- [x] 5.3 Reframe the empty stage as an intentional "awaiting matter" survey console (graticule + contour panel)
+
+## 6. Verify parity
+
+- [x] 6.1 `bun run check` clean (tsc --noEmit)
+- [x] 6.2 `bun run build` succeeds incl. worker-bundle check
+- [x] 6.3 `bun run test` fully green (8 AppFooter assertions pass; diagnostics in DOM)
+- [x] 6.4 Live preview: no console errors
+- [x] 6.5 OpenSpec strict validation passes