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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/verify-debatelab/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run
## Gotchas

- Live runs are slow: sparring and crossfire take several minutes each because the model must reason. Use `-g` to run one test while iterating.
- Vite does not reload `tailwind.config.ts` on a running stack: new theme keys, variants and the `future` flags produce no CSS until `$H down && $H up`. Edits to `src/` and `src/index.css` apply on reload as usual.
- The fake provider rejects prompts it doesn't recognize. If a prompt change makes a feature fail with a provider error, update `e2e/full-stack/fake-openrouter.mjs`. Don't mock around it.
- Once, the fake-provider container was SIGKILLed about 20 seconds after `up`, and the cause was never found. Doctor catches this, and `drive` refuses to run until you `down && up`.
- Demo-code users are Pro with a daily limit of 999. To test quota or tier paths, change `user_settings` through `admin` inside the recipe, as `e2e/full-stack/case-generation.spec.ts` does.
Expand Down
3 changes: 2 additions & 1 deletion e2e/animation-polish.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ test('removes non-essential route and state movement for reduced motion', async
const options = await openGenerateOptions(page);

await expect(page.locator('[data-route-transition-ms]')).toHaveCount(0);
await expect.poll(() => options.evaluate((element) => getComputedStyle(element).transitionDuration)).toBe('0s');
// Reduced motion keeps gentle fades but nothing that moves: no transform, height or grid-row transitions.
await expect.poll(() => options.evaluate((element) => getComputedStyle(element).transitionProperty)).not.toMatch(/transform|grid-template-rows|height|all/);
await expect.poll(() => page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === 'running').length)).toBe(0);
await page.screenshot({ path: testInfo.outputPath('reduced-empty-options.png'), fullPage: true });

Expand Down
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
<meta name="author" content="DebateLab" />
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta name="language" content="English" />
<meta name="theme-color" content="#0f172a" />
<meta name="theme-color" content="#f1f5f9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="canonical" href="https://debate.pi-stack.org/" />
<link rel="manifest" href="/manifest.webmanifest" />
Expand Down
223 changes: 223 additions & 0 deletions plans/2026-10-09-design-audit.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Every plan lives in this folder and opens with a `**Status:**` line; a plan list

## In progress

- **Design audit.** UI audit against Emil Kowalski's Apple-design and motion skills: broken toast stacking, collapsed text-colour tokens, a global CSS layer that overrides the primitives, touch and reduced-motion gaps, wayfinding and undo. Shipping as a four-PR stack: Foundations, Shell, AI tools, Landing. Plan: [2026-10-09 design audit](2026-10-09-design-audit.md).
- **Staging that tracks main.** Audit of staging, the VPS sync timer, the seed, and what to delete. Plan: [2026-10-03 staging loop](2026-10-03-staging-loop.md). Previews come from #95 and the staging spec fix from #100.

## Backlog
Expand Down
2 changes: 1 addition & 1 deletion src/components/OfflineIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function OfflineIndicator() {
if (!offline) return null;

return (
<div className="fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 items-center gap-2 rounded-full border bg-background/95 px-4 py-2 text-sm font-medium text-foreground shadow-lg backdrop-blur" role="status" aria-live="polite">
<div className="native-safe-fixed-bottom fixed left-1/2 z-[60] flex -translate-x-1/2 items-center gap-2 rounded-full border bg-background/95 px-4 py-2 text-sm font-medium text-foreground shadow-lg backdrop-blur" role="status" aria-live="polite">
<WifiOff className="h-4 w-4 text-amber-500" />
{t('offlineIndicator.label', 'Offline mode')}
</div>
Expand Down
72 changes: 52 additions & 20 deletions src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,61 @@
// src/components/ThemeProvider.tsx
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useSettingsStore } from '@/lib/stores/settings-store';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useSettingsStore((state) => state.theme);
type ResolvedTheme = 'light' | 'dark';

useEffect(() => {
const root = window.document.documentElement;
// Browser chrome colours, matching --background in each theme.
const THEME_COLORS: Record<ResolvedTheme, string> = { light: '#f1f5f9', dark: '#0f172a' };

function syncThemeColor(theme: 'light' | 'dark' | 'system', resolved: ResolvedTheme) {
document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]').forEach((meta) => {
const media = meta.getAttribute('media') ?? '';
const own: ResolvedTheme = media.includes('dark') ? 'dark' : 'light';
// In system mode each meta keeps its own media colour; an explicit choice wins everywhere.
meta.content = THEME_COLORS[theme === 'system' ? own : resolved];
});
}

