feat(m1): PR3 T3.0 — shell UI, action kinds, story tab #16

Merged
ginnoir merged 26 commits from feat/m1-progression into main 2026-06-11 22:52:29 -05:00
6 changed files with 241 additions and 114 deletions
Showing only changes of commit bbded8a266 - Show all commits
+4
View File
@@ -135,6 +135,10 @@ export class GameRuntime {
}
}
toggleActionGroupCollapsed(groupKey: string): void {
useGameStore.getState().toggleActionGroupCollapsed(groupKey);
}
enqueueAction(actionId: string): void {
this.performAction(actionId);
}
+131
View File
@@ -0,0 +1,131 @@
import { useState } from 'react';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import type { ActionView } from '../state/viewModel';
interface ActionCardProps {
action: ActionView;
}
export function ActionCard({ action }: ActionCardProps) {
const activeActionId = useGameStore((s) => s.activeActionId);
const actionProgress = useGameStore((s) => s.actionProgress);
const prefs = useGameStore((s) => s.prefs);
const [isOpen, setIsOpen] = useState(false);
const isActive = action.id === activeActionId;
const isDisabled = !action.available && !isActive;
const summaryParts = [
action.costsSummary ? `Cost: ${action.costsSummary}` : null,
action.yieldsSummary ? `Yield: ${action.yieldsSummary}` : null,
].filter(Boolean);
let borderBgClass = '';
if (isDisabled) {
borderBgClass = 'border-slate-800 bg-slate-900/40 opacity-50 cursor-not-allowed';
} else {
borderBgClass =
'border-slate-700 bg-slate-800/70 hover:border-amber-500/60 hover:bg-slate-800 cursor-pointer';
if (action.kind === 'story') {
borderBgClass =
'border-amber-500/50 bg-slate-800/70 hover:border-amber-500/80 hover:bg-slate-800 cursor-pointer';
} else if (action.kind === 'loop' && action.loopEnabled) {
borderBgClass =
'border-amber-500 bg-amber-500/10 shadow-[0_0_8px_rgba(245,158,11,0.15)] hover:border-amber-400 hover:bg-amber-500/15 cursor-pointer';
}
}
return (
<div className="relative flex gap-1 w-full">
<button
type="button"
disabled={isDisabled}
title={prefs.actionDetailMode === 'hover' ? action.storyTooltip : undefined}
onClick={() => gameRuntime.performAction(action.id)}
className={`relative w-full overflow-hidden rounded-lg border px-4 py-3 text-left transition-all duration-200 ${borderBgClass}`}
>
{isActive ? (
<div
className="absolute inset-y-0 left-0 bg-amber-500/15 transition-all duration-100 ease-linear pointer-events-none"
style={{ width: `${Math.round(actionProgress * 100)}%` }}
aria-hidden="true"
/>
) : null}
<div className="relative flex flex-col gap-1 w-full">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-slate-100 flex items-center gap-2">
{action.kind === 'loop' && (
<span
className={`h-4 w-4 rounded border flex items-center justify-center transition-colors shrink-0 ${
action.loopEnabled
? 'border-amber-500 bg-amber-500 text-slate-950'
: 'border-slate-600 bg-slate-900/50'
}`}
aria-hidden="true"
>
{action.loopEnabled && (
<svg
className="h-2.5 w-2.5 stroke-slate-950 stroke-[3] fill-none"
viewBox="0 0 24 24"
>
<title>Loop enabled</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span>
)}
{action.name}
</span>
<div className="flex items-center gap-1.5">
{action.kind === 'story' && (
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-amber-300 uppercase tracking-wider">
Story
</span>
)}
<span className="text-slate-400 text-xs shrink-0">
{isActive
? 'running…'
: isDisabled
? (action.disabledReason ?? 'unavailable')
: 'start'}
</span>
</div>
</div>
{summaryParts.length > 0 ? (
<p className="text-slate-500 text-xs">{summaryParts.join(' · ')}</p>
) : null}
{prefs.actionDetailMode === 'inline' && action.storyHint ? (
<p className="text-slate-400 text-xs mt-0.5">{action.storyHint}</p>
) : null}
</div>
</button>
{prefs.actionDetailMode === 'info-button' && action.storyTooltip ? (
<div className="relative shrink-0 flex">
<button
type="button"
aria-label={`Details for ${action.name}`}
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center rounded-lg border border-slate-700 bg-slate-800/70 px-2 text-slate-400 transition-colors hover:border-amber-500/60 hover:text-slate-200"
title={action.storyTooltip}
>
</button>
{isOpen ? (
<div
role="tooltip"
className="absolute top-full right-0 z-10 mt-1 w-64 rounded-lg border border-slate-600 bg-slate-900 p-3 text-slate-300 text-xs shadow-lg"
>
{action.storyTooltip}
</div>
) : null}
</div>
) : null}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ActionGroupView } from '../state/viewModel';
import { ActionGroup } from './ActionGroup';
interface ActionColumnProps {
label: string;
groups: ActionGroupView[];
actionKind: string;
}
export function ActionColumn({ label, groups, actionKind }: ActionColumnProps) {
return (
<div className="w-80 min-w-80 shrink-0 flex flex-col gap-4 bg-slate-900/40 rounded-xl p-4 border border-slate-800/60">
<h2 className="font-bold text-slate-200 text-sm uppercase tracking-widest border-b border-slate-800 pb-2 select-none">
{label}
</h2>
<div className="flex flex-col gap-3">
{groups.map((group) => (
<ActionGroup key={group.id} group={group} actionKind={actionKind} />
))}
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import type { ActionGroupView } from '../state/viewModel';
import { ActionCard } from './ActionCard';
interface ActionGroupProps {
group: ActionGroupView;
actionKind: string;
}
export function ActionGroup({ group, actionKind }: ActionGroupProps) {
const collapsed = useGameStore(
(s) => !!s.prefs.collapsedActionGroups[`${actionKind}:${group.id}`],
);
return (
<div className="flex flex-col gap-1 w-full">
<button
type="button"
onClick={() => gameRuntime.toggleActionGroupCollapsed(`${actionKind}:${group.id}`)}
className="flex w-full items-center justify-between py-1.5 text-left text-slate-400 hover:text-slate-200 transition-colors focus:outline-none"
>
<span className="font-semibold text-xs uppercase tracking-wider">{group.label}</span>
<span className="text-slate-500 text-xs shrink-0 select-none">{collapsed ? '▶' : '▼'}</span>
</button>
{!collapsed && (
<div className="flex flex-col gap-2 pl-1">
{group.actions.map((action) => (
<ActionCard key={action.id} action={action} />
))}
</div>
)}
</div>
);
}
-112
View File
@@ -1,112 +0,0 @@
import { useState } from 'react';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
/** Action list with queue, cancel, disabled states, and story hints/tooltips. */
export function ActionPanel() {
const actions = useGameStore((s) => s.actions);
const activeActionId = useGameStore((s) => s.activeActionId);
const actionProgress = useGameStore((s) => s.actionProgress);
const queuedActionIds = useGameStore((s) => s.queuedActionIds);
const queuedActionNames = useGameStore((s) => s.queuedActionNames);
const prefs = useGameStore((s) => s.prefs);
const [openInfoId, setOpenInfoId] = useState<string | null>(null);
return (
<section aria-label="Actions" className="flex flex-col gap-2">
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Actions</h2>
{actions.map((action) => {
const isActive = action.id === activeActionId;
const isDisabled = !action.available && !isActive;
const summaryParts = [
action.costsSummary ? `Cost: ${action.costsSummary}` : null,
action.yieldsSummary ? `Yield: ${action.yieldsSummary}` : null,
].filter(Boolean);
return (
<div key={action.id} className="relative flex gap-1">
<button
type="button"
disabled={isDisabled}
title={prefs.actionDetailMode === 'hover' ? action.storyTooltip : undefined}
onClick={() => gameRuntime.enqueueAction(action.id)}
className={`relative w-full overflow-hidden rounded-lg border px-4 py-3 text-left transition-colors ${
isDisabled
? 'cursor-not-allowed border-slate-800 bg-slate-900/40 opacity-50'
: 'border-slate-700 bg-slate-800/70 hover:border-amber-500/60 hover:bg-slate-800'
}`}
>
{isActive ? (
<div
className="absolute inset-y-0 left-0 bg-amber-500/15"
style={{ width: `${Math.round(actionProgress * 100)}%` }}
aria-hidden="true"
/>
) : null}
<div className="relative flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="font-medium text-slate-100">{action.name}</span>
<span className="text-slate-400 text-xs">
{isActive
? 'running…'
: isDisabled
? (action.disabledReason ?? 'unavailable')
: 'start'}
</span>
</div>
{summaryParts.length > 0 ? (
<p className="text-slate-500 text-xs">{summaryParts.join(' · ')}</p>
) : null}
{prefs.actionDetailMode === 'inline' && action.storyHint ? (
<p className="text-slate-400 text-xs">{action.storyHint}</p>
) : null}
</div>
</button>
{prefs.actionDetailMode === 'info-button' && action.storyTooltip ? (
<div className="relative shrink-0">
<button
type="button"
aria-label={`Details for ${action.name}`}
aria-expanded={openInfoId === action.id}
onClick={() => setOpenInfoId(openInfoId === action.id ? null : action.id)}
className="flex h-full items-center rounded-lg border border-slate-700 bg-slate-800/70 px-2 text-slate-400 transition-colors hover:border-amber-500/60 hover:text-slate-200"
title={action.storyTooltip}
>
</button>
{openInfoId === action.id ? (
<div
role="tooltip"
className="absolute top-full right-0 z-10 mt-1 max-w-xs rounded-lg border border-slate-600 bg-slate-900 p-3 text-slate-300 text-xs shadow-lg"
>
{action.storyTooltip}
</div>
) : null}
</div>
) : null}
</div>
);
})}
{queuedActionNames.length > 0 ? (
<ol aria-label="Action queue" className="mt-2 flex flex-col gap-1">
{queuedActionNames.map((name, index) => (
<li
key={queuedActionIds[index]}
className="flex items-center justify-between rounded border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
>
<span className="text-slate-300">{name}</span>
<button
type="button"
aria-label={`Cancel ${name}`}
onClick={() => gameRuntime.cancelQueuedAction(index)}
className="rounded px-2 py-0.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-amber-400"
>
</button>
</li>
))}
</ol>
) : null}
</section>
);
}
+48 -2
View File
@@ -1,14 +1,60 @@
import { ActionPanel } from './ActionPanel';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import { ActionColumn } from './ActionColumn';
import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar';
export function PlayPanel() {
const columns = useGameStore((s) => s.actionColumns).filter(
(col) => col.groups.length > 0 && col.groups.some((g) => g.actions.length > 0),
);
const queuedActionIds = useGameStore((s) => s.queuedActionIds);
const queuedActionNames = useGameStore((s) => s.queuedActionNames);
return (
<div className="space-y-6">
<div className="block md:hidden">
<ResourceBar />
</div>
<ActionPanel />
<section aria-label="Actions" className="flex gap-4 overflow-x-auto pb-4">
{columns.map((col) => (
<ActionColumn
key={col.kind}
label={col.label}
groups={col.groups}
actionKind={col.kind}
/>
))}
</section>
{queuedActionNames.length > 0 ? (
<div className="border border-slate-800 bg-slate-900/30 rounded-xl p-4">
<h3 className="font-semibold text-slate-400 text-xs uppercase tracking-wider mb-2">
Action Queue
</h3>
<ol aria-label="Action queue" className="flex flex-col gap-1.5">
{queuedActionNames.map((name, index) => (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: indices are stable and identify individual queue items
key={`${queuedActionIds[index]}-${index}`}
className="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
>
<span className="text-slate-300">{name}</span>
<button
type="button"
aria-label={`Cancel ${name}`}
onClick={() => gameRuntime.cancelQueuedAction(index)}
className="rounded px-2 py-0.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-amber-400"
>
</button>
</li>
))}
</ol>
</div>
) : null}
<div className="block md:hidden">
<EventLog />
</div>