Skip to content
Draft
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
28 changes: 28 additions & 0 deletions apps/web/src/app/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const routeMockState = vi.hoisted(() => ({
error: null as unknown,
}));

const toasterMockState = vi.hoisted(() => ({
props: [] as Array<Record<string, unknown>>,
}));

vi.mock('./routes', () => ({
AppRoutes: () => {
if (routeMockState.error) {
Expand All @@ -25,6 +29,13 @@ vi.mock('./providers/ErrorBoundary', () => ({
ErrorBoundary: ({ children }: { children: ReactNode }) => <>{children}</>,
}));

vi.mock('sonner', () => ({
Toaster: (props: Record<string, unknown>) => {
toasterMockState.props.push(props);
return null;
},
}));

(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
Expand Down Expand Up @@ -70,6 +81,7 @@ describe('App', () => {
});
usePreferencesStore.getState().reset();
routeMockState.error = null;
toasterMockState.props = [];
document.documentElement.dataset.contrast = 'standard';
container = document.createElement('div');
document.body.appendChild(container);
Expand All @@ -96,6 +108,22 @@ describe('App', () => {
expect(document.documentElement.dataset.contrast).toBe('high');
});

it('keeps persistent toasts dismissible and clear of mobile chrome', async () => {
await act(async () => {
root!.render(<App />);
await Promise.resolve();
});

expect(toasterMockState.props.at(-1)).toEqual(expect.objectContaining({
closeButton: true,
mobileOffset: expect.objectContaining({
bottom: 'calc(env(safe-area-inset-bottom) + 16rem)',
left: '0.75rem',
right: '0.75rem',
}),
}));
});

it('routes save-load render failures into recovery instead of the generic app shell', async () => {
const failure: Extract<LoadSaveSafelyResult, { ok: false }> = {
ok: false,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ export function App() {
<BrowserRouter>
<AppRoutes />
<Toaster
closeButton
position="bottom-right"
mobileOffset={{
bottom: 'calc(env(safe-area-inset-bottom) + 16rem)',
left: '0.75rem',
right: '0.75rem',
}}
toastOptions={{
style: {
background: highContrast ? HC_BASE : dynasty.surface,
Expand Down
19 changes: 16 additions & 3 deletions apps/web/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MonthlyPulseOverlay } from './MonthlyPulseOverlay';
import { TickerBar } from './TickerBar';
import { PressConferenceModal } from '@/features/press-room/components/PressConferenceModal';
import { AssistantPanel } from '@/features/assistant/components/AssistantPanel';
import type { AssistantSeasonSnapshot } from '@/features/assistant/data/assistantGuidance';
import { TourProvider } from '@/shared/components/TourProvider';
import { KeyboardShortcutsPanel } from '@/shared/components/KeyboardShortcutsPanel';
import type { SeasonFlowState } from './seasonFlow';
Expand All @@ -31,7 +32,9 @@ function isEditableTarget(target: EventTarget | null): boolean {
}

return (
target.isContentEditable
target.closest('button, a, [role="button"], [role="menuitem"]') != null
|| target.getAttribute('tabindex') != null
|| target.isContentEditable
|| target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.tagName === 'SELECT'
Expand Down Expand Up @@ -229,6 +232,16 @@ export function AppLayout() {

const activeReport = activeMoment ? null : (monthlyPulse?.pendingReport ?? null);
const activeDecision = activeReport ? null : (monthlyPulse?.decisionQueue[0] ?? null);
const assistantSeasonSnapshot: AssistantSeasonSnapshot | null = seasonFlow
? {
teamName,
standing: seasonFlow.standingsSnapshot.find((standing) => standing.teamId === userTeamId) ?? null,
daysUntilTradeDeadline: seasonFlow.daysUntilTradeDeadline,
phaseLabel: seasonFlow.phaseLabel,
detailLabel: seasonFlow.detailLabel,
seasonSummary: seasonFlow.seasonSummary,
}
: null;
const ambientMode = resolveAmbientMode(
location.pathname,
isInitialized,
Expand Down Expand Up @@ -468,7 +481,7 @@ export function AppLayout() {
{/* Main area: sidebar + content */}
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<main id="main-content" className="flex-1 overflow-y-auto p-4 pb-20 md:p-6 md:pb-6">
<main id="main-content" className="flex-1 overflow-y-auto p-4 pb-56 md:p-6 md:pb-6">
<>
{seasonFlow && (
<SeasonFlowCard
Expand All @@ -495,7 +508,7 @@ export function AppLayout() {
flow={seasonFlow}
/>

<AssistantPanel tickerFeed={tickerFeed} />
<AssistantPanel tickerFeed={tickerFeed} seasonSnapshot={assistantSeasonSnapshot} />

{/* Command palette overlay */}
<CommandPalette
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/app/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ function SidebarLink({ item, collapsed }: { item: NavItem; collapsed: boolean })
);
}

/** Mobile bottom tab indices: Dashboard, Roster, Draft, Trade, League, More */
const MOBILE_TAB_INDICES = [0, 2, 8, 9, 10];
const MOBILE_TAB_ROUTES = ['/dashboard', '/roster', '/draft', '/trade', '/league/standings'] as const;

function MobileTabLink({ item }: { item: NavItem }) {
return (
Expand Down Expand Up @@ -153,7 +152,7 @@ function MobileMoreDrawer({
return (
<div className="fixed inset-0 z-50 flex flex-col justify-end">
<div className="flex-1" onClick={onClose} aria-hidden />
<div className="max-h-[70vh] overflow-y-auto rounded-t-xl border-t border-dynasty-border bg-dynasty-surface pb-safe">
<div className="max-h-[70vh] overflow-y-auto rounded-t-xl border-t border-dynasty-border bg-dynasty-surface pb-[calc(env(safe-area-inset-bottom)+0.75rem)]">
<div className="flex items-center justify-between border-b border-dynasty-border px-4 py-3">
<span className="font-heading text-sm font-semibold text-dynasty-textBright">Navigation</span>
<button
Expand Down Expand Up @@ -194,11 +193,13 @@ function MobileMoreDrawer({

export function MobileTabBar({ items }: { items: NavItem[] }) {
const [moreOpen, setMoreOpen] = useState(false);
const tabItems = MOBILE_TAB_INDICES.map((i) => items[i]).filter((item): item is NavItem => item != null);
const tabItems = MOBILE_TAB_ROUTES
.map((route) => items.find((item) => item.to === route))
.filter((item): item is NavItem => item != null);

return (
<>
<nav className="fixed inset-x-0 bottom-0 z-40 flex items-stretch border-t border-dynasty-border bg-dynasty-surface md:hidden" aria-label="Mobile navigation">
<nav className="fixed inset-x-0 bottom-0 z-40 flex items-stretch border-t border-dynasty-border bg-dynasty-surface pb-[env(safe-area-inset-bottom)] md:hidden" aria-label="Mobile navigation">
{tabItems.map((item) => (
<MobileTabLink key={item.to} item={item} />
))}
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/app/layout/SimControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function SimButton({ onClick, disabled, icon, label, shortLabel, tooltip }: SimB
}}
disabled={disabled}
title={tooltip}
className="focus-ring flex min-h-[48px] flex-1 items-center justify-center gap-2 rounded-md bg-accent-primary px-4 py-2 font-heading text-sm font-semibold text-white transition-colors hover:bg-accent-primaryHover disabled:cursor-not-allowed disabled:opacity-40"
className="focus-ring flex min-h-[48px] flex-1 items-center justify-center gap-2 rounded-md bg-accent-primary px-4 py-2 font-heading text-sm font-semibold text-dynasty-base transition-colors hover:bg-accent-primaryHover disabled:cursor-not-allowed disabled:opacity-40"
aria-label={tooltip ?? label}
>
{icon}
Expand All @@ -53,7 +53,7 @@ export function SimControls({
const showRegularControls = flow?.canUseRegularSimControls ?? true;

return (
<footer data-tour="sim-controls" className="border-t border-dynasty-border bg-dynasty-surface px-3 py-2 pb-2 md:px-4 md:pb-2">
<footer data-tour="sim-controls" className="border-t border-dynasty-border bg-dynasty-surface px-3 py-2 pb-[calc(env(safe-area-inset-bottom)+4.5rem)] md:px-4 md:pb-2">
<div className="flex items-center gap-2 md:gap-3">
{/* Status display */}
<div className="hidden min-w-[140px] md:block" aria-live="polite">
Expand Down Expand Up @@ -110,7 +110,7 @@ export function SimControls({
onFlowAction();
}}
disabled={isSimulating || !flow?.actionLabel}
className="focus-ring flex min-h-[48px] flex-1 items-center justify-center gap-2 rounded-md bg-accent-primary px-4 py-2 font-heading text-sm font-semibold text-white transition-colors hover:bg-accent-primaryHover disabled:cursor-not-allowed disabled:opacity-40"
className="focus-ring flex min-h-[48px] flex-1 items-center justify-center gap-2 rounded-md bg-accent-primary px-4 py-2 font-heading text-sm font-semibold text-dynasty-base transition-colors hover:bg-accent-primaryHover disabled:cursor-not-allowed disabled:opacity-40"
>
<Zap className="h-4 w-4" />
{flow?.actionLabel ?? 'Continue'}
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/features/assistant/components/AssistantAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export type AssistantExpression = 'neutral' | 'excited' | 'warning' | 'success' | 'thinking';

export interface AssistantAvatarProps {
expression: AssistantExpression;
pulse?: boolean;
}

function expressionClasses(expression: AssistantExpression): string {
if (expression === 'warning') return 'border-accent-warning/70 text-accent-warning shadow-[0_0_20px_rgba(245,158,11,0.18)]';
if (expression === 'success') return 'border-accent-success/70 text-accent-success shadow-[0_0_20px_rgba(34,197,94,0.18)]';
if (expression === 'excited') return 'border-accent-primary/70 text-accent-primary shadow-[0_0_20px_rgba(249,115,22,0.2)]';
if (expression === 'thinking') return 'border-accent-info/70 text-accent-info shadow-[0_0_20px_rgba(59,130,246,0.18)]';
return 'border-dynasty-border text-dynasty-text';
}

function eyebrowPath(expression: AssistantExpression): string {
if (expression === 'warning') return 'M16 18l6-2M30 16l6 2';
if (expression === 'success' || expression === 'excited') return 'M16 16l6 1M30 17l6-1';
if (expression === 'thinking') return 'M16 17l6-1M30 16l6 1';
return 'M16 17h6M30 17h6';
}

function mouthPath(expression: AssistantExpression): string {
if (expression === 'warning') return 'M21 33h10';
if (expression === 'success' || expression === 'excited') return 'M20 31c3 4 10 4 13 0';
if (expression === 'thinking') return 'M22 32c3 1 7 1 10-1';
return 'M22 32c3 2 7 2 10 0';
}

export function AssistantAvatar({ expression, pulse = false }: AssistantAvatarProps) {
return (
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full border bg-dynasty-elevated ${expressionClasses(expression)} motion-safe:animate-assistant-enter ${pulse ? 'motion-safe:animate-assistant-pulse' : ''}`}
aria-label={`Mack Mercer expression: ${expression}`}
role="img"
>
<svg
viewBox="0 0 52 52"
className="h-10 w-10"
aria-hidden="true"
>
<circle cx="26" cy="26" r="23" fill="currentColor" opacity="0.1" />
<path d="M14 39c2-8 7-12 12-12s10 4 12 12" fill="currentColor" opacity="0.12" />
<path d="M15 20c1-7 6-12 12-12 5 0 9 3 11 8-5 0-9 1-13 4-4-1-7-1-10 0z" fill="currentColor" opacity="0.28" />
<circle cx="20" cy="24" r="2" fill="currentColor" />
<circle cx="32" cy="24" r="2" fill="currentColor" />
<path d={eyebrowPath(expression)} stroke="currentColor" strokeWidth="2" strokeLinecap="round" fill="none" />
<path d={mouthPath(expression)} stroke="currentColor" strokeWidth="2" strokeLinecap="round" fill="none" />
<path d="M13 38h26" stroke="currentColor" strokeWidth="2" strokeLinecap="round" opacity="0.45" />
{expression === 'thinking' ? (
<path d="M38 12h5M41 9v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" opacity="0.8" />
) : null}
{expression === 'success' ? (
<path d="M38 13l3 3 6-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
) : null}
</svg>
</div>
);
}
Loading