// Remove existing theme classes
function applyTheme(resolved: ResolvedTheme, animate: boolean) {
const root = window.document.documentElement;
const swap = () => {
root.classList.remove('light', 'dark');
root.classList.add(resolved);
};

if (!animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
swap();
return;
}

// One 200ms crossfade of the whole page instead of every surface easing at its own speed.
if (typeof document.startViewTransition === 'function') {
const transition = document.startViewTransition(swap);
// A newer switch skips this one's animation; the class swap still runs, so that's fine.
transition.ready.catch(() => {});
transition.finished.catch(() => {});
return;
}

// No View Transitions: switch in a single frame so nothing ripples.
root.classList.add('theme-switching');
swap();
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-switching')));
}

const systemTheme = (): ResolvedTheme =>
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useSettingsStore((state) => state.theme);
const mounted = useRef(false);

if (theme === 'system') {
// Check system preference
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
useEffect(() => {
const resolved = theme === 'system' ? systemTheme() : theme;
// theme-init.js already applied the stored theme before first paint.
applyTheme(resolved, mounted.current);
syncThemeColor(theme, resolved);
mounted.current = true;
}, [theme]);

// Listen for system theme changes when in 'system' mode
Expand All @@ -29,9 +65,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

const handleChange = (e: MediaQueryListEvent) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(e.matches ? 'dark' : 'light');
applyTheme(e.matches ? 'dark' : 'light', true);
};

mediaQuery.addEventListener('change', handleChange);
Expand All @@ -40,5 +74,3 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {

return <>{children}</>;
}


2 changes: 1 addition & 1 deletion src/components/features/ask/AskChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, onSave }: { m
<Button
variant="ghost"
size="icon"
className="absolute -right-10 top-0 opacity-0 group-hover:opacity-100 transition-opacity h-8 w-8"
className="absolute -right-10 top-0 h-8 w-8 opacity-60 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
const title = message.content.length > 30
? `${message.content.slice(0, 30)}...`
Expand Down
2 changes: 1 addition & 1 deletion src/components/features/attack/AttackForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function AttackForm({ onSubmit, isLoading, importedCase, debateContext =
<SlidersHorizontal className="h-4 w-4" />
{t('attackForm.moreOptions')}
</span>
<ChevronDown className={`h-4 w-4 transition-transform duration-300 ${optionsOpen ? 'rotate-180' : ''}`} />
<ChevronDown className={`h-4 w-4 transition-transform duration-150 ease-out-strong ${optionsOpen ? 'rotate-180' : ''}`} />
</Button>
</div>

Expand Down
2 changes: 1 addition & 1 deletion src/components/features/attack/AttackResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function WeaknessCard({ weakness }: { weakness: Weakness }) {
const Icon = config.icon;

return (
<div className={cn('rounded-lg border p-4 space-y-3 motion-list-item card-hover-glow', config.border, config.bg)}>
<div className={cn('rounded-lg border p-4 space-y-3 motion-list-item', config.border, config.bg)}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<Icon className={cn('h-5 w-5 mt-0.5', config.color)} />
Expand Down
4 changes: 2 additions & 2 deletions src/components/features/debate/DebateContextControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ function SourceRow({ source, onChange }: { source: ContextSourceItem; onChange:
const id = `debate-context-source-${source.id}`;
const note = source.included ? statusNote(source, t) : (!source.active ? t('debateContext.inactive', 'Turned off in your Library.') : null);
return (
<li className="flex items-start gap-3 rounded-md px-1 py-1.5">
<li className="flex items-start gap-3 rounded-md px-1 py-1.5 pointer-coarse:min-h-11">
<Checkbox id={id} checked={source.included} onCheckedChange={(checked) => onChange(checked === true)} className="mt-0.5" />
<label htmlFor={id} className="min-w-0 flex-1 cursor-pointer text-sm">
<span className="block break-words">{source.title}</span>
Expand All @@ -248,7 +248,7 @@ function OutputRow({ output, onChange, onPin, pinDisabled }: { output: ContextOu
const note = output.included ? statusNote(output, t) : null;
const kind = t(`debate.kinds.${output.ref.kind}`);
return (
<li className="flex items-start gap-3 rounded-md px-1 py-1.5">
<li className="flex items-start gap-3 rounded-md px-1 py-1.5 pointer-coarse:min-h-11">
<Checkbox id={id} checked={output.included} onCheckedChange={(checked) => onChange(checked === true)} className="mt-0.5" />
<label htmlFor={id} className="min-w-0 flex-1 cursor-pointer text-sm">
<span className="block break-words">{output.title || kind}</span>
Expand Down
4 changes: 2 additions & 2 deletions src/components/features/factoids/FactoidsResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR
const safeUrl = validateUrl(factoid.citation?.url);

return (
<div className="rounded-lg border border-border bg-card p-4 space-y-4 motion-list-item card-hover-glow">
<div className="rounded-lg border border-border bg-card p-4 space-y-4 motion-list-item">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
Expand All @@ -56,7 +56,7 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR
<h4 className="font-semibold text-foreground group-hover:text-primary transition-colors underline-offset-4 group-hover:underline text-left">
{factoid.shortClaim}
</h4>
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
<ExternalLink className="h-3 w-3 shrink-0 opacity-60 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" aria-hidden="true" />
</a>
) : (
<h4 className="font-semibold text-foreground">{factoid.shortClaim}</h4>
Expand Down
2 changes: 1 addition & 1 deletion src/components/features/generate/GenerateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene
<SlidersHorizontal className="h-4 w-4" />
{t('generateForm.moreOptions')}
</span>
<ChevronDown className={`h-4 w-4 transition-transform duration-300 ${advancedOpen ? 'rotate-180' : ''}`} />
<ChevronDown className={`h-4 w-4 transition-transform duration-150 ease-out-strong ${advancedOpen ? 'rotate-180' : ''}`} />
</Button>
</div>

Expand Down
2 changes: 1 addition & 1 deletion src/components/features/generate/GenerateResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface GenerateResultsProps {
function ArgumentCard({ contention, index }: { contention: Contention; index: number }) {
const { t } = useTranslation();
return (
<div className="rounded-lg border border-border bg-card p-4 space-y-3 motion-list-item card-hover-glow">
<div className="rounded-lg border border-border bg-card p-4 space-y-3 motion-list-item">
<div className="flex items-start justify-between gap-2">
<h4 className="font-semibold text-foreground">
<span className="text-primary mr-2">#{index + 1}</span>
Expand Down
6 changes: 3 additions & 3 deletions src/components/ui/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all duration-200 hover:text-primary [&[data-state=open]>svg]:rotate-180",
"flex flex-1 items-center justify-between py-4 font-medium transition-colors duration-150 hover:text-primary [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-300 ease-out" />
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-150 ease-out-strong" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
Expand All @@ -40,7 +40,7 @@ const AccordionContent = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ease-out-strong data-[state=open]:duration-200 data-[state=closed]:duration-120",
className,
)}
{...props}
Expand All @@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg ease-out-strong data-[state=open]:duration-200 data-[state=closed]:duration-120 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-1/2 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 motion-reduce:transition-none motion-reduce:hover:scale-100",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
{
variants: {
variant: {
Expand Down
12 changes: 6 additions & 6 deletions src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
"motion-press inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:transform-none disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"motion-press inline-flex pointer-coarse:min-h-11 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:transform-none disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
default: "motion-lift bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "motion-lift bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "motion-lift border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "motion-lift bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
icon: "h-10 w-10 pointer-coarse:min-w-11",
},
},
defaultVariants: {
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"peer relative h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5",
className,
)}
{...props}
Expand Down
Loading
Loading